Autocomplete a search bar

Offer suggestions from the first characters typed into a search bar.

No AI needed

FreeInstantYour data never leaves

The verdict

N0 answers, N1 only puts back in order what N0 found, and both levels fail on the same typo, as N1's own test demonstrates. Take N0, and add N1 the day your click log is thick enough to show that the term people pick is no longer the one at the top — thick enough, and not before: one single click there commands the ranking of every prefix it shares. The reranking only reads counters held in memory, with no call, but you take on a behavioural log to keep, and a counter that reinforces its own output.

Prefix tree, ordered by search frequency

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/add-autocomplete-to-a-search-bar/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Suggest as the user types: a prefix tree, ordered by how often a term is searched.

Rung N0. Standard library only, and the whole index is a nest of dictionaries
that fits in the memory of the process serving the search bar.

Two decisions carry the approach.

First, the tree is keyed on a normalised spelling, accents folded and case
dropped, while each leaf keeps the original one. Someone typing "ec" finds
"écharpe", and still reads it spelled properly in the drop-down.

Second, the ordering is a plain sort on the usage count, done once per node
and kept there. The empty bar reaches the whole index, a first letter a good
share of it, and every user asks for both: sorting them again at each
keystroke is where the time would go. The price is memory, at most one
reference per term for each node a keystroke has reached.
"""

import unicodedata

# Marks the terms that end at a node. It is not a string, so no character can
# collide with it.
END = object()

# Where a node keeps its ranked terms once a keystroke has asked for them.
RANKED = object()

# Categories M* are the accents NFKD detaches; Cf holds the invisible characters.
DROPPED = {"Mn", "Mc", "Me", "Cf"}
# NFKD has already turned non-breaking spaces into plain ones; these are the
# other spaces. The final sigma is folded by hand: JavaScript keeps it.
FOLD = str.maketrans("\t\n\v\f\rς", "     σ")


def normalise(text: str) -> str:
    """
    Fold case and strip accents, so that "ec" reaches "écharpe".

    Invisible characters (zero-width space, byte order mark) are dropped, and
    runs of spaces, non-breaking ones included, become one space with none at
    either end: what gets pasted into a search bar carries all of these.
    """
    # Upper then lower case folds "ß" into "ss", the same way in both languages.
    # "ẞ" is the capital of "ß" and upper-casing leaves it alone; written as
    # "ß" first, the pair folds to "ss" on both sides.
    folded = unicodedata.normalize("NFKD", text.replace("\u1e9e", "ß")).upper().lower().translate(FOLD)
    kept = "".join(c for c in folded if unicodedata.category(c) not in DROPPED)
    return " ".join(word for word in kept.split(" ") if word)


def build(entries) -> dict:
    """
    Build the tree from pairs of (term, how often it was searched).

    Terms sharing a normalised spelling are kept side by side at the same
    leaf, rather than one silently replacing the other.
    """
    root: dict = {}
    for term, count in entries:
        key = normalise(term)
        node = root
        for char in key:
            node = node.setdefault(char, {})
        node.setdefault(END, []).append((count, key, term))
    return root


def _descend(root: dict, prefix: str) -> dict:
    """The node holding everything that starts with `prefix`, empty if none does."""
    node = root
    for char in prefix:
        node = node.get(char, {})
    return node


def _collect(node: dict):
    """
    Every (count, key, term) stored under a node, in no particular order.

    A stack rather than recursion: one term of a hundred thousand characters in
    the search log would otherwise overflow the call stack.
    """
    stack = [node]
    while stack:
        for key, value in stack.pop().items():
            if key is END:
                yield from value
            elif key is not RANKED:
                stack.append(value)


def suggest(root: dict, prefix: str, limit: int = 5) -> list[str]:
    """
    The most searched terms starting with `prefix`, most searched first.

    An empty prefix returns the most searched terms overall, which is what an
    empty search bar should offer. An unknown prefix returns nothing: the tree
    answers about the characters it was given, not about the ones it guesses.
    """
    node = _descend(root, normalise(prefix))
    if RANKED not in node:
        # Sorted once per node, then read back. Equal counts fall back to the
        # normalised spelling, so "écharpe" comes before "zèbre".
        node[RANKED] = sorted(_collect(node), key=lambda entry: (-entry[0], entry[1], entry[2]))
    return [term for _, _, term in node[RANKED][:limit]]

JavaScript

snippets/add-autocomplete-to-a-search-bar/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Suggest as the user types: a prefix tree, ordered by how often a term is searched.
 *
 * Rung N0. No dependency, and the whole index is a nest of maps that fits in
 * the memory of the process serving the search bar.
 *
 * Two decisions carry the approach.
 *
 * First, the tree is keyed on a normalised spelling, accents folded and case
 * dropped, while each leaf keeps the original one. Someone typing "ec" finds
 * "écharpe", and still reads it spelled properly in the drop-down.
 *
 * Second, the ordering is a plain sort on the usage count, done once per node
 * and kept there. The empty bar reaches the whole index, a first letter a good
 * share of it, and every user asks for both: sorting them again at each
 * keystroke is where the time would go. The price is memory, at most one
 * reference per term for each node a keystroke has reached.
 */

// Marks the terms that end at a node. A character can never collide with it.
const END = Symbol('term');

// Where a node keeps its ranked terms once a keystroke has asked for them.
const RANKED = Symbol('ranked');

/**
 * Fold case and strip accents, so that "ec" reaches "écharpe".
 *
 * Invisible characters (zero-width space, byte order mark) are dropped, and
 * runs of spaces, non-breaking ones included, become one space with none at
 * either end: what gets pasted into a search bar carries all of these.
 */
export function normalise(text) {
  // Upper then lower case folds "ß" into "ss", the same way in both languages,
  // and the final sigma is folded by hand. \p{M} holds the accents NFKD
  // detaches, \p{Cf} the invisible characters; NFKD has already turned
  // non-breaking spaces into plain ones.
  // "ẞ" is the capital of "ß" and upper-casing leaves it alone; written as
  // "ß" first, the pair folds to "ss" on both sides.
  const folded = text.replace(/\u1e9e/g, 'ß').normalize('NFKD').toUpperCase().toLowerCase().replace(/ς/g, 'σ');
  const kept = folded.replace(/[\p{M}\p{Cf}]/gu, '').replace(/[\t\n\v\f\r]/g, ' ');
  return kept.split(' ').filter(Boolean).join(' ');
}

/**
 * Build the tree from pairs of [term, how often it was searched].
 *
 * Terms sharing a normalised spelling are kept side by side at the same leaf,
 * rather than one silently replacing the other.
 */
export function build(entries) {
  const root = new Map();
  for (const [term, count] of entries) {
    const key = normalise(term);
    let node = root;
    for (const char of key) node = node.get(char) ?? node.set(char, new Map()).get(char);
    if (!node.has(END)) node.set(END, []);
    node.get(END).push([count, key, term]);
  }
  return root;
}

/**
 * Every [count, key, term] stored under a node, in no particular order.
 *
 * A stack rather than recursion: one term of a hundred thousand characters in
 * the search log would otherwise overflow the call stack.
 */
function collect(root) {
  const found = [];
  const stack = [root];
  while (stack.length > 0) {
    for (const [key, value] of stack.pop()) {
      if (key === END) for (const entry of value) found.push(entry);
      else if (key !== RANKED) stack.push(value);
    }
  }
  return found;
}

const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);

/**
 * The most searched terms starting with `prefix`, most searched first.
 *
 * An empty prefix returns the most searched terms overall, which is what an
 * empty search bar should offer. An unknown prefix returns nothing: the tree
 * answers about the characters it was given, not about the ones it guesses.
 */
export function suggest(root, prefix, limit = 5) {
  // Walk down to the node holding everything that starts with the prefix; an
  // unknown prefix walks into an empty node, which holds nothing to suggest.
  let node = root;
  for (const char of normalise(prefix)) node = node.get(char) ?? new Map();
  if (!node.has(RANKED)) {
    // Sorted once per node, then read back. Equal counts fall back to the
    // normalised spelling, so "écharpe" comes before "zèbre".
    const found = collect(node);
    found.sort((a, b) => b[0] - a[0] || compare(a[1], b[1]) || compare(a[2], b[2]));
    node.set(RANKED, found);
  }
  return node.get(RANKED).slice(0, limit).map(([, , term]) => term);
}

Try it

interactive

The code above runs in your browser. Type in the box: the result is redone in front of you.

The search count is what the tree sorts on: it decides the order, and nothing else does.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

What it returns

SuggestionSearches over a fortnight
crème fraîche900
cream cheese540

2 suggestions out of the 7 terms in the log.

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the index lives inside the process serving the search bar, and the keystroke never leaves it

Breaking point

A typo, at any position: the tree only walks down the characters it is given, and the list empties from the offending key on. « echarpe » brings back « écharpe en laine », « rcharpe » and « echarpz » bring back nothing, and the correct spelling was in the index all along — a typo on the first character is the worst case, because nothing was ever shown. Second gap, more common on a catalogue of compound terms: typing the second word. « laine » does not bring back « écharpe en laine », because word suffixes are not indexed too.

When to move up a level

Your click log shows that the term people pick is not the one at the top of the list: search frequency ranks badly what they end up taking.

The four options

  1. RecommendedN0Prefix tree, ordered by search frequencyFree · Instant
  2. N1Reranking on counted past clicksnégligeable · Instant
  3. N2Not worth it here
  4. N3Not worth it here

The other options

N1Reranking on counted past clicksnégligeable · Instant

Reranking on counted past clicks

Lightweight classic modelN1négligeableInstant

Python

snippets/add-autocomplete-to-a-search-bar/n1.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Reorder the suggestions with what people actually clicked.

Rung N1. The prefix tree of N0 ranks candidates by how often a term is
searched. That count says what people looked for, not what they picked once
the drop-down opened. Clicks say the second, and they are already in the logs.

The model is a count, not a gradient: for every prefix that was ever typed,
how many times each suggestion was chosen. It trains in one pass over the log
and is read back with dictionary lookups, at most one per prefix length for
each candidate, on a query whose counted length is capped.

The candidates come in as an argument: this rung reorders a list, it does not
retrieve it.
"""

import unicodedata

# Categories M* are the accents NFKD detaches; Cf holds the invisible characters.
DROPPED = {"Mn", "Mc", "Me", "Cf"}
# NFKD has already turned non-breaking spaces into plain ones; these are the
# other spaces. The final sigma is folded by hand: JavaScript keeps it.
FOLD = str.maketrans("\t\n\v\f\rς", "     σ")

# Only the first characters of a query are counted. A pasted paragraph would
# otherwise store one key per prefix, each a copy of the prefix: the memory
# grows with the square of its length, and so does the lookup.
MAX_TYPED = 64


def normalise(text: str) -> str:
    """Same folding as the prefix tree, so both rungs agree on what was typed."""
    # Upper then lower case folds "ß" into "ss", the same way in both languages.
    # "ẞ" is the capital of "ß" and upper-casing leaves it alone; written as
    # "ß" first, the pair folds to "ss" on both sides.
    folded = unicodedata.normalize("NFKD", text.replace("\u1e9e", "ß")).upper().lower().translate(FOLD)
    kept = "".join(c for c in folded if unicodedata.category(c) not in DROPPED)
    return " ".join(word for word in kept.split(" ") if word)


def learn(clicks) -> dict:
    """
    Count clicks from pairs of (what was typed, which suggestion was clicked).

    One click teaches something about every prefix of what was typed, from the
    first letter on: whoever chose "chaussettes de sport" after typing "chau"
    also tells us what to show at "c" and at "cha". The empty prefix is left
    out on purpose: counted, one click made under any query at all would move
    its term to the top of every other query.

    Nothing here decays and nothing is windowed: a term that was clicked a lot
    two years ago keeps its lead for good.
    """
    model: dict = {}
    for typed, term in clicks:
        typed = normalise(typed)[:MAX_TYPED]
        for length in range(1, len(typed) + 1):
            key = (typed[:length], term)
            model[key] = model.get(key, 0) + 1
    return model


def _evidence(model: dict, prefix: str, term: str) -> int:
    """
    Clicks recorded for the longest prefix of the query that saw this term.

    Backing off matters: a rare prefix has too few clicks of its own, but it
    shares its first letters with past queries that do. The longer
    the matching prefix, the more specific the evidence, hence the weight.
    """
    for length in range(len(prefix), 0, -1):
        clicked = model.get((prefix[:length], term), 0)
        if clicked:
            return clicked * (length + 1)
    return 0


def rerank(model: dict, prefix: str, candidates, limit: int = 5) -> list[str]:
    """
    Sort candidates by past clicks, keeping their incoming order as tie-break.

    Candidates arrive ordered by search frequency, as the previous rung left
    them. A term nobody ever clicked keeps that order.

    What that does not make it is safe on a thin log. Clicks are counted, not
    weighed: one click recorded under "c" puts its term at the top of every
    query that starts with a "c", ahead of terms drawn from thousands of
    searches. And the clicks come from the list this very ranking produced —
    the top suggestions get clicked because they are at the top, and the count
    then hardens the order it was meant to correct.
    """
    prefix = normalise(prefix)[:MAX_TYPED]
    ranked = sorted(
        enumerate(candidates),
        key=lambda pair: (-_evidence(model, prefix, pair[1]), pair[0]),
    )
    return [term for _, term in ranked[:limit]]

JavaScript

snippets/add-autocomplete-to-a-search-bar/n1.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Reorder the suggestions with what people actually clicked.
 *
 * Rung N1. The prefix tree of N0 ranks candidates by how often a term is
 * searched. That count says what people looked for, not what they picked once
 * the drop-down opened. Clicks say the second, and they are already in the
 * logs.
 *
 * The model is a count, not a gradient: for every prefix that was ever typed,
 * how many times each suggestion was chosen. It trains in one pass over the
 * log and is read back with map lookups, at most one per prefix length for
 * each candidate, on a query whose counted length is capped.
 *
 * The candidates come in as an argument: this rung reorders a list, it does
 * not retrieve it.
 */

// Only the first characters of a query are counted. A pasted paragraph would
// otherwise store one key per prefix, each a copy of the prefix: the memory
// grows with the square of its length, and so does the lookup.
export const MAX_TYPED = 64;

/** Same folding as the prefix tree, so both rungs agree on what was typed. */
export function normalise(text) {
  // Upper then lower case folds "ß" into "ss", the same way in both languages,
  // and the final sigma is folded by hand. \p{M} holds the accents NFKD
  // detaches, \p{Cf} the invisible characters; NFKD has already turned
  // non-breaking spaces into plain ones.
  // "ẞ" is the capital of "ß" and upper-casing leaves it alone; written as
  // "ß" first, the pair folds to "ss" on both sides.
  const folded = text.replace(/\u1e9e/g, 'ß').normalize('NFKD').toUpperCase().toLowerCase().replace(/ς/g, 'σ');
  const kept = folded.replace(/[\p{M}\p{Cf}]/gu, '').replace(/[\t\n\v\f\r]/g, ' ');
  return kept.split(' ').filter(Boolean).join(' ');
}

// A map key has to be a single value. A tab never occurs inside a normalised
// prefix, since normalise turns it into a space, so the first tab splits the key.
const key = (prefix, term) => `${prefix}\t${term}`;

/**
 * Count clicks from pairs of [what was typed, which suggestion was clicked].
 *
 * One click teaches something about every prefix of what was typed, from the
 * first letter on: whoever chose "chaussettes de sport" after typing "chau"
 * also tells us what to show at "c" and at "cha". The empty prefix is left out
 * on purpose: counted, one click made under any query at all would move its
 * term to the top of every other query.
 *
 * Nothing here decays and nothing is windowed: a term that was clicked a lot two
 * years ago keeps its lead for good.
 */
export function learn(clicks) {
  const model = new Map();
  for (const [typed, term] of clicks) {
    const prefix = normalise(typed).slice(0, MAX_TYPED);
    for (let length = 1; length <= prefix.length; length += 1) {
      const at = key(prefix.slice(0, length), term);
      model.set(at, (model.get(at) ?? 0) + 1);
    }
  }
  return model;
}

/**
 * Clicks recorded for the longest prefix of the query that saw this term.
 *
 * Backing off matters: a rare prefix has too few clicks of its own, but it
 * shares its first letters with past queries that do. The longer
 * the matching prefix, the more specific the evidence, hence the weight.
 */
function evidence(model, prefix, term) {
  for (let length = prefix.length; length >= 1; length -= 1) {
    const clicked = model.get(key(prefix.slice(0, length), term)) ?? 0;
    if (clicked) return clicked * (length + 1);
  }
  return 0;
}

/**
 * Sort candidates by past clicks, keeping their incoming order as tie-break.
 *
 * Candidates arrive ordered by search frequency, as the previous rung left
 * them. A term nobody ever clicked keeps that order.
 *
 * What that does not make it is safe on a thin log. Clicks are counted, not
 * weighed: one click recorded under "c" puts its term at the top of every query
 * that starts with a "c", ahead of terms drawn from thousands of searches. And
 * the clicks come from the list this very ranking produced — the top
 * suggestions get clicked because they are at the top, and the count then
 * hardens the order it was meant to correct.
 */
export function rerank(model, prefix, candidates, limit = 5) {
  const typed = normalise(prefix).slice(0, MAX_TYPED);
  const scored = candidates.map((term, rank) => [evidence(model, typed, term), rank, term]);
  scored.sort((a, b) => b[0] - a[0] || a[1] - b[1]);
  return scored.slice(0, limit).map(([, , term]) => term);
}

What this means for you

  • Your data stays on your own machines
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Low footprint

What this adds to your regulatory scope

  • Processing of usage data on your own infrastructure: the log keeps what users typed, and the suggestion they picked next
  • A search query holds whatever the user puts into it: nothing in this approach filters it before counting it

Breaking point

Clicks are counted, not weighed. One single click recorded under « c » puts its term at the top of every query starting with a « c », ahead of terms drawn from thousands of searches: on a thin log, one robot click or one click made while testing overturns the ranking. And the clicks come from the list this very ranking produced — the top suggestions get clicked because they are at the top, and the count then hardens the order it was meant to correct. Witness: a click made under an unrelated query, « zzz », moves nothing.

When to move up a level

There is no level above this one. What is still missing, tolerance to typos, is fixed in retrieval rather than in ranking: a bounded edit distance on the prefix, which index engines already offer — the `fuzzy` option of Elasticsearch’s `completion` suggester, for one.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

A self-hosted model costs a permanent service to run and a call on every key, to propose terms that already belong to a closed set: the prefix tree pulls them straight out of process memory.

N3Not worth it here

Not worth it here

General-purpose LLM APIN3

A billed network call on every typing pause, to propose terms the index already holds and which form a closed set. Nothing a model would add is in that set.

Further reading

Metadata