Freedam
EngineeringPart 5 of 7 · 28 min read

Measuring a search nobody thought was broken

Search is the only part of a product that can be badly broken while every test passes, every page loads and every user says it is fine. A search that has quietly lost two thirds of its matches returns ten plausible results in ninety milliseconds, exactly like a search that has lost none, and nobody looking at it can tell the difference, because they cannot see what is missing.

This is the fourth article in a series on how search works inside freedam. The first covered retrieval: manufacturing a searchable document, fusing BM25 with pgvector, guaranteeing recall under caps. The second took text out of the problem and looked at perceptual hashing. The third put it back and asked which language the index is in. All three quote numbers. This one is about where those numbers came from.

The short version: a sixty-case relevance panel, run manually against a replica of a real customer library of 22,944 assets. The first serious run found fifteen hard failures in a search everyone described as pretty good. Over the next eight runs it reversed a confident diagnosis, priced a fix nobody wanted to pay for, and caught itself being structurally blind to the feature it was validating.

Part 1: "Pretty good" is not a measurement

Nobody had reported a search problem. The library worked. People found things. The internal verdict, if you asked, was that search was pretty good.

The first serious run of the panel returned exit code 1: forty-five passes, fifteen hard failures, zero errored cases, across sixty cases on a 22,944-asset replica of a real retail catalogue.

Run 1 of the relevance panel, sixty cases by category, forty-five passing and fifteen failing RUN 1, SIXTY CASES, ONE REAL CUSTOMER CORPUS autocomplete color compatibility did_you_mean gtin material misspelling model negative part_number phrase scoped semantic suggestion EXACT IDENTIFIERS WERE PRODUCTION-GRADE GTIN lookup scored recall@10 1.000, recall@50 1.000 and MRR 1.000. Part numbers scored 0.833 across six cases. Quoted phrases, 1.000. Nothing to fix. Every category where the query is a string the catalogue also stores, verbatim, was already right. EVERYTHING TYPED IN WORDS DEGRADED material recall@10 0.340, colour 0.520, model 0.640, device compatibility 0.537 with five hard failures. Both collection-scoped cases failed. Both autocomplete cases failed. Green passed, red hard-failed. 45 / 15 / 0 errored, on a search that nobody had filed a single complaint about.
The first run of the panel, by category. The shape is the finding: retrieval was perfect for queries that are also stored strings, and fell apart for everything a retailer would actually type.

The reason this can happen is not subtle, and it applies to every search product on earth.

Perceived search quality has almost no resolution below the top three results. A user types a term, glances at the first two or three, and forms a judgement. Whether the other 178 matching assets were reachable at all is invisible: no interface says "and 166 more that we cannot show you", and nobody scrolls to position 900 to check that position 900 exists. So a system can lose most of a result set and still feel, to everyone who uses it, pretty good.

The panel found galaxy a52 case returning twelve results when 178 assets carried that exact device. That number was there to be found on any day of the previous year. It took writing a case down.

Part 2: What has to be in a case before it is worth running

A relevance case is not a test. It is a claim about the corpus plus a claim about what the system should do with it, and the second is worthless if the first goes stale.

Each case carries an id, a category, the query as a retailer would type it, ground truth, and assertions split into hard and soft. Hard assertions fail the run; soft targets are reported and never fail anything, which sounds like a weakness and is a specific trap Part 8 comes back to. The interesting field is ground truth, and specifically the fact that it is almost never a list of asset ids.

Anatomy of one relevance case, with ground truth resolved from the database rather than from search ONE CASE id: compat-galaxya52-01 category: compatibility query: galaxy a52 case ground_truth: derived metadata_equals device = Galaxy A52/A52S hard find_all, min_results 1 soft first_relevant_in_top_k 10 THE SYSTEM UNDER TEST the same entry point the gallery search box calls, paginated to exhaustion GROUND TRUTH resolved against the tenant database at run time, never through search EVALUATOR pass or hard_fail recall@10, recall@50 reciprocal rank, notes THE ONE RULE Ground truth must be independent of the thing being measured. 51 of the panel's 63 cases carry derived ground truth. Zero carry a hand-written id list. Twelve assert something other than membership.
The two lanes never touch. The search path resolves the query; the resolver resolves the specification straight against the tenant database through the asset model's own query builder, so soft-deleted and archived rows are outside both.

A panel that dies when the corpus is replaced stops being run. That is the whole argument for derived ground truth, and it is practical rather than purist. The replica gets re-snapshotted from production whenever somebody needs fresh data, and asset ids do not survive that. A case pinned to 178 identifiers becomes an errored case the morning after a re-snapshot, and a suite that errors on a third of its cases is ignored within a week and deleted within a month.

So a derived case declares what it means rather than what it matched last time. Four specification types cover the panel: a metadata field equal to a value, one matching a prefix or substring, membership of a named collection, and a token-level title match. The resolver runs the specification against the database at the start of each run and hands the evaluator a fresh id set.

The schema also allows curated ground truth, an explicit id list, for cases where relevance is genuinely a judgement rather than a property. The panel currently uses zero of them. That is not an oversight. Curated cases are exactly the ones a re-snapshot invalidates, so every one of them is a future maintenance event, and the discipline of expressing a case as a specification usually reveals that the judgement was hiding an unstated rule.

One resolver detail is worth copying. A collection name has no unique constraint and a metadata key is unique only per asset class, so both lookups can genuinely match several rows. Picking the first silently would let a find_all case pass while half its assets were missing, because the expected set had quietly narrowed. Both refuse ambiguity instead: errored case, exit 2. A ground-truth resolver that guesses is worse than one that fails, because a case that resolves to the wrong set is a green light with no basis.

Twelve cases carry no id ground truth at all, because they measure something other than membership: the did-you-mean hint, the suggestions dropdown, the autocomplete strings, and the negative cases that must return nothing. And because "helpful" is a judgement call, every assist case carries a written rationale, frozen only after the live dropdown for that query was captured from the replica and reviewed by a person. An assist expectation invented at a desk measures the author's imagination.

Part 3: An instrument has to state its own conditions

Two things decide whether a relevance number means anything, and both are properties of the instrument rather than of the search: what configuration produced it, and what the candidate cap did to it before anybody looked.

Every run prints a settings snapshot before the first case executes: hybrid.bm25_top_k, hybrid.limit, hybrid.vector_top_k, hybrid.rrf_k, both fusion weights, typo.enabled and prefix.enabled. A misspelling result is meaningless without knowing whether typo correction was on. So is a recall figure without the candidate limit that produced it. Two runs whose numbers disagree are evidence of either a code change or a settings change, and the only way to tell is to have recorded both. Every claim in this article is qualified by a snapshot, which is why they can be compared at all: runs 1 through 8 all measured hybrid.limit 1000, hybrid.rrf_k 60, weights 1.0 and 1.0, typo.enabled true.

The snapshot also caught something nobody was looking for. On the tenant used for the first three runs, prefix.enabled printed as null. Not false. Null, meaning no settings row existed at all. The code default is true, so prefix matching was running and every result reflected it, but nobody had ever decided that, and reading the settings screen would never have told you, because a screen renders the effective value. The report printed the stored one. An effective value tells you what happened; a stored value tells you whether anybody decided it.

The second condition changes what an assertion is allowed to say. A total equal to the cap is not a count. On this tenant the candidate limit is 1000, so any query with more than a thousand plausible matches returns exactly 1000, which is the shape of the cap rather than a measurement of the corpus. A case whose ground truth is 2,189 assets cannot assert that all 2,189 appear in a result set structurally incapable of holding more than a thousand. Assert it anyway and the case is permanently red, and a permanently red case is worse than none: it trains everybody to read the run summary as "the usual failures plus whatever is new", which is how a real regression hides.

The saturation guard: how find_all downgrades to sampled recall on large ground-truth sets how big is the ground-truth set? threshold: 200 200 OR FEWER: FULL MEMBERSHIP every expected asset must appear in the collected result set, or the case hard-fails and names the misses MORE THAN 200: SAMPLED RECALL a deterministic sample of 50, ordered by a hash of case id and asset id, must score at least 0.80, and the report records that the downgrade happened SATURATION IS LABELLED, NEVER ASSERTED AS A COUNT If the result may be truncated, its total is a lower bound. max_results is skipped with a note instead. BUT A MISS STILL FAILS A directly matching asset that the cap kept out is reported as "not findable within candidate window" and hard-fails anyway. The sample is drawn by hashing case id with asset id, so it touches no global seed and the same case always draws the same fifty.
Downgrading the assertion is a concession to the cap. Excusing the failure is not. The guard changes how the question is asked and refuses to change the answer.

The second half of that figure is the load-bearing half. It would have been easy, and wrong, to treat saturation as a get-out: the result was truncated, so of course some assets are missing, so do not fail. That would make the whole class of cap defects invisible to the instrument built to find them. The settled product decision is that an asset whose title contains the query must always be findable regardless of rank or cap, so cap pressure is not an excuse for losing a directly matching asset. The report says so in the failure text: not findable within candidate window is a failure with its cause attached.

One sampling detail is worth copying. The sample is drawn by hashing rather than by a seeded random draw, so the same case draws the same fifty every run, and sampled recall 0.76 in run 1 against 0.76 in run 2 is a real comparison rather than two draws from the same urn.

Part 4: Failures cluster by mechanism, not by symptom

Fifteen failures spread across seven of the panel's fourteen categories looks like fifteen problems. It was six.

The fifteen run-1 failures sorted into six mechanisms, and how many cases each fix moved FIFTEEN FAILURES, SIX MECHANISMS Slash-joined device labels indexed as one token galaxy a52 case, iphone 12 case, iphone x case 3 Scope applied after the candidate cap, not before black in a 28-item collection returned 1; tan in a 24-item returned 10 2 Genuine cap crowding on large ranges lynge 0.76, copenhagen 0.60, iphone 16 case 0.52 sampled recall 3 Vocabulary the corpus does not contain vegan leather case returned 0; airtag holder found 28 of 149 2 Typo machinery iphon, kickstnd 2 Dropdown junk raw SKUs, filenames, copii 3 FIXING A CAUSE One tokenisation change moved three cases at once and changed nothing else that was measured. One scope change moved two, and not one other case moved on any metric at all. FIXING A SYMPTOM The five compatibility failures look like one problem and are three: tokenisation, a real cap, and a missing word. Only one is about retrieval limits at all. Three different fixes, one symptom.
Sorting failures by category tells you where they show up. Sorting them by mechanism tells you how many fixes you need, which is a different and much smaller number.

The finding in the first row is worth the detail, because it is the kind of defect that survives every reasonable investigation.

Device compatibility values in this catalogue look like Galaxy A52/A52S and iPhone 12/12 Pro. PostgreSQL's English parser classifies a slash-joined value as a single token of type file, so Galaxy A52/A52S is indexed as the lexeme a52/a52s and a search for a52 cannot match it. The text was in the document; a substring search found it; the tsvector did not contain it. Across the corpus, 178 documents held the raw text A52 and twelve held the lexeme, those twelve arriving via space-separated titles.

Every wrong diagnosis is available here. It looks like missing metadata, and the metadata is present. It looks like a stale index, and re-indexing regenerates exactly the same lexemes, so it fixes nothing while producing a plausible "we tried that". The correct fix is at index time: when a vocabulary label contains a slash, emit the split segments alongside the original.

There was also a booby trap. The tokeniser feeding the typo dictionary splits on slashes; the document builder did not. So the dictionary confidently reported a52 in 178 documents while BM25 could reach twelve. The instrumentation was reporting the answer the fix was supposed to produce, before the fix existed.

Run 2 measured the tokenisation fix and the scope change separately, in one four-point design. galaxy a52 case went from 12 results to 178 with zero assets unfindable, iphone 12 case from sampled recall 0.06 to 1.00, and the two scoped cases from 1 of 28 and 10 of 24 to full membership. Between the scope-off and scope-on arms, nothing else in the panel moved at all: not a verdict, not a total, not a recall figure, not an MRR. The design constraint that unscoped searches pay zero additional cost stopped being an assertion and became a measurement.

Part 5: Analysis proposes, measurement decides

This is the part of the story worth the price of the whole instrument.

The tokenisation fix had a side effect. It first shipped applying its rule to the entire normalised document rather than to vocabulary labels only, so it split every slash-joined token from any source: EXIF shutter speeds like 1/100, voltages, URLs, EU directive references like 2009/125/EC, ordinary conjunctions. Roughly half the corpus grew, and documents carrying a slash averaged 3,758 characters against 917 for those that did not.

BM25 normalises by document length, so every document that grew lost a little score on every term it contained and every document that did not grow gained rank for free. Sure enough, five cases regressed on recall@10 between run 1 and run 2, two of them from a perfect score to zero. The analysis wrote itself: the expansion inflated document lengths, inflated lengths demoted the ground truth, and narrowing the expansion to vocabulary labels would reverse it while keeping the recall recovery.

That was the recorded prediction. Run 3 narrowed the expansion, rebuilt the corpus, and measured it.

Five recall regressions attributed to tokenisation, four of which moved by exactly zero when it was fixed RECALL@10, THE FIVE CASES THE NARROWING WAS PREDICTED TO REVERSE case run 1 run 3a run 3b model-greenland-01 0.70 0.00 0.00 misspelling-grenland-01 1.00 0.00 0.00 misspelling-iphon-01 0.70 0.00 0.00 color-clear-01 1.00 0.80 0.80 material-biodegradable 0.70 0.60 0.70 Four of the five are identical to fifteen decimal places. Same recall@10, same recall@50, same reciprocal rank. One case recovered. WHAT ACTUALLY DECIDES THE QUERY GREENLAND ranks 1 to 11: Product Video documents, 155 to 493 characters rank 12 onward: product images, 746 to 1,280 characters the document at the boundary contains no slash at all THE ATTRIBUTION Slash expansion inflated document lengths. BM25 penalises length. Ground truth was demoted. Coherent, specific, mechanism- level, and written down before anyone measured it. THE MEASUREMENT 2,077 of the 2,189 ground-truth documents did carry a slash and were inflated. Removing the inflation moved the ranking by zero positions. The cause was document length.
The attribution was not lazy. It named a real mechanism, in the right subsystem, and it was wrong four times out of five. Tens of characters added to a document of hundreds is a rounding error next to a three-to-eight-times length ratio between a video clip's document and a product image's.

The narrowing did exactly what it was designed to do at the corpus level. The lexeme a52 still matched 178 documents; the lexeme 2009, which had been leaking out of 2009/125/EC, matched zero. And four of the five predicted reversals did not move by a hair.

The real mechanism was visible once someone stopped looking at tokens and started looking at lengths. The query greenland was being won by eleven Product Video documents of 155 to 493 characters, each essentially the title repeated by its field weight plus a little codec metadata. The first product image, which is what the case's ground truth consists of, arrived at rank 12 at 751 characters against a ground-truth average of 1,280. A three-to-eight-times length ratio decides an ordering on its own, and the document at the boundary contained no slash at all, so neither version of the expansion had ever touched it.

A wrong diagnosis that is coherent, specific and mechanism-level is much more dangerous than a vague one, because it survives review. Four people can agree with it. The only thing that disagreed was a measurement, and the only reason a measurement existed is that somebody had written down the prediction beforehand in a form that could be scored.

The narrowing shipped regardless, because it was still the right change. It also cost a case. compat-iphonexxs-01, the query iphone x case, went from sampled recall 1.00 to 0.74 and flipped from pass to hard failure, because its result set saturates at the cap in every run and the over-broad expansion had happened to lengthen its competitors just enough to admit more iPhone X and Xs assets. Take the pollution away, the competitors get shorter, score better, and crowd back in. The panel went from 48 passes to 47, and the trade-off went into the record alongside the fix.

A relevance change with no measured cost usually means an unmeasured cost. Ranking is close to zero-sum inside a capped window: something that rises pushes something down. If your instrument reports only improvements, either you found a genuinely free win, which happens, or your case set has nothing pointed at what you displaced, which happens more often. A case that fails for a known, priced reason is not a broken test; it is an accepted liability with a name. The failure mode to avoid is not "the suite is red", it is "nobody can say which reds are on purpose".

The habit worth stealing is smaller than the finding: write the prediction down, per case, before the run. Not "this should improve recall" but "these three cases should flip and these two should not move". Run 2 scored ten such predictions and got ten right, and the informative half was the negative ones, because iphone 16 case and airtag holder were the cases that would have exposed a misdiagnosis. Run 3 scored five and got one right. Both runs were worth the same, and only one of them felt good.

Part 6: A panel can only see what it has cases for

Run 4 was a canary for a different feature: the title-findability guarantee, which promises that an asset whose title contains the query is findable by that query regardless of rank or cap. Control arm off, treatment arm on, same corpus, same build, same settings. One caveat travels with every number from here on: run 4 measured a fresh replica of production, which has deliberately not been re-indexed since the tokenisation fix shipped, so its corpus is the pre-fix one. Both arms share it, which is all an A/B needs, but the run-4 verdicts are not comparable case by case with run 3 even where the totals coincide.

The result was flat on verdicts, 47 passes in both arms, and unambiguous on metrics. Mean recall@10 fell from 0.6667 to 0.6333, mean recall@50 from 0.7446 to 0.7338, and three cases regressed. Not one case improved on any relevance metric.

Read at face value, that is a feature which costs relevance and delivers nothing. The correct reading is different, and the run record says so in its own headline: not one of the sixty cases asked whether a beyond-cap titled asset is findable by its title, which is the entire contract the guarantee exists to satisfy. Every case graded retrieval against metadata-derived ground truth: a range, a colour, a device list, a GTIN. The instrument was structurally incapable of observing the benefit it was being used to judge.

Run 4 therefore decided nothing except that the panel needed cases. Run 5 added three.

A blind panel and a sighted one: the same feature measured before and after title cases were added THE SAME FEATURE, THE SAME CORPUS, TWO DIFFERENT INSTRUMENTS RUN 4: SIXTY CASES, NO TITLE CASE verdicts 47 / 13 with the guarantee off verdicts 47 / 13 with the guarantee on 0 cases better, 3 cases worse Reads as: the feature costs relevance and buys nothing. RUN 5: SIXTY-THREE CASES verdicts 48 / 15 with the guarantee off verdicts 50 / 13 with the guarantee on 2 hard failures recovered, 0 introduced Same three cases still cost recall@10. Now there is something on the other side. THE THREE CASES, AND WHY ONE OF THEM IS A CONTROL title-mainimage-01 band of 3,339 titles, well past the 1,000 candidate cap 0.32 sampled recall to 1.00 title-product-01 band of 2,156 against 13,516 competing documents 0.30 sampled recall to 1.00 title-lifestyle...-01 band of 188, under the cap and under the sampling threshold passes in BOTH arms. The control.
Two cases the feature is supposed to fix, and one it is supposed to leave alone. Without the third, a clean sweep would show that the cases were written to the answer rather than that the guarantee works.

The control case is the one that looks like padding. Its title band is 188 assets: below the 1,000 candidate cap, and below the 200-asset threshold at which membership assertions downgrade to sampling. Nothing is ever cut, so it passes with the guarantee off and with it on, identical in both arms.

A case set where every case only passes with the feature enabled proves that the cases were written to the answer. It cannot distinguish "the guarantee works" from "somebody chose three queries the guarantee happens to help". The control is the case that would have gone red if the implementation were doing something broader than advertised, such as reordering results that were already fully reachable. It stayed green and unchanged, which is a claim the other two cases cannot make on their own.

The addition also let the panel see something the sixty cases never could. title-product-01 returned 2,587 results against a ground truth of 2,156, meaning 431 non-title matches survived alongside a band more than twice the size of the cap. An earlier version of the guarantee had numbered the band and the ranked pool together, so the band took every position under the cap, got re-admitted anyway by its own clause, and silently deleted the ranked matches it displaced. Those 431 assets are that fix, visible on a real corpus.

Then the panel priced the cost, and paid for one more run that returned nothing. With the guarantee on, the whole title band still sorted ahead of every content-only match, because the final ordering puts a phrase-match boost first and every member of a single-word query's band earns that boost by construction. The first fix added a demotion inside the search plan's SQL, and run 6 came back byte-identical to run 5b: not one metric on one case moved. That plan is consumed as a joined subquery, and a subquery's ordering does not survive the join; three places emit the clause and only two govern what a user sees. Moving the demotion to both consumers took the panel to zero regressions on any relevance metric across all sixty-three cases, with both title cases still passing on full bands of 3,339 and 2,587. An unchanged result is different in kind from a partially changed one, and only one of the two means the fix did not work.

Part 7: Change is not correctness

Everything so far comes from one instrument with a fixed blind spot. Its cases probe failure modes we already suspected and its ground truth is derived from metadata, so it answers "is the right thing findable" precisely and cannot answer "would a real user notice" at all. Part 6 is what happens when you forget that.

The second instrument is built from the production query log, and it is worth being clear about the kind of evidence it produces before quoting any of it. It has no ground truth. It compares two arms of the same corpus and reports what moved. That measures change, not correctness: a query whose top ten is identical is unchanged, which is not the same as verified, and a query that gained results has gained reachable results, not good ones.

The curated panel and the query-log replay answer different questions and neither substitutes for the other THE CURATED PANEL 63 cases, 15 categories, derived ground truth resolved from the database Answers: is the right thing findable? Grades recall@10, recall@50, MRR and hard membership against a known set. Cannot answer: would anyone notice? Its queries were written by us, to probe mechanisms we already suspected. THE QUERY-LOG REPLAY 55 real keyword searches from ten weeks of one tenant's log, no ground truth Answers: did anything get worse? Compares the top ten and the total of each query between two arms. Cannot answer: is any of it correct? An identical top ten is unchanged, which is not the same as verified. WHAT THE REPLAY MEASURED, TITLE GUARANTEE OFF VERSUS ON 55 / 55 top ten identical 5 totals gained results 0 totals lost results 2 zero-result, before and after
Neither instrument is a superset of the other. The panel knows what the right answer is and does not know what anybody asked; the replay knows what everybody asked and has no idea what the right answer is.

The source was analytics_searches on one production tenant: 140 rows covering 99 distinct terms over ten weeks. Only aggregate fields were extracted, the term, its occurrence count, the search type and the recorded result count. No user id and no session id left the table. A query log is one of the most personally revealing artefacts a product holds, and a relevance instrument does not need to know who typed anything.

Of the 99 distinct terms, 55 were keyword searches and 44 were serialised advanced filter trees. The 44 were excluded with a stated reason rather than quietly dropped: a tree with no full-text leaf never reaches the hybrid search path, so including them would have inflated the "nothing changed" denominator with queries that could not possibly change. An exclusion without a reason is a number you cannot audit. The 55 that remained look nothing like a relevance panel: SKUs, filenames with extensions, brand phrases, natural-language descriptions, case variants of the same word counted separately, one misspelling, one Danish term, and a single paste of 79 SKUs.

The result was strictly additive. The top ten was identical on all 55. Five totals grew, every one a query already pinned at the 1,000 candidate cap, so the gain was purely beyond-cap reachability: greenland 1000 to 1014, iphone 1000 to 1010, phone and blue products 1000 to 1005. Nothing lost results, and the two zero-result queries were the same two before and after.

That is not a result about quality. It is a result about blast radius, which is the thing a curated panel is worst at estimating, because its sixty-three queries were chosen precisely for being sensitive to the mechanisms under test while the replay's were chosen by customers for their own reasons. Both facts are true at once, and neither instrument can state both.

It did find one thing the panel could not. With the band merged in, the reported candidate limit became null on 52 of the 55 queries while the truncation flag stayed set on all of them, so the interface goes from "may be truncated, limit 1000" to "may be truncated, depth not describable by one number". That is honest, and it is a copy decision to make before rollout rather than discover afterwards.

Latency came with its own lesson in measurement hygiene. The first pass suggested the title band made search faster, which is not a thing a band can do: the arms had run sequentially and the second inherited warm caches. Warm against warm, the difference is about five milliseconds of mean, which is noise, with the worst individual queries eight to ten milliseconds slower. If your A and B did not run under the same cache conditions, you measured the cache.

The limit is the sample. Fifty-five distinct terms from one tenant over ten weeks, skewed towards SKU lookups, is a small window on exactly the query shape least likely to be affected by the thing being tested.

Part 8: Checks that cannot fail

Everything above assumes the instrument works. That assumption failed four times in a single day, in four places, in one shape.

A missing check is obvious because nobody wrote it. A check that cannot fail looks like coverage. It is in the diff, in the test list, and green on every run, and if the thing it watches broke right now it would emit exactly what it emits when everything is fine.

A computed exit code that nothing consumed. The runner carefully returned 0 for pass, 1 for hard failures and 2 for configuration errors. The trait that invoked it discarded the integer and reacted only to a thrown exception, and the runner was deliberately designed never to throw, so every real invocation exited 0 regardless of how many cases failed. The suite's own tests passed, because the test runtime takes an earlier branch that does propagate the return value. The only exercised path was the only correct one.

Metrics that report but cannot gate. Soft targets emit a note saying the first relevant result arrived at rank 17 against a target of 10, and notes do not move an exit code. After the document-builder change, five cases lost top-ten relevance, two falling from perfect to zero, and four of them still reported pass.

A monitor that reported silence while the job it watched died, because it filtered for progress markers only and a crash produces no marker. A tie-break test that stayed green after the tie-break was stripped out, because the assertion happened to hold for reasons unrelated to the behaviour it named.

They share a shape: a signal exists and no consumer can act on it. Nobody writes a branch that does nothing on failure deliberately; it arises where a value crosses a boundary and the far side was written to a different contract.

The question that finds them is one sentence long. If the thing this check watches broke right now, what would it output? If the answer is "the same as when it is fine", it is not a check. The detection method that works is more embarrassing and far more reliable than the question: break it on purpose, then confirm red. Of the four, the two caught by fault injection were caught quickly; the two caught by reading took far longer and only worked because somebody was already suspicious, which is not a technique.

The cost of the two that shipped is the argument for spending the hour. The exit-code defect would have made a full replica run report success unconditionally, so the entire measurement exercise in this article would have produced a green light with no basis. The soft-target defect is live: a merged change moved a search's first relevant result from rank 1 to rank 17 while the suite that exists to catch exactly that reported a pass. It is documented as diagnostic rather than gating, which is the minimum acceptable resolution. A number that is reported but gates nothing will be read as a guarantee unless you write down that it is not.

What we would tell anyone building this

The transferable version, stripped of our specifics.

  • Assume you are wrong about your own search until an instrument disagrees. Perceived quality has no resolution below the top three results, so a system can lose most of a result set and still feel fine to everyone who uses it. Fifteen failures in sixty cases, and not one support ticket.
  • Express ground truth as a specification, not a list of ids. A panel that dies when the corpus is replaced stops being run, and a suite nobody runs is deleted within the month. Curated cases are future maintenance events; keep them near zero.
  • Make the report state the configuration it measured, and print stored values rather than effective ones. A recall figure without its candidate cap is a rumour, and the gap between stored and effective tells you whether anybody actually decided.
  • Downgrade the assertion under a cap, never the verdict. Sample instead of asserting full membership, but keep a missing direct match a hard failure, or the instrument goes blind to exactly the defect class it was built for.
  • Sort failures by mechanism before you fix any of them. Five failures in one category were three bugs needing three fixes, and only one was about retrieval limits. Fixing a cause moves several cases at once; fixing a case moves one and breaks another.
  • Write the prediction down before the run, per case, including what should not move, and expect a bill. The negative predictions expose a misdiagnosis. Ranking is close to zero-sum inside a cap, so a change that shows only improvements usually means nothing in your case set points at what you displaced.
  • Check whether your instrument can see the thing you are asking it about, and include a control that must pass with the feature off. Zero better and three worse was a true measurement and a false conclusion, because not one case exercised the contract under test. A case set where everything only passes with the feature on shows the cases were written to the answer.
  • Run two instruments with different blind spots, and ask of every gate what it would output if the thing it watches broke. A curated panel knows the right answer and not what anyone asked; a query log knows what everyone asked and not the right answer. Then break your checks on purpose and confirm red, because reading finds these only if you were already suspicious.

Where this lives in the product

None of this is a feature. It is the instrument that decides which features ship, and its output is visible only in the behaviour of ordinary searches: the gallery, the REST API, the TypeScript SDK, and any AI agent connected over MCP. The fixes it arbitrated are the ones described in the first article: the recall guarantees under caps, the candidate selection, the completeness reporting that lets a truncated result say so. The same subject at product altitude is search and discovery, and the fact that all of it runs inside one PostgreSQL instance is what makes it reproducible on a replica at all, which is also what makes freedam practical to self-host.

The limits deserve stating as plainly as the results, because an instrument that oversells itself is the exact failure this article is about. The panel is manually run and deliberately not in continuous integration: it needs a replica of a real customer corpus, an embedding sidecar and about ninety seconds per run, and its numbers mean nothing against any other corpus. Its queries are English only, because the catalogue it was built for is English, so it measures none of the multilingual behaviour in the previous article. Its cases resolve their ground truth freshly on every run and survive a re-snapshot, but a changed corpus still changes the numbers: runs 4 through 8 measured a fresh replica of production, un-reindexed since the tokenisation fix, so they are not comparable case by case with run 3 even where the totals happen to match.

Next in this series: faces and person search, where the interesting problems stop being about retrieval quality and start being about who is allowed to assert that two faces are one person.

Keep reading