Field guide · Nº 22

Finding Things

A field guide to search, from keywords to vectors

Everything in search — stemming rules, posting lists, thousand-dimension embeddings — is machinery for closing the gap between the words a person typed and the thing they were looking for. This guide builds that machinery from both sides, literal and semantic, then joins them — because retrieval is now the floor under every AI answer, and most model problems turn out to be search problems.

Module 01 Not a database query

You have almost certainly shipped this: a text box, a WHERE name LIKE '%' || :q || '%', and a results list. For an internal tool over four hundred rows it works, and nobody complains. Then real users arrive, type real sentences, and the thing falls over in a way that is genuinely confusing — because it still returns results, they are just the wrong ones, in the wrong order, and sometimes there are none at all for a query that obviously should have matched.

This module names the discontinuity precisely. A database predicate and a search query are not the same kind of object: one has a correct answer set defined by the query text, and one is evidence of an intent the system has to infer. Everything the rest of this course builds — analyzers, posting lists, scoring functions, embeddings, rerankers — exists to do that inference well. Before any of that machinery makes sense, you need the two-stage anatomy it hangs on, and you need to stop thinking of ranking as a garnish on top of matching.

The question you asked vs. the question you meant

A cellar owner types barolo tannins into your wine app. Consider what a SQL engine does with that string and what a person means by it. The engine, given WHERE note LIKE '%barolo tannins%', has exactly one correct answer: the set of rows whose note contains that literal substring, in that order, with that spacing. In this corpus the answer is the empty set, and the engine is not wrong. It answered the question it was asked, perfectly.

The person meant something like: show me Barolo, or wines built like Barolo, and I care about structure — I am shopping for tannin, not for the word tannin. That intent is not recoverable from the string by any amount of parsing. It is recoverable, partially and probabilistically, from the string plus a model of how people write, a model of what is in the corpus, and a willingness to return things that only approximately qualify. That is the whole discipline.

So the useful frame is evidentiary rather than declarative. A query is not a specification; it is testimony about a need, given by a witness who is in a hurry, does not know your vocabulary, and will not be cross-examined. Your job is inference under ambiguity — deciding what the witness probably wanted and ordering the corpus by how well each document serves it. Relevance is the name for that fit, and unlike a predicate it is a judgment: it can be argued about, it varies by user and context, and it can only be settled by evidence about outcomes.

The load-bearing idea

A database predicate defines its own correct answer. A search query does not — the correct answer lives in the user's head, and the system's output is an inference about it. Every technique in this course is a way of making that inference less wrong.

Ranking is the product

Suppose matching is solved. A user searches a 40,000-bottle catalogue for red wine and 3,000 documents qualify. Now what? The match set is a fact about the corpus and worth almost nothing to the user, who will look at somewhere between three and ten results and then either click or leave. The entire product — the thing they experience as "this search is good" or "this search is garbage" — is which three documents came first.

This is why the two-stage anatomy matters. Stage one, matching, is a cheap set operation that narrows a corpus to candidates. Stage two, ranking, spends the real effort deciding their order. Matching is a solved problem in the sense that a good data structure makes it fast and boring; ranking is where every remaining decision, every tuning knob, every regression, and every argument with your stakeholders lives.

Side-by-side: one user need routed through a SQL predicate versus through a two-stage search engine of matching then rankingUser need“a structured, age-worthy red”Database predicatenote LIKE '%structured red%'answer set: { } — 0 rowsexact, unordered, completecorrect by definitionand useless to the userone stage: qualify or notSearch engineStage 1 · matching3,000 candidates · cheap, boringStage 2 · rankingorder the top 10 · all the qualityd1, d2, d5, … — ordered, partial, arguable
Figure 1.1 — Two stages, one of which is the product. The predicate has exactly one answer and no notion of better; it is complete and unordered. The search engine splits the work: matching narrows cheaply to candidates, and ranking spends the effort deciding the order the user will actually see. Every later module in this course attaches to one of these two boxes — m2 and m4 to matching, m3 and m6 through m8 to ranking.

Two systems can return the identical 200 documents and be a delight and a disaster respectively. That sentence is the whole argument, and it is worth saying out loud to any stakeholder who proposes measuring search by whether it "finds the thing" — it always finds the thing, somewhere on page nine.

Why LIKE is not search

Name the anti-pattern precisely so you can recognize it in a design doc. LIKE-as-search is shipping a substring predicate as the search feature of a product where users type freely. It fails in four structural ways, none of which is fixable by tuning the database.

It cannot rank. A predicate returns a set. Whatever order you see is the order the storage engine happened to produce, which means the first result is an accident. It cannot analyze. Substring matching has no concept of a word: %oak% matches "oaky" (good, by luck) and "Oakland" (bad, by the same luck), while %tannins% misses a note that says "tannin" and %rose% misses "Rosé" unless your collation happens to save you. It cannot tolerate error. barollo returns nothing, forever, with no signal to anyone that a customer just failed to find a $89 bottle. It cannot scale. A leading wildcard defeats a B-tree index, so every query is a full scan — and the moment somebody notices the latency, the team reaches for a database index, which fixes the one problem the users were not complaining about.

Symptom and corrective

Symptom: complaints cluster as "it found nothing when I know we sell that," "the results are in a weird order," and "I had to type it exactly right." No error appears in any log, because nothing errored. Corrective: move the text to a derived index with a real analyzer and a scoring function — that is m2 and m3 — and keep the database as the system of record.

Be equally precise about when the predicate is right. If the field is an identifier, the user is an operator, the corpus is small, and the interaction is a lookup rather than a search — an admin panel that fetches a customer by exact account ID, a support tool that pulls a case number — then a predicate is not a degraded search engine, it is the correct tool. It is exact, auditable, has no index to fall out of date, and returns nothing when nothing matches, which is the honest answer. The mistake is not using LIKE; the mistake is using it where users type prose.

Cross-reference · Guide Nº 06, Relational data modeling

The relationship to hold onto: the database is the system of record and the search index is a derived, query-optimized projection of it. That framing pays for itself twice in this course — it explains why analyzer changes require reindexing (m2) and why a derived copy silently drifts from its source (m8). Anything you would not accept in a materialized view, do not accept in an index.

The gap, closed from two sides

Here is the thesis the rest of the course elaborates. Between the words a person typed and the thing they were actually looking for there is a gap, and every mechanism in search is machinery for closing it. There are exactly two families of machinery, and they close it from opposite ends.

Keyword machinery closes the gap from the literal side. It forgives what you said: it decides what counts as a word, folds case and accents, strips plurals, tolerates a transposed letter, and weighs your terms so that a rare one counts for more than a common one. It is precise, cheap, inspectable, and completely blind to meaning — it cannot know that "like a Barolo but cheaper" describes a wine whose tasting note never uses the word Barolo. That machinery is m2 (the index), m3 (scoring), and m4 (the query side).

Vector machinery closes the gap from the meaning side. It matches what you meant: text becomes a point in a learned space where similar usage lands nearby, so a query and a document can match with no vocabulary in common at all. It is fuzzy about what you said, which is precisely the problem when what you said was a lot number. That machinery is m6, and its cost model is m6 as well.

The gap between typed words and actual intent, bridged by keyword machinery on one side and vector machinery on the other, with course modules annotated on each bridgeWhat was typedlike a Barolobut cheaperliteral, misspelled, terseWhat was wanted2021 Langhe Nebbiolo$28 · d2unstated, contextualthe gapKeyword machinery — forgives what you saidanalyze · weigh terms · tolerate typos · blind to meaningm2 · m3 · m4Vector machinery — guesses what you meantmeaning as geometry · matches without shared words · fuzzy about factsm6m5 referees · m7 joins · m8 operates
Figure 1.2 — Two bridges over one gap. Keyword machinery starts from the words and forgives their form; vector machinery starts from the meaning and forgives the words entirely. Neither spans the gap alone: the literal bridge cannot reach a document that shares no vocabulary with the query, and the semantic bridge cannot land on an exact lot number. m5 supplies the measurement that says which bridge is failing, m7 joins them, and m8 runs the result in production.

Between the two bridges sits the thing that makes either of them improvable: evaluation (m5). Without a judged set of queries you can only argue about relevance, and the argument is won by whoever complained most recently. With one, relevance becomes a number with a denominator, changes become regressions or improvements, and "the search feels worse since Tuesday" becomes a testable claim. Then m7 joins the bridges — because the honest finding, once you measure, is that each engine visibly loses queries the other wins — and m8 puts the joined system under an AI feature, where retrieval quality becomes the hard ceiling on what any model can say.

Module 02 The inverted index

The data structure that makes search fast is a single trick applied ruthlessly: stop asking each document what words it contains, and start asking each word what documents contain it. A relational row store maps document to terms. An inverted index maps term to documents. That inversion turns a scan over the corpus into a handful of dictionary lookups plus a set intersection, and the cost of a query stops depending on how much you have and starts depending on how much you asked for.

Everything else in keyword search is bookkeeping on top of that inversion — and all of the bookkeeping happens before any user types anything. The corpus is transformed once, at write time, by a pipeline called the analyzer, and what that pipeline decides is what can ever be found. This module builds the structure with the five canonical wines, walks a document through the analyzer stage by stage, reads a real posting list, and ends on the constraint that governs the next two modules: the index is a contract signed at index time, and query time cannot renegotiate it.

Turning documents inside out

Store the five cellar wines as rows and the natural question is document-shaped: for each row, does its note contain what I want? Answering it requires visiting every row, because a row store has no way to know which rows are candidates until it looks. Five rows is free; five million is a table scan you feel in the p99.

Invert it. Build a dictionary whose keys are terms and whose values are the documents containing them — the posting list for that term. Now the question is term-shaped: look up "firm", look up "tannin", intersect. You never touch a document that fails the query. The cost is two dictionary lookups plus a walk over two short lists, and — this is the property worth memorizing — that cost is a function of how many terms the user typed and how long those particular lists are, not of how many documents you own. Adding a million wines that do not mention firmness makes the query for firm tannins no slower at all.

Tokenization: deciding what a word is

Tokenization splits a stream of characters into the units you will index. It feels like a mechanical step and it is not: it is a policy decision that helps some queries and hurts others, and the corpus votes on which.

Splitting on whitespace and punctuation is the sane default for prose, and it immediately does something consequential to L-4471: the hyphen is punctuation, so the lot code becomes two tokens, l and 4471. Now a search for the exact lot code matches any document containing a stray "L" near a stray "4471", and — worse — the code no longer exists in the index as a single findable thing. Keep the hyphen and you have solved lot codes while breaking "American-oak aging" in d5, which now indexes the token american-oak and will not match a search for oak. Apostrophes have the same shape of problem ("producer's" as one token or two), and so do CJK scripts, where whitespace does not delimit words at all.

There is no policy that wins everywhere, which is why the working answer is not a better tokenizer but a second field. Index the tasting note with a prose analyzer and index the lot code as a single verbatim keyword — same document, two representations, each queried by the class of query it serves. That split is the corrective you will reach for repeatedly, and m4 shows what happens to the product when nobody makes it.

When it misleads

Tokenizers fail quietly and asymmetrically. Prose queries keep working perfectly while identifier lookups degrade into fuzzy nonsense, so the bug reaches you as "lot search is weird sometimes" from one warehouse user rather than as a broken build. If your corpus contains identifiers — SKUs, control IDs, CVEs, case numbers, account numbers — assume the default tokenizer has already broken them and go look.

Normalization and stemming: forgiving the literal

Tokens as typed are too literal to match reliably. Normalization flattens variants that carry no meaning difference: case folding so Barolo and barolo are one term, accent folding so Rosé and rose meet, punctuation stripping so tannins; loses its semicolon. Each fold buys recall and spends precision, and the spending is usually invisible — until a corpus of French producers discovers that accent folding has merged two distinct estates.

Stemming goes further, reducing inflected forms to a shared root so that a document saying "tannins" answers a query for "tannin". This corpus uses a light English stemmer — plural and common-suffix stripping, plus one hand-written rule mapping oaky to oak so that d3's "unapologetically oaky", d4's "no new oak", and d5's "American-oak aging" all land on the same term. That hand-written rule is worth noticing: it is the honest shape of production analysis, where a general algorithm gets you most of the way and a short list of domain rules covers the vocabulary that actually matters to your users.

The failure mode has two directions and they need different fixes. Over-stemming merges words that should stay distinct — an aggressive stemmer that maps both mature (d5, ready to drink) and maturation (a winemaking process) to matur will make a search for drink-ready wines return every wine whose note discusses barrel maturation, and precision for that query collapses. Under-stemming leaves forms unmerged, so rose petal never matches a note that says rose petals, and the miss is silent. The instinct after an over-stemming incident is to reach for a lighter stemmer everywhere, which trades one silent failure for the other; the better move is per-field analysis, keeping aggressive stemming on descriptive prose and light or no stemming on names and controlled vocabulary.

Posting lists and the lookup

A posting list stores more than document ids. For each term it holds, per document, the term frequency (how many times the term occurs there) and the positions at which it occurs. Both are there to pay for something specific later: frequencies feed the scoring function in m3, and positions make phrase and proximity queries possible in m4. If you have ever wondered why an index is several times the size of the text it indexes, this is most of the answer.

Here is the whole cellar index, analyzed and inverted, with the query firm tannins traced through both structures.

The five cellar wines stored as database rows versus as an inverted index with complete posting lists, and the query firm tannins traced through eachRow store — document → termsid · wine · noted1 · 2016 Barolo Monvigliero · $89“Tar and roses, firm tannins; austere now…”d2 · 2021 Langhe Nebbiolo · $28“Rose petal, sour cherry, supple tannins…”d3 · 2018 Napa Cabernet · $65“Black currant and cedar, muscular tannins…”d4 · 2020 Etna Rosso · $34“Volcanic and taut; red fruit, no new oak…”d5 · 2015 Rioja Gran Reserva · $47“Dried cherry and leather, long American-oak…”Trace: firm tanninsvisit d1, d2, d3, d4, d5 — test each note5 documents read to return 1cost grows with the corpusInverted index — term → documentsa → d1aging → d5american → d5and → d1 d3 d4 d5austere → d1big → d2black → d3burgundian → d4cedar → d3cherry → d2 d5currant → d3decade → d1dried → d5drink → d2fruit → d4fully → d5in → d4leather → d5long → d5mature → d5muscular → d3nebbiolo → d2new → d4no → d4now → d1one → d2petal → d2red → d4rose → d1 d2sleep → d2sour → d2spirit → d4supple → d2tar → d1taut → d4the → d2to → d2volcanic → d4while → d2unapolo- → d3 geticallyfirm → d1oak → d3 d4 d5tannin → d1 d2 d3Trace: firm tanninslookup firm → { d1 }lookup tannin → { d1, d2, d3 }intersect → { d1 }2 lookups + 1 walk of 4 postings — 0 documents readWhat a posting entry actually holdstannin → [ d1 : tf 1, pos 5 ] [ d2 : tf 1, pos 6 ] [ d3 : tf 1, pos 6 ]document id — which documents contain the term (matching, this module)term frequency — how strongly (scoring, m3) · positions — where (phrases and proximity, m4)
Figure 2.1 — The same five wines, two structures. The row store must visit all five documents to answer firm tannins and would visit five million if you owned five million. The inverted index answers with two dictionary lookups and one intersection of four postings, reading no documents at all — firm appears only in d1, so the intersection collapses immediately to {d1}. Scan cost grows with the corpus; lookup cost grows with the query. Each posting also carries the term frequency that m3 will score on and the positions that m4 will match phrases with.

Note the shape of the intersection: the engine walks the shortest list first. firm has one posting, so the candidate set is one document before tannin's three postings are even consulted. This is why a query containing one rare term is fast regardless of how common the other terms are, and it is the same fact — rarity is informative — that m3 turns into a scoring signal.

Everything is decided at index time

Now the constraint that governs the rest of the keyword half of this course. The analyzer ran once, when the document was written, and what it produced is all that exists. There is no copy of the original text inside the posting lists; there are terms. If the analyzer did not emit tannin for d1, then no query, however cleverly constructed, can find d1 by that word — the form was never indexed, and query time has nothing to search.

This makes the analyzer a contract with two signatories. The index side promises to have transformed documents a certain way. The query side must promise to transform queries the same way, or the two never meet — that is m4's mirror-or-miss rule, and it is the single most common silent search bug in production. And when you change the contract, you have not upgraded the index; you have created an index whose old documents were written under the old terms and whose new documents are written under the new ones. The corpus must be reprocessed from the source of truth.

Index-time analysis pipeline for a tasting note drawn above the mirrored query-time pipeline, with the mirror-or-miss rule annotated where they meetINDEX TIME — once, at writed1 raw noteTar and roses, firmtannins; austere now,needs a decade.tokenize[Tar][and][roses][firm][tannins]… 10 tokensnormalizetar and rosesfirm tanninscase + punctuationstem → postingsrose, firm, tannin,austere, need, decadetannin → d1 : tf 1, pos 5mirror or miss — same stages, same orderQUERY TIME — every requestraw queryfirm tanninsas typedtokenize[firm][tannins]normalizefirm tanninsstem → lookupfirm, tanninhits the posting lists above ✓
Figure 2.2 — One analyzer, two pipelines. The document is transformed once at write time and survives only as terms: tannins is gone, tannin remains. The query must run the identical stages in the identical order to produce the identical term, or it looks up a key that was never written. The gold junction is the whole contract — mirror the pipeline and the terms meet; diverge on any stage and the query returns nothing while both halves report success. m4 draws the broken version.
Symptom and corrective

Symptom: an analyzer setting is changed and redeployed; new documents behave correctly, older documents stop matching queries they used to answer, and the split between working and broken sorts by document age rather than by content. Corrective: a full reindex from the source of truth, run as a routine part of any analysis change — and, because reindexing a large corpus is not instant, an index-versioning scheme where the new index is built alongside the old and traffic switches when it is complete.

The forward pointer is worth planting now. m3 will score using the frequencies stored here. m4 will match phrases using the positions stored here, and will show what happens when the query side breaks the mirror. m8 will note that this index is a derived copy of a source of truth and derived copies drift — which is the same fact as "the analyzer ran once," seen from operations rather than from correctness.

Module 03 What floats to the top

Matching produced candidates. Something must now order them, and that something is a scoring function: a number per document per query, sorted descending. The industry standard family is BM25, and the usual way it is taught — as a formula with two tuning constants — teaches nothing, because nobody debugs a ranking by staring at algebra.

Build it the other way. There are three signals, each introduced by the ranking mistake you would make without it: how often the term appears, how rare the term is across the corpus, and how long the document is. Add a fourth consideration, which field the match landed in, and you can hand-score a query against a handful of documents and predict the order. A reader who can do that can debug a ranking complaint in an afternoon, which is the actual skill.

Three signals, one score

Term frequency. A document that says "tannin" four times is more likely to be about tannin than one that says it once. Without this signal, every document containing a query term scores identically, and ordering collapses back to whatever the storage engine felt like — the m1 failure. So: more occurrences, more score.

Term rarity. Not all query terms are equal evidence. In this five-wine corpus, tannin appears in three of five documents and firm in exactly one. A document matching firm has told you something; a document matching tannin has told you almost nothing, because most of the corpus would have. Rarity — the signal usually called inverse document frequency — makes a match on a discriminating term worth more than a match on a ubiquitous one. Without it, the query the 2016 barolo would be dominated by whatever document repeats the most, which is a real and famous failure.

Document length. Frequency alone rewards volume. A 4,000-word tasting essay will mention almost any wine term more often than a twelve-word note, not because it is more about that term but because it is longer. Length normalization divides out the advantage, asking not "how many times" but "how many times, for a document this size." Without it, your longest documents win every query, and the symptom is a results page where the same handful of verbose documents appear for unrelated searches.

Each signal exists to correct one error, and the corrections are independent — which is why you can reason about them separately when a ranking looks wrong. The diagnostic question is always: is this document winning on repetition, on rarity, or on brevity?

Saturation: the tenth mention is not ten times better

Term frequency helps, but taken literally it is exploitable and wrong. A note mentioning tannin ten times is not ten times more about tannin than one mentioning it once. The first occurrence carries enormous information — the document is about this at all. The second confirms it. The seventh tells you the author is repetitive.

Real scoring functions therefore bend the frequency curve so each additional occurrence contributes less than the one before it, approaching a ceiling. This is saturation, and it is the single most important behavioral property of BM25 — the thing that distinguishes it from the naive count-based scoring that preceded it. The formula's constants exist to control how sharply the curve bends; the behavior is what you need.

Score contribution plotted against term occurrences, comparing a straight linear line to a saturating curve that flattens after the first few occurrencesterm occurrences in the documentscore contribution0124810naive linear countsaturating curve2nd occurrence: a real gain10th occurrence: almost nothingFirst occurrence: is this document about the term at all?Everything after: confirmation with diminishing value.
Figure 3.2 — Why repetition stops paying. Under naive linear counting (dashed) a term repeated ten times contributes ten times as much as one occurrence, so the ranking can be bought with repetition. A saturating curve gives most of its value to the first two occurrences and flattens thereafter: the gap between one and two mentions is large, the gap between nine and ten is negligible. This is BM25's defining behavior, and it is why keyword stuffing does not work.
Where the intuition earns its keep

Saturation is why a merchant who pastes a term fifty times into a product description gets a shrug from the ranking, and why a stakeholder's proposal to "just repeat the important words in the description" is a waste of everyone's afternoon. It is also why term frequency is a weak lever for you: if you want a document to rank higher, repetition is close to the least effective thing you can change. Rarity and field placement are where the leverage is.

The relevance anatomy, worked

Take the canonical query firm tannins against the three documents that match either term after analysis — d1, d2, and d3 — and score them by hand. The model below is deliberately simplified: per-term contribution is saturated frequency × rarity weight, and the document's total is adjusted for length. It is BM25-shaped illustrative arithmetic, not BM25's formula; it preserves the behavior you need to reason with and drops the constants you would never tune by hand anyway.

The rarity weights come from the corpus: firm appears in one document of five, so it is highly discriminating (weight 3.0); tannin appears in three of five, so it barely narrows anything (weight 1.0). The length adjustment is the corpus average note length, 10.8 tokens, divided by the document's length — d1 is 10 tokens (1.1×), d2 is 15 (0.7×), d3 is 8 (1.35×). Every term here occurs once in every document, so saturated frequency is 1.0 throughout and this comparison is decided entirely by rarity and length.

Stacked bar chart scoring the query firm tannins against three wine documents, with each term's contribution labeled and totals rankedQuery: firm tanninscontribution = saturated frequency × rarity weight, then × length adjustmentrarity: firm = 3.0 (1 of 5 docs) · tannin = 1.0 (3 of 5 docs)d1 · Barolo Monvigliero10 tokens · length ×1.1firm 1.0 × 3.0 × 1.1 = 3.3tannin 1.1= 4.41std3 · Napa Cabernet8 tokens · length ×1.35tannin 1.35no firm match — 0= 1.42ndd2 · Langhe Nebbiolo15 tokens · length ×0.70.7no firm match — 0 · long note pays a length penalty= 0.73rdd1 wins by 3.0 points, and the entire margin is the rare term: only d1 matches firm, worth 3× a tannin match.d3 beats d2 on brevity alone — identical single tannin match, 8 tokens vs. 15.
Figure 3.1 — Why the winner wins, visibly. Each bar is one document's score, segmented by query term. d1 scores 3.3 + 1.1 = 4.4; d3 scores 1.35, rounded to 1.4; d2 scores 0.7. Every number is checkable: contribution is saturated frequency (1.0 here, since each term occurs once) times rarity weight (firm 3.0, tannin 1.0) times the document's length adjustment (10.8-token corpus average divided by document length). The margin is not close, and it is entirely explained by rarity — with d3's win over d2 explained entirely by length.

Two habits come out of this. First, when a ranking surprises you, decompose it: most search engines will show you the per-term contributions on request, and the answer is nearly always that one document matched a rarer term or is much shorter than you assumed. Second, notice how little work term frequency did here. In short documents — product titles, tasting notes, support ticket subjects, control descriptions — almost every term occurs once, so rarity and length decide everything, and tuning frequency behavior is theatre.

Fields are not equal

One thing the anatomy above ignores: where the match happened. A wine whose name is "2016 Barolo Monvigliero" and a wine whose tasting note happens to say "unlike the Barolo next to it" both match barolo, and they are not remotely equally relevant. Field weighting fixes this by multiplying a field's contributions before they are summed — a name match at 3×, a grape or region match at 2×, a note match at 1×.

The justification has to come from intent, not from taste. Why does the name deserve a boost? Because a name is what a document is, while a note is what someone said about it, and a user typing a proper noun is far more often naming the thing than describing it. That is an argument you can defend, and — crucially — an argument that could be wrong for a different corpus. In a support-ticket search, the title field is written hastily and the body carries the diagnosis, so the same reasoning can point the other way.

Here is generation 1 of the wine cellar's tuning pass, run honestly. The complaint was that searching a producer name surfaced other wines' notes first. The fix — boost name to 3× — worked: the producer query snapped into place. The regression, discovered later, was that nebbiolo now returned d2 ("2021 Langhe Nebbiolo", name match, 3×) far above d1 (2016 Barolo Monvigliero, whose grape is Nebbiolo but whose name does not say so), and a customer shopping the grape got the cheap bottle and not the serious one. One query improved, one regressed, and the team had no way to know which effect was larger.

The load-bearing idea

Every relevance change is a trade, not an improvement. Boosting a field, adding a synonym, loosening a stemmer — each helps one query class and hurts another, and the only question that matters is the net effect across the queries your users actually issue. You cannot answer that question by looking at one query, which means you cannot answer it at all until you have a judged set. That is m5, and this cliffhanger is why it exists.

Anti-pattern: tuning by anecdote

Symptom: relevance work is a queue of individually-reported bad queries; each fix ships after someone eyeballs that one query; a month later the earliest complaints have quietly returned and nobody can say what the system used to do. There is no record of past states and no denominator anywhere. Corrective: a judged query set that every relevance change runs against before shipping — the fix becomes "improved the average and regressed two queries, both acceptable," which is a decision instead of a hope.

Module 04 The query side

The index is half the machine. The other half runs on every request: it parses what the user typed, decides which fields to consult, expands or forgives the terms, and hands the result to the lookup. Most of what users experience as "this search understands me" happens here, and so does the most confusing class of production search bug.

This module covers the query side's real tools — boolean structure, phrase and proximity matching, typo tolerance — with a bias toward scoping, because every one of these tools is correct somewhere and corrupting somewhere else. Then it draws the failure the previous module set up: the query pipeline diverging from the index pipeline, which produces zero results, no error, and nothing in any log.

Free text vs. boolean: two contracts

A boolean query is a specification. (nebbiolo OR barolo) AND tannin NOT oak defines a set exactly, and the engine's job is set arithmetic. It is precise, reproducible, auditable, and completely unforgiving: if the corpus says "Nebbiolo d'Alba" and you asked for nebbiolo, you get it; if it says "Nebbiolo-based", your tokenizer had better agree with you. And it typically arrives unranked, because a set has no order — which is fine, since the user who wrote that query has accepted the burden of specifying exactly what they want.

A free-text query is testimony, in m1's sense. The engine treats terms as evidence, matches loosely, scores, and orders. It forgives the user's vocabulary and spelling and returns something useful for almost any input. What it gives up is determinism: you cannot fully predict the result set from the query, two similar queries can behave differently, and "why did this document appear" requires an explain plan rather than a reading of the query.

Cross-reference · your first career

You have been an expert boolean query author for a decade. Westlaw and Lexis syntax is precisely this contract: /s and /p are proximity operators (same sentence, same paragraph), ! is truncation — a manual stemmer, where negligen! matches negligent, negligence, negligently — and % is a NOT. That syntax exists because legal research demands reproducibility and defensibility: you must be able to say what you searched for and show that the search was adequate. Everything this module names under the hood, you have been operating by hand. The insight worth carrying forward is the one those systems encode: the burden of precision sits on whoever can bear it. A professional researcher can bear it. A shopper cannot.

The correct product answer is almost never either-only. Ship free text as the default surface and expose structure — filters, facets, an advanced mode, quoted phrases — for the users who want to bear the burden. What is genuinely wrong is forcing boolean syntax on a consumer product, and what is quietly wrong is offering only free text in a compliance or research context where someone will eventually have to defend the completeness of a search.

Phrases and proximity

Bag-of-words matching loses word order the moment the document is indexed. That is usually fine and occasionally disastrous: "firm tannins" and "tannins, though not firm" contain identical terms, and only the stored positions from m2 can tell them apart.

A phrase query consumes those positions directly. Look up firm (d1, position 4) and tannin (d1, position 5; d2, position 6; d3, position 6), then keep only documents where the positions are consecutive in the right order — d1 survives, the others were never adjacent. Proximity search softens the constraint: instead of requiring distance 1, score by distance, so terms three words apart contribute more than terms thirty apart. This is what /s was doing in Westlaw, and it is the better default for most prose search because it captures the intuition — related words cluster — without the brittleness.

Phrase strictness helps where the phrase is a name or a quotation: "Gran Reserva" is a legal designation, not two adjectives, and matching it loosely returns every Spanish wine with a long word next to a reserva. It strangles recall where users are describing rather than naming, because a two-word phrase must appear in exactly that order to survive, and prose does not cooperate. The workable pattern is to run both — the phrase interpretation as a scoring boost rather than a filter — so documents containing the exact phrase rank first while documents containing the terms scattered still appear below.

Typo tolerance and where it corrupts

Fuzziness matches terms within a small edit distance of what was typed: barollo reaches barolo at distance 1 (one deletion), nebiolo reaches nebbiolo at distance 1. The cost is that everything within that radius now matches, so the term stops being one term and becomes a neighborhood, and precision falls by however many real words happen to live nearby.

On prose, the trade is usually good and the standard configuration reflects it: distance 1 for medium words, distance 2 for long ones, exact matching for short words where a single edit reaches too many unrelated terms. On identifiers it is not a trade, it is a defect. L-4471 and L-4477 are at edit distance 1 and refer to different lots of different wine at different prices. A fuzzy match between them does not help a confused user; it silently ships the wrong bottle and produces an inventory discrepancy that nobody traces back to a search setting three months later.

Anti-pattern: fuzziness on by default, everywhere

Symptom: identifier lookups intermittently return a neighbor — a lot code, an order number, a control ID, a CVE — and the reports are rare, unreproducible from the reporter's description, and dismissed as user error. The blast radius is asymmetric: a fuzzy miss on prose costs a scroll, a fuzzy miss on an identifier costs a wrong shipment or a wrong compliance answer. Corrective: fuzziness is a per-field setting, not a global one. Identifier fields — the verbatim fields from m2 — get exact matching, and the query router sends identifier-shaped input there.

The general rule is worth stating as a principle, because it recurs: forgiveness is appropriate where being approximately right is useful, and inappropriate where being approximately right is being wrong. Prose is the first case. Codes, names, quantities, and dates are the second.

Mirror or miss

Now the failure m2 set up. The analyzer is a contract with two signatories, and the query side is the one that breaks it — usually by accident, usually during a migration, always silently.

Suppose the index stems and the query pipeline does not. A user types tannins. The query side passes tannins to the lookup. There is no posting list for tannins; the term was stemmed to tannin at write time, and tannin's list — containing d1, d2, and d3 — sits in the same dictionary, inches away, unreachable. The engine returns zero results and reports success, because zero results is a valid answer to a well-formed query. No exception is raised. No log line is written. Both halves of the system are working exactly as configured.

Swimlane trace showing an unstemmed query missing the stemmed posting list entirely, and the corrected mirrored pipeline hitting itINDEX LANE — ran once, at write timed1 note: “…firm tannins…”analyzer: tokenize+fold+stemtannin → { d1, d2, d3 }no key “tannins” existsit was never writtenQUERY LANE — broken mirror (stemming step missing)tanninstokenize + fold onlylookupdictionary[“tannins”] → ∅list sits inches away, unreachable0 results · HTTP 200no error, no log lineQUERY LANE — mirrored (same analyzer object)tannins → tannintokenize + fold + stemlookupdictionary[“tannin”] → hitsame list as the index lane aboved1, d2, d3 ✓
Figure 4.1 — The same index, two query pipelines. The index lane stemmed tannins to tannin once, at write time, and that is the only key that exists. A query pipeline missing the stemming step looks up a key that was never written, gets an empty set, and returns HTTP 200 with zero results — a well-formed answer to a well-formed query, with nothing anywhere to indicate a defect. Mirror every stage in the same order and the query lands on the identical posting list. The rule: terms meet in the index only if both pipelines transformed them the same way. Figure 2.2 shows this working; this shows it broken.
Anti-pattern: mismatched analysis

Symptom: a class of queries returns nothing, and the class has a morphological signature rather than a topical one — plurals fail while singulars work, accented forms fail while unaccented work, uppercase fails while lowercase works. Nothing errors, latency is normal, and the affected users describe it as "search is broken for some things." Corrective: one analyzer definition, referenced by both pipelines from a single configuration source, so divergence is impossible by construction rather than by discipline — plus a zero-results report (m8) that makes the signature visible within a day instead of a quarter.

Notice the shape of the diagnosis, because it generalizes: when a failure sorts by form of the word, look at analysis; when it sorts by age of the document, look at reindexing (m2); when it sorts by topic, look at ranking (m3). Three symptom patterns, three different stages, and the misdiagnosis in each direction wastes a week.

One more query class deserves a flag before it becomes m6's problem. Try red with no oak against the fuller catalog you will judge in m5 — where red and no are ordinary common words, not rare tokens that happen to single out one bottle. The analyzer produces red, with, no, oak; of these only oak carries real weight, and it points at the oakiest wines; so the two documents that are most about oak ("unapologetically oaky", "long American-oak aging") score highest, because term matching has no operator for negation inside free text — no is just another token, powerless to subtract oak from the ranking. The correct answer ("no new oak") is buried beneath the very wines the user ruled out. No query-side tuning fixes this: fuzziness does not help, phrase matching does not help, field weighting does not help. Hold that failure; it will still be there in m6, and it will take until m7 to resolve.

Module 05 Was it any good

Every module so far ended with a knob and no way to know whether turning it helped. Stem harder or lighter. Boost the name field or do not. Loosen fuzziness. Each is a trade, each helps one query class and hurts another, and so far the only instrument available has been someone's opinion about one query — which is how you end up shipping a change that fixes the director's search and quietly breaks four hundred customers'.

This module installs the instrument. Precision and recall name the two distinct ways a search result can be wrong, judged query sets make them computable, and running every relevance change against that set converts search tuning from an argument into a regression suite. This is also where the wine cellar's generation-1 numbers get established — the baseline that m7 will re-measure after the vector engine and the hybrid architecture arrive.

Two ways to be wrong

Precision asks: of the results you returned, what fraction were relevant? It is the junk metric — low precision means the user is reading through noise. Recall asks: of the documents that were relevant, what fraction did you return? It is the missing-treasure metric — low recall means the right document exists in your corpus and the user never saw it.

The asymmetry that matters is in who reports them. Bad precision is visible: the user sees the junk and complains about it. Bad recall is invisible: nobody can report a result they were never shown. A search system with terrible recall generates almost no complaints — it generates silence, abandonment, and a support ticket six months later that says "I thought we didn't carry that." This is why a team optimizing on complaint volume drifts steadily toward high precision and unmeasured recall, and why the metric you are not tracking is usually the one degrading.

The two trade against each other by construction. Loosen matching to catch d2 for the query like a Barolo but cheaper — add synonyms, drop a required term, expand fuzziness — and recall rises while a crowd of marginal documents arrives with it, lowering precision. Tighten to remove the junk and the treasure goes back into the dark. There is no configuration that maximizes both, which is why the real question is never "is our search good" but "which error can we afford here."

A two-by-two of retrieved versus relevant with counts from one wine-cellar query, showing precision and recall computed as ratios over different cellsQuery J4: red with no oak · corpus 12 documents · keyword search, top 10RELEVANT (judged)NOT RELEVANTRETRIEVEDNOT RETRIEVED2found treasured4, d108junk returnedincl. d3, d5 — the oakiest wines1treasure missedd8 — nobody will ever report this1correctly ignoredPRECISION2 / (2+8)0.20top rowRECALL2 / (2+1)0.67left columnSame result set, two very different numbers — because they divide by different denominators.“Search quality” as a single number is a category error; ask which of these two you can afford to lose.
Figure 5.1 — Two metrics, two denominators. For the query red with no oak the keyword engine returned ten documents, two of which were judged relevant, while a third relevant document never appeared. Precision divides by what was returned (the top row): 2/10 = 0.20. Recall divides by what was relevant (the left column): 2/3 = 0.67. The bottom-left cell — the missed document — is the one no user will ever report, which is why recall degrades silently in systems tuned by complaint volume.

The judged query set

The instrument is unglamorous: a list of real queries, each with human judgments about which documents should have come back. Sample queries from your logs rather than inventing them — invented queries encode what you think users type, which is the belief you are trying to test. Weight toward head queries for coverage and include a deliberate tail sample, because the tail is where semantic failures hide.

Here is the wine cellar's set: ten queries over a twelve-document corpus, with generation-1 keyword search measured against it. Binary judgments (relevant or not) are enough to start; graded judgments come later if you need them.

#QueryJudged relevantKeyword top 3Hits@3P@3
J1firm tanninsd1, d3, d9d1, d3, d220.67
J2nebbiolo to drink youngd2, d7, d11d2, d1, d720.67
J3like a Barolo but cheaperd2, d7, d11d1, d6, d300.00
J4red with no oakd4, d8, d10d3, d5, d410.33
J5austere age-worthy piedmont redd1, d2, d11d1, d2, d420.67
J6oaky california cabernetd3, d6, d12d3, d6, d520.67
J7volcanic sicilian redd4, d10, d12d4, d10, d320.67
J8mature spanish red ready nowd5, d9, d12d5, d1, d920.67
J9L-4471d1d1found
J10L-5203d2d2found

The arithmetic is deliberately checkable. Across the eight free-text queries there are 13 hits out of 24 slots, so mean precision@3 is 13/24 = 0.54. Counting relevant documents recovered anywhere in the top ten gives 19 of 24, so mean recall@10 is 0.79. The two identifier queries are reported separately as found-at-1: 2 of 2. That separation is not fussiness — a query with exactly one relevant document can never exceed 0.33 on precision@3, so averaging it in would drag the number down for a reason that has nothing to do with quality. Choosing a metric that can actually be achieved is part of building the instrument.

The load-bearing idea

Ten judged queries beat a thousand opinions. Not because ten is a large sample — it is not — but because ten queries with recorded judgments have a denominator, survive personnel changes, and can be re-run. An opinion cannot be re-run against last quarter's system.

Two things about maintenance. First, judgments are per result, not per query: "J3 was bad" is an opinion again, while "for J3, d1 is not relevant and d2 is" is data. Second, the set is a living artifact — the corpus grows, query patterns shift, and a set built at launch and never touched becomes an instrument measuring a system that no longer exists.

Cross-reference · Guide Nº 03, Evals

Retrieval evals are evals. Everything that guide says applies here without translation: the graded set is the specification of intended behavior, coverage matters more than volume, the set drifts as reality moves, and the discipline's value is that it converts "this feels worse" into a number with a denominator. If you have built an LLM eval suite, you have already built this — the only difference is that the unit of judgment is a retrieved document rather than a generated answer, and that ranking gives you a position to score rather than a single output to grade.

You have litigated this before

If measuring search quality feels like engineering pedantry, consider that your first career already litigated it — under oath, with sanctions attached.

E-discovery is a recall problem with a legal standard. A producing party must find the responsive documents in a corpus that may run to millions, and "we searched and found these" is not a defense on its own; the adequacy of the search itself is discoverable and arguable. Technology-assisted review made this explicit: parties negotiate the protocol, sample the null set to estimate what the process missed, and report recall as a number that the other side is entitled to challenge. Courts have engaged directly with whether a given recall level was reasonable. Nobody in that room thinks recall is a metric an engineer invented to look busy — it is the quantity that determines whether the production was adequate.

Take two things from this. First, the discipline transfers: the null-set sample is a judged query set, built to estimate the bottom-left cell of Figure 5.1 precisely because that cell is invisible by construction. Second, the metric choice is a consequence of asymmetric cost, and that reasoning generalizes to your GRC work directly. When a compliance analyst searches the control library for evidence relating to an audit finding, a missed document can mean an unremediated gap presented as remediated. When a security engineer searches for every service touching a vulnerable dependency, a missed service is an unpatched service. In both cases the cost of a miss dwarfs the cost of reading three irrelevant results, so recall is the metric and precision is a comfort. Flip the asymmetry — a shopper browsing wine, where a miss costs a scroll and junk costs the sale — and precision leads.

A standing discipline

The judged set is not a launch artifact. It is a regression suite, and it belongs in the same mental slot as your test suite: every relevance change — an analyzer edit, a field boost, a new synonym list, a swapped engine — runs against it before it ships, and the output is a diff.

That diff changes the conversation in a specific way. "The director's query looks better" becomes "mean precision@3 moves from 0.54 to 0.58, J2 and J7 regress by one hit each, and here is what regressed and why." You may still ship it. The point is not that the number decides; the point is that you now know what you are trading, the trade is written down, and in six months somebody can find out what the system used to do.

The evaluation loop drawn as a cycle from judged set to metrics to change to re-run, with the anecdote loop drawn dashed alongside as the failure pathThe evaluation loop — has a denominator and a memoryjudged set10 queries, judgedrun the systemcapture top-k per querymetricsP@3 0.54 · R 0.79propose changere-run · ship or revertdiff recorded against the previous stateThe anecdote loop — no denominator, no memorya complaint arrivestweak until it looks rightshipa different complaint arrives — often the one fixed two months agoWhat the dashed loop lacks: a denominator (how many queries did this help, how many did it hurt?)and a memory (what did the system do before? nobody can say, so old fixes silently unship).
Figure 5.2 — Two loops, one of which converges. The evaluation loop measures before and after against a fixed set, so every change produces a diff and the previous state is recoverable. The anecdote loop responds to whichever complaint arrived most recently, has no way to know how many other queries a fix damaged, and keeps no record — so it cycles indefinitely, periodically re-breaking queries it repaired a quarter earlier. The two loops can look identical from a standup update; only one of them accumulates.

The generation-1 baseline is now established and binding: mean precision@3 0.54, mean recall@10 0.79, identifier lookups 2/2. Look at where it fails. J3 scores zero and J4 scores 0.33, and those two share a signature — the relevant documents share little or no vocabulary with the query, or the query contains a structural relationship (negation) that terms cannot express. That is not a tuning failure, it is the ceiling of the machinery, and the number now exists to prove it. m6 attacks exactly that hole.

Module 06 Meaning as geometry

The judged set localized a hole. Queries J3 and J4 fail not because the ranking is mistuned but because the relevant document shares no vocabulary with the query, or because the query expresses a relationship that a bag of terms cannot represent. No amount of stemming reaches a document that never used the word.

Embeddings attack that hole by changing the representation. Instead of a document being a set of terms, it becomes a point in a high-dimensional space, positioned by a model trained so that texts used in similar ways land near each other. Retrieval becomes geometry: embed the query, find the nearest points. This module covers what that vector actually represents, what the geometry genuinely captures, what it reliably misses — and the approximation every vector database makes on your behalf to find those neighbors fast enough to serve.

What an embedding is

An embedding is a learned function from text to a fixed-length list of numbers — a point in a space of several hundred to a few thousand dimensions. The function is trained on an enormous amount of text with an objective that amounts to: put passages that get used in similar contexts near each other, and push dissimilar ones apart. Whatever ends up encoded in those coordinates is whatever served that objective.

That last sentence is the honest description, and it is worth resisting the more flattering ones. The vector does not represent truth, or logic, or the properties of the wine. It represents usage patterns in the training data — a compressed prior about which texts behave like which other texts. It is extremely good at that, and the consequences are useful: "structured Piedmontese red" and "tar and roses, firm tannins" land near each other despite sharing zero words, because in the training corpus those phrases occur in the same neighborhoods of discourse. It is also the reason the failures in section three are not bugs to be fixed by a better model — they are what you get when your retrieval signal is discourse similarity.

Cross-reference · Guide Nº 13

How these functions are trained — contrastive objectives, negative sampling, what makes one embedding model outperform another on a benchmark — is that guide's territory. Here you are a consumer of the function: it arrives as an API call or a local model, you send text, you get a vector. The consumer's questions are the ones this module answers: what does nearness mean, where does it lie to me, and what does it cost to search.

Similarity as distance

If documents are points, similarity is a geometric relation. The standard measure is cosine similarity: the cosine of the angle between two vectors, running from 1 (same direction) through 0 (unrelated) to −1 (opposite). Angle rather than distance, because magnitude in these spaces tends to encode uninteresting things like text length, and you want a measure that ignores it.

Retrieval then has a pleasingly simple shape: embed the query with the same model that embedded the documents — this is the vector world's mirror-or-miss rule, and mixing embedding models produces coordinates in unrelated spaces and results that look like a random shuffle — then find the document points closest to the query point.

Two-dimensional projection of the five wine tasting notes clustered by meaning, with two query points and their nearest neighbors, one of which is semantically close but factually wrongEmbedding space — 2D projection of the cellar corpusNebbiolo · structure, red fruit, tanninoak · power, sweet spice, woodd1Barolo · $89d2Langhe Nebbiolo · $28d3Napa Cabernetd5Rioja Gran Reservad4Etna Rosso · volcanic, no oakother cellar documentsQ-C “like a Barolo but cheaper”nearest → d2 ✓ correct, and unreachable by keywordsQ-E “the 2016 Barolo”nearest → d2 ✗ semantically close, factually wrongthe user named d1; keywords would have matched it exactlynearness = angle between vectors, not shared wordsBoth clusters form without shared vocabulary: d1 and d2 never use the same descriptors, and d3, d4, d5 all contain “oak” yet d4 sits far away.The projection is illustrative — the real space has hundreds of dimensions and no two of them are this page.
Figure 6.1 — Meaning as position, with both morals. The two clusters form on usage, not vocabulary: d1 and d2 share almost no descriptors yet sit together, while d3, d4, and d5 all contain the term oak and only two of them cluster — d4 is pushed away because "no new oak, Burgundian in spirit" is used like a lean-wine description, not an oaky one. Query Q-C lands beside d2 and retrieves the $28 Nebbiolo that keyword search could never reach. Query Q-E, "the 2016 Barolo", also lands beside d2 — semantically close and factually wrong, because the user named a specific bottle and the geometry only knows the neighborhood. This 2D drawing is a projection of a several-hundred-dimensional space; treat positions as illustrative of relationships, not as coordinates.

Read the map twice. The first reading is the promise: Q-C, the query that scored zero in the judged set, lands next to exactly the right document, and it does so with no shared vocabulary. The second reading is the warning, and it is the same picture: Q-E is a query with a precise, checkable answer — the user said "the 2016 Barolo" — and the nearest point is the wrong wine, returned with a high similarity score and no indication that anything went wrong.

What embeddings miss

Four failure classes, each with a geometric reason and each visible in this corpus.

Exact identifiers. Query L-4471. The string carries no usage pattern — it is an arbitrary label, and whatever position the model assigns it is essentially arbitrary too, driven by superficial character similarity to other codes. The nearest neighbors will be other lot codes and whatever else lives in that corner of the space, none of them meaningfully related. Keyword search answers this in one exact lookup; the vector system answers it with confident garbage.

Proper names with no training footprint. A small producer whose name appeared nowhere in the training data has no learned position. The model still produces a vector — it always produces a vector — assembled from subword fragments, which is to say from how the name is spelled rather than from anything about the producer. Well-known names work beautifully; obscure ones fail silently, and your catalogue is mostly obscure ones.

Negation and structure. Query red with no oak. The words "no" and "oak" appearing together produce a vector that sits near the oak region, because the passage is discussing oak. Geometry has no NOT operator: there is no direction in the space that means "exclude the neighborhood you are pointing at." This is why J4 fails on both engines, and it is a property of representing meaning as position, not a deficiency of any particular model. The same applies to quantities and comparatives — "under $40", "older than the 2015" — which are structural constraints wearing prose.

Out-of-domain queries. Jargon the model never encountered maps to some position anyway, and that position returns neighbors with respectable-looking scores. In a specialized corpus — internal control identifiers, proprietary product codenames, a house vocabulary — this is the common case rather than the edge case.

The load-bearing idea

Vector search never says "no results." It says "here is the nearest thing," always, for any input, with a similarity score that looks like confidence and is not. For queries about meaning that behavior is the feature — you asked vaguely and got something useful. For queries about facts it is the defect, because the system cannot distinguish "I found what you meant" from "nothing here is close, so have the least distant thing." Keyword search's ability to return an honest empty set is a capability, not a limitation.

Cross-reference · your GRC work

The negation failure is not academic in a compliance corpus. "Policies without an encryption exception", "vendors that have not completed a SOC 2", "controls with no compensating control" — these are the questions compliance teams actually ask, and they are all structural. Every one of them embeds into the neighborhood of the thing being excluded. The corrective is not a better embedding model; it is to recognize the structural clause and route it to machinery that can express it: a filter, a boolean clause, or a reranker that reads query and document together (m7).

Exact nearest-neighbor does not scale

The naive way to find the nearest vectors is to compare the query against every document vector and keep the best. It is exact, trivially correct, and O(corpus) per query — the full scan that the inverted index was invented to eliminate, reappearing in the vector world wearing different clothes.

Do the arithmetic before dismissing it. A million documents at 768 dimensions is 768 million multiply-adds per query, plus the memory bandwidth to stream three gigabytes of float32 vectors past the processor. On modern hardware with good libraries that is tens to low hundreds of milliseconds for a single query, and it does not amortize across concurrent users — every query pays it again. At ten thousand documents brute force is genuinely fine and you should not build anything more complicated. At ten million it is not a latency problem, it is an architecture problem.

Note that the inverted index escaped this trap structurally: it never examines a document that lacks a query term, so most of the corpus is untouched by construction. Vector search has no equivalent shortcut, because every document has some distance to the query — there is no such thing as a document that does not contain the query point. Nearness is not a filter. That is why approximation arrives here not as an optimization but as the price of admission.

Approximate indexes and the recall knob

An approximate nearest neighbor index pre-organizes the vectors so a query can visit a small fraction of them. The families differ in mechanism — partition the space into cells and probe only nearby cells; build a navigable graph and greedily walk toward the query; compress vectors so more of them fit in fast memory — but they share one behavior, and the behavior is what you need.

They sometimes miss the true nearest neighbor. Not through a bug: by design. The search visits some regions and not others, and a genuinely close document sitting in an unvisited region is simply not found. The measure of this is recall@k — of the true top-k neighbors, what fraction did the index actually return — and every ANN index runs below 1.0. A typical production default sits somewhere near 0.95, which sounds like a rounding error and is not.

Recall at ten plotted against query latency for an approximate nearest-neighbor index, with exact search marked as the recall 1.0 reference pointquery latency — log scalerecall@10 vs. exact0.700.850.951.001 ms5 ms25 ms120 msexact search · recall 1.00vendor default · recall ≈ 0.96the setting nobody changedaggressive · recall 0.78What the missing 0.04 actually isnot a diffuse 4% haze of quality —specific documents, in unvisited regions,silently unreachable for specific queriesthe gap you inherited
Figure 6.2 — The knob you were given and did not turn. Recall against latency for an approximate index: pushing latency down abandons regions of the space and drops true neighbors, and buying recall back costs time non-linearly. Exact search is the reference point at recall 1.00 — the only configuration with no missing documents, at a latency most products will not pay. The vendor default lands near 0.96, and the missing 0.04 is not an even haze of degradation: it is particular documents, in regions the search did not visit, permanently unreturnable for the particular queries that point at them.
Anti-pattern: treating the ANN top-k as ground truth

Symptom: occasional reports that a document "exists but never comes back" for a particular query. It is reproducible for that query and fine for every similar one, so it reads like a data problem; someone re-indexes the document, the position shifts slightly, the query starts working, and the incident is closed as a mystery. Nobody knows what the index's recall is, and nobody has ever compared it against exact search. Corrective: sample a few hundred queries, run them through brute force and through the index, and compute recall@10 — a half-day of work that turns an inherited default into a number. Then set the knob deliberately against your latency budget, and re-measure after any change to index parameters or corpus size, because recall drifts as the corpus grows.

Two facts make this worth your attention as a product decision rather than an infrastructure detail. First, the trade is yours to make: the same index will run at 0.78 or 0.99 recall depending on a parameter, and the right point depends on whether a missed document costs a scroll or an audit finding — the m5 reasoning, applied to a config value. Second, ANN recall compounds with everything else. If retrieval feeds a RAG feature (m8), a document the index never returns is a fact the model never sees, and the resulting wrong answer will be diagnosed as a model problem by everyone who does not know this parameter exists.

Module 07 Both, not either

m5 built the instrument; m6 built the second engine. This module runs the bake-off honestly, which means running it on the same judged set rather than on the demo queries each engine was chosen to win.

The result is not a winner. Each engine loses queries the other takes, and their averages hide it — which is the first lesson: a single quality number tells you which engine to prefer, and the per-query breakdown tells you what to build. What you build is an architecture: run both branches, merge their rankings without pretending their scores are comparable, and spend real compute on a short list at the end.

The honest bake-off

Same judged set, same corpus, three canonical queries drawn out for inspection, and both engines' full numbers reported.

Comparison grid of three queries run through keyword search, vector search, and hybrid retrieval with reranking, showing each single engine failing at least one queryKeyword (gen 1)Vector (gen 2)Hybrid + rerank (gen 3)Q-BL-4471exact identifierwant: d1✓ d1one exact lookupverbatim field, no fuzziness✗ d2, d5similarity 0.71, 0.68confident garbage; d1 absent✓ d1router short-circuits tothe keyword branchQ-Clike a Barolobut cheaperwant: d2 ($28)✗ d1, d3d1 is the $89 bottlethe user is avoidingd2 shares no vocabulary✓ d2, d1same neighborhood,no shared words✓ d2fusion ties d1 and d2;reranker reads “cheaper”against $89 vs. $28Q-Dred with no oaknegationwant: d4✗ d3, d5the two oakiest wines“no” is just a token✗ d3, d5query embeds into theoak region; no NOT✓ d4fusion does not fix this;the reranker does, byreading both togetherNeither engine column is clean. The third column is not a third engine — it is the first two plus a merge and a quality stage.
Figure 7.1 — Three queries, and no engine survives all of them. Keywords take the exact identifier deterministically and lose the semantic query to the very bottle the user is trying not to buy. Vectors take the semantic query with no shared vocabulary and return confident garbage for the lot code. Both lose the negation query to the two wines the user excluded. The hybrid column resolves all three — but note how: Q-B by routing, Q-C by fusion plus a reranker that can read the price against "cheaper", Q-D by the reranker alone, since no merge of two wrong lists produces a right one.

Now the aggregate, on the same ten-query judged set from m5, with the identifier queries reported separately as before.

GenerationMean precision@3Mean recall@10Identifier found@1
1 · keyword only0.540.792 / 2
2 · vector only0.790.920 / 2
3 · hybrid + rerank0.920.962 / 2

Read the middle row carefully, because it is the row that gets misread in migration proposals. Vector-only improves both relevance averages substantially — and takes identifier lookups from perfect to zero. If your dashboard is mean precision, generation 2 is a triumph; if your warehouse team searches by lot code all day, generation 2 is an outage that does not page anyone. The average concealed a categorical regression, which is the general hazard of steering on a single quality number.

Fusion: merging two ranked lists

You now have two ranked lists per query and need one. The obvious move — average the scores — is numerology. A BM25-family score is an unbounded sum of term contributions that lands somewhere around 2 to 15 in this corpus and shifts with corpus statistics and query length. A cosine similarity is bounded in [−1, 1] and, for a real embedding model, clusters tightly in a narrow band like 0.65 to 0.95. Add or average those and the keyword branch dominates every fused result by sheer magnitude, and no amount of hand-tuned weighting fixes it durably, because the keyword scale moves as the corpus grows while the cosine scale does not.

Reciprocal rank fusion sidesteps the problem by discarding the scores entirely and keeping only what is comparable across engines: position. Each document scores 1/(k + rank) in each list it appears in, summed across lists, with k a smoothing constant conventionally set to 60 to keep the top few positions from dominating absolutely. Rank one contributes 1/61 ≈ 0.0164; rank two, 1/62 ≈ 0.0161; rank three, 1/63 ≈ 0.0159. The differences within a list are tiny — which is the design intent. What moves a document decisively is appearing in both lists.

Work it on J5, austere age-worthy piedmont red. Keyword returns [d1, d2, d4]; vector returns [d2, d11, d1].

DocKeyword rankVector rankRRF scoreFused
d22 → 1/62 = 0.01611 → 1/61 = 0.01640.03251st
d11 → 1/61 = 0.01643 → 1/63 = 0.01590.03232nd
d112 → 1/62 = 0.01610.01613rd
d43 → 1/63 = 0.01590.01594th

The structure is visible in the arithmetic: documents endorsed by both engines score roughly twice what single-engine documents score, so agreement is the dominant signal and neither branch's internal confidence can bully the result. That is the property you want — not because both engines are equally trustworthy, but because their errors are largely uncorrelated, and a document both a lexical and a semantic matcher independently liked is a genuinely stronger candidate than one either liked alone.

Fusion has a limit, and Q-C shows it exactly. Keyword returns [d1, d6, d3]; vector returns [d2, d11, d7]; the lists are disjoint, so d1 and d2 both score 1/61 and tie at the top. Fusion has said the honest thing — two engines each endorse their own leader, and I have no basis to choose — and it is right to say it. Breaking that tie requires reading the query and the documents together, which is the next stage.

Reranking: the quality stage

Both retrieval engines share a structural limitation: they compare a query representation to a document representation that was computed before the query existed. A posting list was built at index time; a document vector was computed at index time. Neither has ever considered this query and this document together.

A reranker removes that limitation for a small number of candidates. It takes the pair — the full query text and the full document text — and scores them jointly, which lets it attend to relationships that no precomputed representation can hold. That is what resolves Q-D: given "red with no oak" and d4's note "Volcanic and taut; red fruit, no new oak, Burgundian in spirit", a model reading both together can register that the document asserts the absence the query requires, while d3's "unapologetically oaky" asserts the opposite. Negation stops being a geometric impossibility and becomes a reading-comprehension task, which is a thing models are good at.

The cost is the whole design constraint. Where retrieval is one index lookup per query regardless of corpus size, reranking is one model inference per candidate. Reranking 40 candidates is 40 inferences; reranking 10,000 is 10,000, and no product's latency budget survives that. So the architecture is a funnel — cheap and wide first, expensive and narrow last — and the candidate count is a line item in a latency budget rather than a free parameter.

Hybrid retrieval pipeline showing a query fanning out to keyword and vector branches, merging at rank fusion, and narrowing through a reranker, with candidate counts and relative cost at each stagequery+ routerkeyword branchanalyzer → inverted indextop 50 · ~4 msvector branchembed → ANN indextop 50 · ~25 msrank fusion~90 · <1 msrerankerreads query + doctop 40 in · ~180 mstop 10 resultsCost per stage: index lookup is O(query) and flat in corpus size · fusion is arithmetic · reranking is one inference per candidate.Total ≈ 210 ms against a 300 ms budget. Raising rerank depth from 40 to 400 costs ~1.8 s and buys almost nothing.identifier? →short-circuit
Figure 7.2 — Cheap and wide, then expensive and narrow. The query fans out to both branches, each returning fifty candidates from indexes whose cost does not grow with the corpus; fusion merges them by rank into roughly ninety unique documents for the price of some arithmetic; the reranker spends real inference on the top forty and returns ten. The identifier router short-circuits to the keyword branch before either index is consulted, which is how Q-B stays deterministic. The whole budget is spent in the last stage, which is why rerank depth is the parameter you defend in a design review.

Choose the depth by measuring, not by taste. Run the judged set at rerank depths of 10, 20, 40, and 100 and plot quality against latency; in most corpora the curve flattens well before 100, because a document ranked eightieth by two independent retrievers is rarely the answer. Depth 40 in the cellar's case costs 180 ms and captures essentially all the available gain.

Choosing your architecture

Hybrid is not a default to adopt unexamined; it is complexity that has to earn its place. Three honest positions.

Keyword-only is right for small structured corpora, identifier-heavy lookup, and audit-demanding domains. A 3,000-document policy library searched by title and control ID has no semantic gap worth an embedding pipeline: the vocabulary is controlled, the users are experts, and the ability to explain exactly why a document matched is a feature that compliance will ask for. It is also dramatically cheaper — no embedding inference at index time, no vector store, no second index to keep fresh.

Vector-only is defensible when queries are uniformly semantic and identifiers genuinely do not exist in the domain: a support-article search where users describe symptoms in their own words and never type a product code, an internal knowledge base whose documents have no stable names. Verify the premise before you rely on it, because "our users never search by identifier" is a claim your logs can test and is wrong more often than teams expect.

Hybrid earns its complexity when the query mix spans both classes — which product search and RAG retrieval almost always do, because catalogues have names and codes and users also describe what they want.

Anti-pattern: vector search as a drop-in upgrade

Symptom: a migration plan proposes retiring the keyword index because "embeddings understand everything keywords do and more." The demo is genuinely dazzling, because it is built from semantic queries. After launch, name lookups, product codes, SKUs, and exact-phrase searches quietly degrade to near-matches; the reports come from operations staff rather than customers, arrive as "search got weird," and take weeks to connect to the migration. Corrective: re-run the judged set before the swap, with identifier queries included and reported separately so the categorical regression cannot hide inside an improved mean — the cellar's generation-2 row (P@3 0.79, identifiers 0/2) makes the trade visible in one line. Then keep both branches and route.

Generation 3, measured on the unchanged judged set, closes the arc: mean precision@3 0.92 against generation 1's 0.54 and generation 2's 0.79; mean recall@10 0.96; identifier lookups back to 2/2. Both single-engine baselines are beaten on both relevance metrics, and the categorical capability is preserved rather than traded. That last clause is the one to carry into a design review: the win is not that the average went up, it is that no query class got worse.

Module 08 The R in RAG — and running it

Retrieval-augmented generation is search wearing a chat interface. A user asks a question, a retriever finds passages, and a model writes an answer from them. The industry talks about it as an AI architecture, which obscures the useful fact: the R is everything this course has been building, and it is the stage that sets a hard ceiling on the answer.

This module puts the seam in the right place — chunking, retrieval, and ranking on this side; prompt construction and generation on Guide Nº 02's side — then covers what it takes to operate search rather than launch it: indexing pipelines that keep a derived copy honest, freshness as a stated contract, and the analytics stream that tells you what your users wanted and did not get.

Retrieval is the ceiling

Draw the pipeline and the constraint is obvious. Documents are chunked, chunks are indexed, a question retrieves some of them, the retrieved text becomes context, and a model writes an answer from that context. The model's input is the retrieved passages. If the fact the user needs is not in them, the model has three options: say it does not know, decline, or invent something plausible. Under commercial pressure to be helpful, the third happens more than anyone would like — and the output is fluent, confident, and wrong, which is the most dangerous class of answer a system can emit, because it carries no signal that anything failed.

So retrieval sets a ceiling. A perfect generator over bad retrieval produces confident nonsense; a mediocre generator over excellent retrieval produces something merely clumsy and correct. This is why the phrase "we need a better model" is usually the wrong first move, and why every skill in modules 1 through 7 is now load-bearing under an AI feature: the analyzer, the scoring function, the judged set, the ANN recall parameter, the reranker. Everything upstream of the model is the R.

The RAG pipeline from documents through chunking, indexing, retrieval, and context assembly to generation, with retrieval highlighted as the quality ceiling and two debugging paths traced backward from a wrong answerdocumentschunkingindex timeindexretrievalm1–m7 live herethe quality ceilingcontextgenerationNº 02the seam — this course ends herewrong answer“drink it now”path 1: right chunk existed, was not retrieved→ a ranking problem · m3, m6, m7 toolspath 2: the fact was never in any one chunk→ a chunking problem · fix at index time, reindexThe model can only synthesize from what retrieval handed it. Both debugging paths point left of the seam —and neither is reached by editing the prompt, which is why retrieved-context inspection is diagnostic step one.
Figure 8.1 — Where the quality actually comes from. Everything left of the gold seam is this course; prompt construction and generation-side behavior sit right of it. Retrieval is drawn heavy because it is the ceiling: the model synthesizes from the passages it was given and cannot recover a fact that never arrived. From one wrong answer, the two backward paths are the diagnostic taxonomy — the right chunk existed and lost the ranking, or the fact was never a retrievable unit at all. The first is fixed with ranking tools, the second at index time with a reindex, and neither is fixed by prompt work.
Cross-reference · Guide Nº 02

Everything right of the seam — how the retrieved passages are assembled into a prompt, how the model is instructed to cite or abstain, how generation-side failures like over-summarization and refusal calibration are handled — belongs to that guide. The seam is worth defending in both directions: a retrieval problem does not get solved by prompt engineering, and a citation-formatting problem does not get solved by tuning your analyzer.

Chunking is index-time design

Documents are usually too long to retrieve whole, so they are cut into chunks, and the chunk is what gets indexed, retrieved, and handed to the model. Which means the chunk boundary decides what facts exist as retrievable units. That is an index-time contract in exactly m2's sense: decided once at write time, unchangeable at query time, and requiring a reindex to alter.

The size trade is real in both directions. Chunks that are too small fragment context — a passage saying "it needs another decade" is useless without whatever "it" refers to. Chunks that are too large dilute: a 3,000-word chunk embeds to a vector that averages a dozen topics and is strongly similar to nothing, and in the keyword branch its length normalization penalty (m3) buries it. Somewhere in the middle is right for your corpus, and the way to find it is to run the judged set at several chunk sizes rather than to adopt a default.

The boundary policy matters more than the size. A fixed character count cuts wherever the counter lands, which is routinely mid-sentence, mid-clause, and mid-fact.

One cellar entry chunked two ways, by fixed character count cutting mid-sentence and by sentence boundaries keeping the fact intactSource: d1 cellar entry · 160 characters2016 Barolo Monvigliero, in Verduno, Piedmont. 100% Nebbiolo, aged 38 months in largeSlavonian botti. Tar and roses, firm tannins; austere now, needs a decade.Fixed 150-character windowchunk 1 · chars 1–150…Tar and roses, firm tannins; austere now, needscut lands herechunk 2 · 151–160a decade.Query “when should I drink the Monvigliero?” retrieves chunk 1 — which says “austere now, needs” and stops. The model reads “now.”Sentence-aware boundarieschunk 1 · identity2016 Barolo Monvigliero…chunk 2 · production100% Nebbiolo, aged 38…chunk 3 · the drink-window fact, whole…austere now, needs a decade.The same query retrieves chunk 3 and the model reads the complete claim: austere now, ready in about ten years. ✓
Figure 8.2 — The boundary decides what facts exist. A 150-character window lands its cut between "needs" and "a decade", splitting one claim into two chunks. The chunk that survives retrieval says the wine is austere now and stops, so a model reading it faithfully produces the opposite of the truth — and the orphaned ten-character chunk is too small to retrieve for anything. Splitting on sentence boundaries keeps the claim intact as one retrievable unit. The rule: a fact that spans a chunk boundary is a fact your system does not have.
Anti-pattern: fixed-character chunking through structural boundaries

Symptom: retrieved context contains fragments beginning or ending mid-sentence; answers cite half a fact or attribute a clause to the wrong subject; the errors are not reproducible by topic but they cluster around long documents. Corrective: split on structure first — sentences, paragraphs, sections, list items, contract clauses — and use a size limit as a ceiling rather than as the boundary rule, with a sentence or two of overlap between adjacent chunks so a claim that genuinely spans a boundary appears whole in at least one chunk. Then re-run the judged set, because chunking changes what is retrievable and therefore changes every metric.

Structure-aware chunking is more valuable the more structured the corpus is, and it is worth naming what "structure" means in a legal or compliance document: a contract's retrievable unit is the clause, not 512 characters. A control catalogue's unit is the control. Cutting a clause in half does not merely lose text — it can invert its meaning, because the exceptions and carve-outs live at the end.

Diagnosing backward

Here is the cellar assistant's incident in full, because the sequence of moves is the transferable part.

The report. A customer asks the assistant, "When should I drink the Barolo Monvigliero?" It answers: "It is drinking well now — the tannins are firm and the wine is showing its austere side, so it is ready to enjoy." That is wrong, and confidently so. The note says the opposite: austere now, needs a decade.

Move one: read the retrieved context, not the prompt. The instinct is to open the system prompt and start adding instructions about carefulness. Resist it. Pull the passages the retriever actually returned for this question and read them as the model saw them. Doing that here surfaces the answer in about four minutes: the top retrieved chunk ends with the string ...austere now, needs. It just stops. The model did not hallucinate; it read a truncated claim and completed it in the most natural direction.

Move two: locate the earliest failing stage. Working backward through Figure 8.1: generation is faithful to its context, so not there. Context assembly passed through what retrieval gave it, so not there. Retrieval returned the most relevant chunk that existed — so not a ranking failure either. The failure is at chunking: a fixed 150-character window cut the drink-window claim in two, and the fragment carrying "a decade" was a ten-character orphan that will never win a retrieval for anything.

Move three: fix at the correct stage. Switch to sentence-aware chunking with a size ceiling and one sentence of overlap, reindex the corpus, and re-run the judged set to confirm nothing else moved. No prompt was edited and no model was changed — worth stating plainly in the incident write-up, because it is the sentence that changes how the next incident gets triaged.

Move four: prevent recurrence. Add this question and its correct answer to the retrieval judged set as a regression case. A fix that is not in the regression suite is a fix with a shelf life.

Anti-pattern: blaming the model for answers the retriever never enabled

Symptom: a multi-week prompt-engineering effort that does not converge — each revision fixes the reported example and a new one appears — while nobody has looked at the retrieved context for any of the failures. Often accompanied by a proposal to upgrade the model, and sometimes by an unnecessary fine-tuning project. Corrective: retrieved-context inspection is diagnostic step one, always, and it is cheap: log the retrieved passages with every answer so the inspection takes four minutes rather than a reproduction effort. If the fact was in the context and the model got it wrong, you have a genuine generation problem and Guide Nº 02 is the right destination; that outcome is real, and it is the minority.

Freshness and the indexing pipeline

The index is a derived copy of a source of truth, and derived copies drift. This is the m1 framing coming due: the database is the system of record and the index is a query-optimized projection, which means something must continuously reconcile them.

Two shapes. A full reindex rebuilds everything from source — simple, self-correcting, and expensive enough that you run it on a schedule rather than continuously. Incremental indexing consumes change events and updates affected documents — cheap per change, near-real-time, and subtly fragile, because it accumulates whatever the event stream drops. Most production systems run both: incremental for freshness, periodic full reindex as the correctness backstop. Note that an analyzer or chunking change forces a full reindex regardless (m2), so the machinery has to exist anyway.

State freshness as a contract with a number in it: search reflects catalogue changes within five minutes at p99. A number can be monitored and can be wrong; "near real-time" cannot be either.

The failure that matters is the silent one. An indexer stops consuming — a poison message, an expired credential, a deploy that quietly fails its health check — and nothing user-facing errors. Search stays fast. Results keep arriving. They are simply the results from whenever the pipeline stopped, and the divergence grows every hour. Users experience it as "I added that bottle yesterday and it isn't there" and "this one sold out last week and it's still showing," which sound like two unrelated data bugs and are one stalled consumer.

Indexing pipeline swimlanes showing normal change propagation with a freshness window, and below it the silent stall where the indexer stops while search keeps serving stale resultsNormal — freshness contract: search reflects the catalogue within 5 minutes (p99)source dbsystem of recordchange eventsqueue · Guide Nº 17indexeranalyze · chunk · embedindexsearchSilent stall — nothing errors, latency is normal, users are served confidently wrong datasource dbstill changing ✓change eventsbacklog growingindexer stoppedpoison message · 03:14indexfrozen at 03:14search200 OK, fasttripwire: indexing lag > 5 min → alertMonitor lag (source timestamp minus newest indexed timestamp), not indexer uptime — a process can be alive and consuming nothing.Back it with periodic source-vs-index reconciliation on counts and checksums, which catches drops the lag metric cannot see.
Figure 8.3 — The failure with no error. In the stalled lane every component reports health: the source is accepting writes, the queue is accepting messages, search is returning 200s in single-digit milliseconds. Only the index is wrong, and being wrong is not an error condition. The tripwire is lag — the age of the newest indexed document against the source — because it measures the property that actually matters, unlike process uptime, which stays green through the entire incident.
Cross-reference · Guide Nº 17

An indexing pipeline is an asynchronous system, and everything that guide says applies here without amendment: queues, consumer lag, at-least-once delivery (so indexing operations should be idempotent — reindexing a document twice must be harmless), poison messages and dead-letter queues, backpressure when a bulk update floods the stream. If you have built one of those systems you already know how this one fails; the only search-specific part is that the visible symptom is bad search results rather than a growing queue depth.

The zero-results report

Of everything a search product can instrument, the highest-signal stream is the list of queries that returned nothing. Each one is a user telling you, in their own words, exactly what they wanted and did not get — unprompted, specific, and free. There is no research method that produces better material, and most teams do not log it.

Mined weekly, zero-result queries sort into three bins, and the sorting is the work.

Vocabulary gaps. Users say something your corpus does not. Customers search claret and your notes say Bordeaux; they search orange wine and your notes say skin-contact. The corrective is a synonym list or a query-expansion rule, and the discipline is that each addition is a relevance change and runs against the judged set like any other.

Content gaps. Users want something you do not have. Forty searches for non-alcoholic in a month with zero results is not a search bug; it is a merchandising finding delivered by your search box, and it belongs in a product review rather than a bug queue. This is the bin that pays for the instrumentation.

Pipeline bugs. The m4 mismatch class shows up here first and unmistakably: a cluster of zero-result queries sharing a morphological signature — all plurals, all accented, all uppercase — is a broken analyzer contract announcing itself. Because this signature is so distinctive, a weekly scan of the zero-results report is effectively a monitor for a bug class that produces no errors anywhere else.

Anti-pattern: shipping search with no query analytics

Symptom: the team learns about search failures from support tickets and hallway anecdotes; nobody can say what the zero-results rate is or whether it moved after the last release; the judged set, if one exists, was built from imagined queries because no log of real ones exists. Corrective: from day one, log the query string, the result count, the ranked result ids, and whether anything was clicked and at what position. Three of those four are free, and together they give you the zero-results report, a source of real queries for the judged set, and click position as a weak relevance signal for spotting regressions between formal evaluations.

Note the loop this closes. The zero-results report supplies real queries; those queries populate the judged set (m5); the judged set referees the tuning (m3, m4, m6, m7); the tuning changes what floats to the top — which was m1's claim about what the product actually is. Search is not a feature you ship; it is a loop you run, and the report is where the loop takes its input from reality.

One last framing worth carrying out of this course. Every module named a mechanism and its failure mode, and every failure mode had the same property: it is silent. A mismatched analyzer returns zero results and reports success. An ANN index drops documents without mentioning it. A vector store answers an identifier query with confident garbage. A stalled indexer serves stale data at full speed. A chunk cut mid-sentence produces a fluent, wrong answer. Nothing in a search stack raises an exception when it stops being useful — which is why the instrumentation in this module and the measurement in m5 are not operational hygiene layered on top of the real work. They are the only reason you would ever find out.

Concept index

Relevance
How well a result serves the intent behind a query — a judgment, not a predicate.
Ranking
The ordering of matched results; in search, the ordering is the product.
Inverted index
A map from each term to the documents containing it, turning search into lookup instead of scan.
Posting list
One term's entry in the inverted index: the documents, with frequencies and positions, that contain it.
Tokenization
The policy that splits text into indexable tokens — a decision, not a fact of nature.
Normalization
Index-time transformations such as case folding and accent stripping that make literal variants match.
Stemming
Reducing words to a shared stem (tannins to tannin), trading precision for recall.
Analyzer
The full index-time text pipeline — tokenize, normalize, stem — and a contract both index and query must honor.
Term frequency
How often a term appears in a document: evidence the document is about it, with diminishing returns.
Term rarity (IDF)
How few documents contain a term — rarer terms carry more information per match.
Saturation
The bending of frequency's contribution so the tenth occurrence adds far less than the second.
BM25
The standard keyword scoring family combining term frequency, rarity, and length normalization.
Length normalization
Adjusting scores so long documents cannot win by sheer volume of text.
Field weighting
Scoring matches differently by field — a hit in the name outranks a hit in the tasting note.
Boolean query
An exact, unranked query contract built from AND, OR, and NOT: precise, brittle, expert-facing.
Phrase query
Matching terms in exact sequence, using positions stored in the posting list.
Proximity search
Scoring terms higher when they occur near each other — a soft phrase.
Fuzziness
Tolerating typos via edit distance — helpful on words, corrupting on identifiers.
Mirror-or-miss
The rule that query-time analysis must match index-time analysis, or terms never meet.
Precision
Of the results returned, the fraction that were relevant — the junk metric.
Recall
Of the relevant documents, the fraction returned — the missing-treasure metric.
Judged query set
Real queries with human relevance judgments — the regression suite for ranking.
Precision@k
Precision computed over the top k results — the slice users actually see.
Embedding
A learned mapping from text to a point in space where nearby means similar-in-usage.
Cosine similarity
Similarity as the angle between two vectors — the standard nearness measure for embeddings.
Nearest-neighbor search
Finding the closest vectors to a query point; done exactly, it costs one comparison per document.
Approximate nearest neighbor (ANN)
Index structures that find near neighbors fast by sometimes missing the true nearest — recall traded for speed.
Recall@k (ANN)
The fraction of the true top-k neighbors an approximate index actually returns.
Hybrid retrieval
Running keyword and vector search together because each catches what the other misses.
Reciprocal rank fusion
Merging ranked lists by position rather than score, sidestepping incomparable scales.
Reranker
An expensive model that re-scores a short candidate list by reading query and document together.
Chunking
Cutting documents into retrievable units at index time — the boundary decides what facts can be found.
Retrieval-augmented generation (RAG)
Answering with a model over retrieved context — search wearing a chat interface.
Index freshness
How closely the index tracks its source of truth, stated as a window and monitored as a lag.
Zero-results rate
The share of queries returning nothing — the highest-signal analytics a search product emits.

This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.