Browse sections

root@kaus.com:~/blog$ cat how-site-search-works.md

Architecture Notes4 min read

The loop hidden inside a search optimization

Caching trigram vectors made this site's search cheaper. It did not remove the document scan. A closer look at the implementation changed how I explain both the speedup and the search itself.

Updated

The key idea

Explain an optimization at the level where it actually applies, and keep the remaining work in the cost model.

Kbit · page guide
  1. 1 / 3

    The article is the primary path.

    Read top to bottom; figures and code-linked examples stay inside the narrative.

  2. 2 / 3

    Continue through related work.

    Use related writing or project links to keep the context connected.

  3. 3 / 3

    Return to the index at any point.

    Open the full Writing index to browse more topics.

An optimization with an extra loop around it

The first version of this article said the search optimization reduced per-keystroke work to O(query trigrams). That description left out a loop: the search still compares the query with the documents in the corpus. The smaller cost belongs to an individual vector comparison, not to the whole search request.

This matters because the implementation is otherwise a reasonable fit for the site. I wanted visitors to find projects by name, technology, or a term buried in a README, without running a search service. The site exports static files. Its content travels to the browser, and the browser performs the search. The useful question is how much work can move away from typing time while keeping the result understandable.

Static content still needs a runtime index

The content records and ingested README text are bundled with the application. The basic index is assembled when the search module evaluates, and the browser builds the close-match vectors lazily. An idle callback can warm those vectors after the search component mounts. This is different from shipping a precomputed vector index produced by the static export.

That distinction locates the costs. The browser still parses the content and constructs lookup structures. Caching lets later queries reuse them, and idle warming can move construction before the first query. Neither makes that work disappear. The cache also occupies memory for as long as the module remains loaded.

The ordinary tier handles direct intent. A full name match outranks a name prefix, which outranks weaker substring and body matches. A visitor who knows a project's name should not have to compete with every incidental mention of the same phrase in a long article.

Why a README needs more than cosine similarity

The supplementary tier breaks text into overlapping character trigrams and counts them. Cosine similarity compares the resulting vectors after accounting for their magnitudes. That works well when similarly sized texts share vocabulary. It can underweight a short, specific query contained in a long README because the rest of the document increases its magnitude.

The implementation therefore takes the larger of cosine similarity and query containment. Containment asks how much of the query's trigram mass appears in the document. It gives a short phrase a way to survive comparison with a much longer text. Field text and README text are also scored separately so a result can say when its match came from the README.

The tradeoff is broader lexical matching. Shared character fragments do not establish that a document answers a question, and containment does not require those fragments to occur together as a phrase. The score threshold and small result cap control presentation, but they do not turn string overlap into semantic understanding.

Simplified scoring and cost model, not executable application codetext
query = vectorize(expandAliases(input))
for each eligible document:
    score = max(cosine(query, document), containment(query, document))

D eligible documents, Q distinct expanded-query trigrams:
vector comparisons: O(D × Q), before sorting
exact tier: separate string matching over the corpus

An alias map is an editorial decision

The alias table connects terms visitors might type to vocabulary used in the records. For example, ml expands to machine learning and rl expands to reinforcement learning. These mappings are explicit and directional. A deep learning query expands toward neural network, machine learning, and pytorch. A neural network query has its own mapping toward neat and neuroevolution. The table is not a symmetric thesaurus.

That makes the vocabulary maintainable at this scale. It also means every added alias changes retrieval behavior. An expansive mapping can help discovery while bringing in projects a visitor did not intend. I can inspect and revise those choices because they are data, rather than describe the system as understanding arbitrary technical synonyms.

Keep the denominator in the explanation

Document vectors and their magnitudes are reused. Each similarity calculation walks the short query vector and looks up matching entries in the document vector. The exact tier also reuses lowercased fields. These changes remove repeated document preparation from the typing path. The remaining document scan, string matching, and result sorting still belong in the explanation.

I have not established a latency benchmark here, so this is a source-level account of avoided work, not a measured speedup claim. For a bounded portfolio corpus, the implementation keeps deployment simple and the ranking inspectable. If the corpus grows, the useful next measurement is end-to-end query latency and index construction cost. An accurate cost model tells me what to measure before replacing the architecture.

Sources & further reading

  1. Explore the searchable project corpus

    The project records and case studies supply part of the browser's search corpus.