Intermediate

Step 5: Search Interface

In this lesson, you will build a complete search interface with autocomplete suggestions, search mode selection, faceted category filters, highlighted result snippets, and pagination. The entire UI is a single HTML file with embedded CSS and JavaScript — no framework required.

Autocomplete API Endpoint

First, add an autocomplete endpoint to the FastAPI backend. It uses the edge_ngram analyzer we set up in the Elasticsearch mapping:

# Add to app/main.py

@app.get("/api/autocomplete")
async def autocomplete(q: str, limit: int = 5):
    """Return autocomplete suggestions based on title prefix matching.

    Uses the edge_ngram analyzer configured in the Elasticsearch mapping
    to match partial words as the user types.
    """
    from app.elasticsearch.client import SearchClient
    client = SearchClient()

    search_body = {
        "query": {
            "match": {
                "title.autocomplete": {
                    "query": q,
                    "operator": "and"
                }
            }
        },
        "_source": ["title", "category"],
        "size": limit,
        "highlight": {
            "fields": {
                "title.autocomplete": {
                    "number_of_fragments": 1
                }
            },
            "pre_tags": ["<strong>"],
            "post_tags": ["</strong>"]
        }
    }

    response = client.es.search(
        index=client.index_name,
        body=search_body
    )

    suggestions = []
    for hit in response["hits"]["hits"]:
        suggestions.append({
            "title": hit["_source"]["title"],
            "category": hit["_source"].get("category", ""),
            "highlight": hit.get("highlight", {}).get("title.autocomplete", [None])[0]
        })

    return {"suggestions": suggestions}


@app.get("/api/facets")
async def get_facets():
    """Return available facet values (categories and tags) with counts."""
    from app.elasticsearch.client import SearchClient
    client = SearchClient()

    search_body = {
        "size": 0,
        "aggs": {
            "categories": {
                "terms": {
                    "field": "category",
                    "size": 50
                }
            },
            "tags": {
                "terms": {
                    "field": "tags",
                    "size": 100
                }
            }
        }
    }

    response = client.es.search(
        index=client.index_name,
        body=search_body
    )

    categories = [
        {"name": bucket["key"], "count": bucket["doc_count"]}
        for bucket in response["aggregations"]["categories"]["buckets"]
    ]
    tags = [
        {"name": bucket["key"], "count": bucket["doc_count"]}
        for bucket in response["aggregations"]["tags"]["buckets"]
    ]

    return {"categories": categories, "tags": tags}

The Complete Search UI

Save this as frontend/index.html. It is a self-contained search interface with all CSS and JavaScript inline:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>AI Search Engine</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      background: #f5f5f5; color: #333; line-height: 1.6;
    }

    /* Search Header */
    .search-header {
      background: linear-gradient(135deg, #6366f1, #8b5cf6);
      padding: 3rem 1rem 2rem; text-align: center; color: white;
    }
    .search-header h1 { font-size: 2rem; margin-bottom: 1rem; }

    /* Search Input */
    .search-container { position: relative; max-width: 640px; margin: 0 auto; }
    .search-input {
      width: 100%; padding: 1rem 1rem 1rem 3rem;
      font-size: 1.1rem; border: none; border-radius: 12px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.15); outline: none;
    }
    .search-icon-input {
      position: absolute; left: 1rem; top: 50%;
      transform: translateY(-50%); font-size: 1.2rem; opacity: 0.5;
    }

    /* Autocomplete Dropdown */
    .autocomplete-list {
      position: absolute; top: 100%; left: 0; right: 0;
      background: white; border-radius: 0 0 12px 12px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.1);
      max-height: 300px; overflow-y: auto; display: none; z-index: 100;
    }
    .autocomplete-item {
      padding: 0.75rem 1rem; cursor: pointer;
      border-bottom: 1px solid #f0f0f0;
    }
    .autocomplete-item:hover { background: #f0f0ff; }
    .autocomplete-item .category {
      font-size: 0.75rem; color: #888; margin-top: 2px;
    }

    /* Mode Selector */
    .mode-selector {
      display: flex; gap: 0.5rem; justify-content: center;
      margin-top: 1rem;
    }
    .mode-btn {
      padding: 0.4rem 1rem; border: 2px solid rgba(255,255,255,0.3);
      border-radius: 20px; background: transparent; color: white;
      cursor: pointer; font-size: 0.85rem; transition: all 0.2s;
    }
    .mode-btn.active { background: white; color: #6366f1; border-color: white; }

    /* Main Content */
    .main { max-width: 960px; margin: 2rem auto; padding: 0 1rem; }

    /* Results Info */
    .results-info {
      display: flex; justify-content: space-between; align-items: center;
      margin-bottom: 1rem; color: #666; font-size: 0.9rem;
    }

    /* Facets */
    .facets {
      display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.5rem;
    }
    .facet-btn {
      padding: 0.3rem 0.8rem; border: 1px solid #ddd; border-radius: 20px;
      background: white; cursor: pointer; font-size: 0.8rem; transition: all 0.2s;
    }
    .facet-btn:hover { border-color: #6366f1; color: #6366f1; }
    .facet-btn.active { background: #6366f1; color: white; border-color: #6366f1; }
    .facet-count { font-size: 0.7rem; color: #999; margin-left: 4px; }

    /* Result Card */
    .result-card {
      background: white; border-radius: 12px; padding: 1.5rem;
      margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
      transition: box-shadow 0.2s;
    }
    .result-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
    .result-title {
      font-size: 1.2rem; font-weight: 600; color: #6366f1;
      margin-bottom: 0.5rem;
    }
    .result-title a { color: inherit; text-decoration: none; }
    .result-title a:hover { text-decoration: underline; }
    .result-snippet { color: #555; font-size: 0.95rem; margin-bottom: 0.75rem; }
    .result-snippet mark { background: #fef08a; padding: 1px 2px; border-radius: 2px; }
    .result-meta { display: flex; gap: 1rem; font-size: 0.8rem; color: #999; }
    .result-meta .tag {
      background: #f0f0ff; color: #6366f1; padding: 2px 8px;
      border-radius: 10px; font-size: 0.75rem;
    }
    .result-score { font-size: 0.75rem; color: #bbb; }

    /* Pagination */
    .pagination { display: flex; justify-content: center; gap: 0.5rem; margin: 2rem 0; }
    .page-btn {
      padding: 0.5rem 1rem; border: 1px solid #ddd; border-radius: 8px;
      background: white; cursor: pointer; transition: all 0.2s;
    }
    .page-btn:hover { border-color: #6366f1; color: #6366f1; }
    .page-btn.active { background: #6366f1; color: white; border-color: #6366f1; }
    .page-btn:disabled { opacity: 0.5; cursor: not-allowed; }

    /* Loading */
    .loading { text-align: center; padding: 3rem; color: #999; }
    .spinner {
      width: 40px; height: 40px; border: 4px solid #f0f0f0;
      border-top-color: #6366f1; border-radius: 50%;
      animation: spin 0.8s linear infinite; margin: 0 auto 1rem;
    }
    @keyframes spin { to { transform: rotate(360deg); } }

    /* Empty State */
    .empty-state { text-align: center; padding: 4rem 1rem; color: #999; }
    .empty-state .icon { font-size: 4rem; margin-bottom: 1rem; }
  </style>
</head>
<body>
  <div class="search-header">
    <h1>AI Search Engine</h1>
    <div class="search-container">
      <span class="search-icon-input">&#128269;</span>
      <input type="text" class="search-input" id="searchInput"
             placeholder="Search articles..." autocomplete="off">
      <div class="autocomplete-list" id="autocompleteList"></div>
    </div>
    <div class="mode-selector">
      <button class="mode-btn active" data-mode="hybrid">Hybrid</button>
      <button class="mode-btn" data-mode="keyword">Keyword</button>
      <button class="mode-btn" data-mode="semantic">Semantic</button>
    </div>
  </div>

  <div class="main">
    <div id="facetsContainer" class="facets"></div>
    <div id="resultsInfo" class="results-info"></div>
    <div id="resultsContainer">
      <div class="empty-state">
        <div class="icon">&#128269;</div>
        <p>Type a query to start searching</p>
      </div>
    </div>
    <div id="paginationContainer" class="pagination"></div>
  </div>

  <script>
    const API = '';  // Same origin
    let currentMode = 'hybrid';
    let currentPage = 1;
    let currentCategory = null;
    let debounceTimer = null;

    // --- Mode Selector ---
    document.querySelectorAll('.mode-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        currentMode = btn.dataset.mode;
        if (document.getElementById('searchInput').value.trim()) {
          performSearch();
        }
      });
    });

    // --- Autocomplete ---
    const searchInput = document.getElementById('searchInput');
    const autocompleteList = document.getElementById('autocompleteList');

    searchInput.addEventListener('input', () => {
      clearTimeout(debounceTimer);
      const query = searchInput.value.trim();
      if (query.length < 2) {
        autocompleteList.style.display = 'none';
        return;
      }
      debounceTimer = setTimeout(() => fetchAutocomplete(query), 200);
    });

    searchInput.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        autocompleteList.style.display = 'none';
        currentPage = 1;
        performSearch();
      }
    });

    document.addEventListener('click', (e) => {
      if (!e.target.closest('.search-container')) {
        autocompleteList.style.display = 'none';
      }
    });

    async function fetchAutocomplete(query) {
      try {
        const res = await fetch(`${API}/api/autocomplete?q=${encodeURIComponent(query)}`);
        const data = await res.json();
        renderAutocomplete(data.suggestions);
      } catch (err) { console.error('Autocomplete error:', err); }
    }

    function renderAutocomplete(suggestions) {
      if (!suggestions.length) {
        autocompleteList.style.display = 'none';
        return;
      }
      autocompleteList.innerHTML = suggestions.map(s => `
        <div class="autocomplete-item" data-title="${s.title}">
          <div>${s.highlight || s.title}</div>
          <div class="category">${s.category}</div>
        </div>
      `).join('');
      autocompleteList.style.display = 'block';

      autocompleteList.querySelectorAll('.autocomplete-item').forEach(item => {
        item.addEventListener('click', () => {
          searchInput.value = item.dataset.title;
          autocompleteList.style.display = 'none';
          currentPage = 1;
          performSearch();
        });
      });
    }

    // --- Facets ---
    async function loadFacets() {
      try {
        const res = await fetch(`${API}/api/facets`);
        const data = await res.json();
        renderFacets(data.categories);
      } catch (err) { console.error('Facets error:', err); }
    }

    function renderFacets(categories) {
      const container = document.getElementById('facetsContainer');
      container.innerHTML = `
        <button class="facet-btn ${!currentCategory ? 'active' : ''}"
                data-category="">All</button>
      ` + categories.map(c => `
        <button class="facet-btn ${currentCategory === c.name ? 'active' : ''}"
                data-category="${c.name}">
          ${c.name}<span class="facet-count">(${c.count})</span>
        </button>
      `).join('');

      container.querySelectorAll('.facet-btn').forEach(btn => {
        btn.addEventListener('click', () => {
          currentCategory = btn.dataset.category || null;
          currentPage = 1;
          container.querySelectorAll('.facet-btn').forEach(b => b.classList.remove('active'));
          btn.classList.add('active');
          if (searchInput.value.trim()) performSearch();
        });
      });
    }

    // --- Search ---
    async function performSearch() {
      const query = searchInput.value.trim();
      if (!query) return;

      const container = document.getElementById('resultsContainer');
      container.innerHTML = '<div class="loading"><div class="spinner"></div>Searching...</div>';

      try {
        let url = `${API}/api/search?q=${encodeURIComponent(query)}&mode=${currentMode}&page=${currentPage}&top_k=10`;
        if (currentCategory) url += `&category=${encodeURIComponent(currentCategory)}`;

        const res = await fetch(url);
        const data = await res.json();
        renderResults(data);
      } catch (err) {
        container.innerHTML = `<div class="empty-state"><p>Search failed: ${err.message}</p></div>`;
      }
    }

    function renderResults(data) {
      const container = document.getElementById('resultsContainer');
      const info = document.getElementById('resultsInfo');

      if (!data.results || !data.results.length) {
        info.textContent = '';
        container.innerHTML = '<div class="empty-state"><div class="icon">&#128533;</div><p>No results found</p></div>';
        return;
      }

      info.innerHTML = `
        <span>${data.total} results for "${data.query}" </span>
        <span>Mode: ${data.mode}${data.reranked ? ' + re-ranking' : ''}</span>
      `;

      container.innerHTML = data.results.map(r => {
        const title = r.highlights?.title?.[0] || r.source.title;
        const snippet = r.highlights?.body?.[0] || r.source.body?.substring(0, 200) + '...';
        const tags = (r.source.tags || []).map(t => `<span class="tag">${t}</span>`).join('');

        return `
          <div class="result-card">
            <div class="result-title"><a href="${r.source.url || '#'}">${title}</a></div>
            <div class="result-snippet">${snippet}</div>
            <div class="result-meta">
              <span>${r.source.category || ''}</span>
              ${tags}
              <span class="result-score">Score: ${r.score?.toFixed(4) || 'N/A'}</span>
            </div>
          </div>
        `;
      }).join('');

      renderPagination(data.total);
    }

    function renderPagination(total) {
      const container = document.getElementById('paginationContainer');
      const totalPages = Math.ceil(total / 10);
      if (totalPages <= 1) { container.innerHTML = ''; return; }

      let html = `<button class="page-btn" ${currentPage === 1 ? 'disabled' : ''}
                          onclick="goToPage(${currentPage - 1})">&larr; Prev</button>`;
      for (let i = 1; i <= Math.min(totalPages, 10); i++) {
        html += `<button class="page-btn ${i === currentPage ? 'active' : ''}"
                         onclick="goToPage(${i})">${i}</button>`;
      }
      html += `<button class="page-btn" ${currentPage === totalPages ? 'disabled' : ''}
                       onclick="goToPage(${currentPage + 1})">Next &rarr;</button>`;
      container.innerHTML = html;
    }

    function goToPage(page) {
      currentPage = page;
      performSearch();
      window.scrollTo({ top: 0, behavior: 'smooth' });
    }

    // --- Initialize ---
    loadFacets();
    searchInput.focus();
  </script>
</body>
</html>

UI Features Breakdown

Autocomplete with Debouncing

searchInput.addEventListener('input', () => {
    clearTimeout(debounceTimer);
    const query = searchInput.value.trim();
    if (query.length < 2) return;  // Wait for at least 2 characters
    debounceTimer = setTimeout(() => fetchAutocomplete(query), 200);  // 200ms debounce
});

Debouncing waits 200ms after the user stops typing before sending the autocomplete request. This prevents hammering the API with a request for every keystroke.

Faceted Category Filters

The facets endpoint uses Elasticsearch aggregations to count documents per category. Clicking a category button adds a filter parameter to the search query without changing the search terms.

Highlighted Snippets

Elasticsearch returns highlighted fragments with <mark> tags around matched terms. The UI renders these directly so users can see why each result matched their query. For semantic search (which does not produce keyword highlights), the UI falls back to the first 200 characters of the body.

Pagination

The from_offset parameter in the search API controls pagination. Page 2 with top_k=10 translates to from_offset=10. The UI shows up to 10 page buttons and smoothly scrolls to the top when navigating.

Keyboard Shortcuts

Add keyboard navigation for power users:

// Add to the script section of frontend/index.html

// Focus search with '/' key (like GitHub)
document.addEventListener('keydown', (e) => {
    if (e.key === '/' && document.activeElement !== searchInput) {
        e.preventDefault();
        searchInput.focus();
    }
    if (e.key === 'Escape') {
        autocompleteList.style.display = 'none';
        searchInput.blur();
    }
});
💡
Accessibility Tip: Add aria-live="polite" to the results container so screen readers announce when new results appear. Add role="listbox" to the autocomplete dropdown and role="option" to each suggestion for keyboard navigation support.

Key Takeaways

  • Debounced autocomplete prevents excessive API calls while feeling responsive to the user.
  • Faceted filters use Elasticsearch aggregations to show category counts and narrow results.
  • The search mode selector lets users switch between keyword, semantic, and hybrid search.
  • Highlighted snippets show users exactly why each result matched their query.
  • The entire UI is a single HTML file with no build tools or framework dependencies.

What Is Next

The search engine is feature-complete. In the next lesson, you will deploy and scale the entire system with Docker, add Redis caching, configure query analytics, and optimize for production traffic.