Autocomplete

Last updated:

LumoSearch autocomplete provides instant search-as-you-type suggestions using prefix-aware indexes. It returns relevance-ranked results as the user types, handling partial words and typos via the same BM25F scoring pipeline as full search — with sub-millisecond response times for up to 100K documents.

Basic Usage

import { LumoSearch } from '@lumosearch/search'

const docs = [
  { title: 'JavaScript Fundamentals' },
  { title: 'Java Programming' },
  { title: 'Python Basics' }
]

const search = new LumoSearch(docs, {
  keys: ['title']
})

// As user types "jav"
const suggestions = search.autocomplete('jav', { limit: 5 })
// Returns: ["JavaScript Fundamentals", "Java Programming"]

How It Works

Autocomplete uses the same scoring pipeline as search(), but optimized for prefix matching:

  • Prefix indexes enable fast retrieval of terms starting with the query
  • Token indexes catch exact matches
  • Trigram indexes handle fuzzy prefix matching
  • Results are ranked by relevance, not just alphabetical order

React Integration

import { useState, useMemo } from 'react'
import { LumoSearch } from '@lumosearch/search'

function SearchBox({ docs }) {
  const [query, setQuery] = useState('')

  const search = useMemo(
    () => new LumoSearch(docs, {
      keys: [{ name: 'title', weight: 3 }]
    }),
    [docs]
  )

  const suggestions = query.length > 0
    ? search.autocomplete(query, { limit: 10 })
    : []

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {suggestions.length > 0 && (
        <ul>
          {suggestions.map((result) => (
            <li key={result.refIndex}>
              {result.item.title}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

Debouncing

For better performance, debounce autocomplete queries:

import { useState, useMemo, useEffect } from 'react'
import { LumoSearch } from '@lumosearch/search'

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay)
    return () => clearTimeout(timer)
  }, [value, delay])

  return debouncedValue
}

function SearchBox({ docs }) {
  const [query, setQuery] = useState('')
  const debouncedQuery = useDebounce(query, 150)

  const search = useMemo(
    () => new LumoSearch(docs, { keys: ['title'] }),
    [docs]
  )

  const suggestions = debouncedQuery.length > 0
    ? search.autocomplete(debouncedQuery, { limit: 10 })
    : []

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {suggestions.length > 0 && (
        <ul>
          {suggestions.map((result) => (
            <li key={result.refIndex}>
              {result.item.title}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

Highlighting Matches

function HighlightedSuggestion({ result }) {
  const highlight = result.highlights[0]
  if (!highlight) return <span>{result.item.title}</span>

  const { value, indices } = highlight
  const parts = []
  let lastIndex = 0

  indices.forEach(([start, end]) => {
    // Text before match
    if (start > lastIndex) {
      parts.push(
        <span key={lastIndex}>{value.slice(lastIndex, start)}</span>
      )
    }
    // Matched text
    parts.push(
      <strong key={start}>{value.slice(start, end)}</strong>
    )
    lastIndex = end
  })

  // Text after last match
  if (lastIndex < value.length) {
    parts.push(<span key={lastIndex}>{value.slice(lastIndex)}</span>)
  }

  return <span>{parts}</span>
}

function SearchBox({ docs }) {
  const [query, setQuery] = useState('')
  const search = useMemo(
    () => new LumoSearch(docs, { keys: ['title'] }),
    [docs]
  )

  const suggestions = query.length > 0
    ? search.autocomplete(query, { limit: 10 })
    : []

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {suggestions.length > 0 && (
        <ul>
          {suggestions.map((result) => (
            <li key={result.refIndex}>
              <HighlightedSuggestion result={result} />
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

Keyboard Navigation

function SearchBox({ docs, onSelect }) {
  const [query, setQuery] = useState('')
  const [selectedIndex, setSelectedIndex] = useState(-1)

  const search = useMemo(
    () => new LumoSearch(docs, { keys: ['title'] }),
    [docs]
  )

  const suggestions = query.length > 0
    ? search.autocomplete(query, { limit: 10 })
    : []

  const handleKeyDown = (e) => {
    if (suggestions.length === 0) return

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setSelectedIndex((i) =>
          i < suggestions.length - 1 ? i + 1 : i
        )
        break
      case 'ArrowUp':
        e.preventDefault()
        setSelectedIndex((i) => (i > 0 ? i - 1 : -1))
        break
      case 'Enter':
        e.preventDefault()
        if (selectedIndex >= 0) {
          onSelect(suggestions[selectedIndex].item)
          setQuery('')
          setSelectedIndex(-1)
        }
        break
      case 'Escape':
        setQuery('')
        setSelectedIndex(-1)
        break
    }
  }

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => {
          setQuery(e.target.value)
          setSelectedIndex(-1)
        }}
        onKeyDown={handleKeyDown}
        placeholder="Search..."
      />
      {suggestions.length > 0 && (
        <ul>
          {suggestions.map((result, index) => (
            <li
              key={result.refIndex}
              onClick={() => onSelect(result.item)}
              className={selectedIndex === index ? 'selected' : ''}
            >
              {result.item.title}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

Grouped Suggestions

Group autocomplete results by category:

function SearchBox({ docs }) {
  const [query, setQuery] = useState('')

  const search = useMemo(
    () => new LumoSearch(docs, {
      keys: ['title', 'category']
    }),
    [docs]
  )

  const suggestions = query.length > 0
    ? search.autocomplete(query, { limit: 20 })
    : []

  // Group by category
  const grouped = suggestions.reduce((acc, result) => {
    const cat = result.item.category
    if (!acc[cat]) acc[cat] = []
    acc[cat].push(result)
    return acc
  }, {})

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {Object.keys(grouped).length > 0 && (
        <div>
          {Object.entries(grouped).map(([category, results]) => (
            <div key={category}>
              <h3>{category}</h3>
              <ul>
                {results.map((result) => (
                  <li key={result.refIndex}>
                    {result.item.title}
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

Performance Tips

  • Always debounce user input (150-300ms is a good range)
  • Cache the LumoSearch instance with useMemo in React
  • Keep limit low (5-10 suggestions)
  • Consider showing suggestions only after 2-3 characters
  • Use a web worker for large datasets

Minimum Query Length

const MIN_QUERY_LENGTH = 2

const suggestions = query.length >= MIN_QUERY_LENGTH
  ? search.autocomplete(query, { limit: 10 })
  : []

Frequently Asked Questions

How does LumoSearch autocomplete work?

LumoSearch autocomplete uses prefix indexes to match partial words as the user types. Typing 'jav' instantly returns 'JavaScript Fundamentals' and 'Java Programming'. Results are ranked by BM25F relevance, not alphabetically, so the most relevant suggestions appear first.

Should I debounce autocomplete queries?

Yes. Debouncing with 150-300ms delay prevents unnecessary computation on every keystroke. In React, combine useDebounce with useMemo to cache the search instance and only recompute suggestions after the user pauses typing.

How many autocomplete suggestions should I show?

Show 5-10 suggestions for the best user experience. More suggestions increase cognitive load without helping users find what they need faster. Set limit: 5 or limit: 10 in the autocomplete options.

Related