The problem

The bid-answering system answers a buyer’s questions from a library of the company’s own material: past responses, policies, certifications, capability statements. A large tree of third-party material existed outside it, being manufacturer documentation for the security products the company resells, organised by vendor.

Bringing it in is obviously useful and obviously dangerous, for one reason: the same document is strong evidence on one bid and a false claim on the next. A datasheet describing a product’s encryption is the right answer when that product is what is being proposed, and a fabrication when it isn’t.

01

Relevance and admissibility are different questions

Retrieval ranks by similarity, and a vendor datasheet is genuinely similar to a security question, often more similar than the company’s own hedged prose. Similarity cannot tell you whether the company is entitled to claim what the document says.

02

The buyer never names the product

Tender questions are written to be vendor-neutral: “the solution must support…”. So any admission rule that reads the question text for a product name has, in the real corpus, almost nothing to read.

03

Adding documents can make answers worse

Not from noise, from near misses. A document that scores highly and does not contain the answer is more damaging to a generated answer than an obviously irrelevant one. This is a measured effect in the retrieval literature, not a hunch.

04

The failure mode is silence

The retrieval lane always runs an unrestricted pass, so a filter that matches nothing does not error, return empty, or log. It produces a slightly worse answer, indefinitely.

What I built

Already existed
  • A content library with upload, extraction, chunking, embedding and a vector index
  • A retrieval lane built as several independent passes whose results are merged, plus an existing exclusion of vendor material from the default pass
  • A field on the bid recording the product stack being proposed: defined, documented, and populated by nothing
  • An endpoint that re-stamps retrieval tags onto already-indexed chunks
What I added
  • Admission at bid level rather than question level: the bid’s declared product stack opens the restricted retrieval pass for every row it contains
  • The interface that populates that stack, shipped in the same change, because a filter keyed on a field nothing writes is the same bug one level up
  • A surface-form alias map resolving product and vendor spellings to the single stored tag, applied at every point that reads or writes one
  • A reconciler comparing the object store’s vendor tree against the library, which reports and never deletes
  • Paginated object listing, with the page boundary asserted in a test
  • Ingest guards: extraction failures that were being indexed as their own error text, and a file-type field that poisoned the first chunk of every plain-text document
Three retrieval passes, and why excluding a class from one of them gates nothing
Admission set for pass 1the bid’s declared stack∪ products named in the questionthe stack is one human input, every rowthe question-named part is the bonusthe correction this page is about:the buyer writes “the solution must…”,so the question names no vendor,the signal is a property of the bidPass 1: restricted to a product setthe only lane vendor docs may enter byfires only if the set is non-empty.an empty set is not an error here:it is a lane that never opensPass 2: restricted by document typefor rows demanding a specific artefactkeeps the exclusion: a datasheet isnot evidence for a flagged rowPass 3: unrestrictedalways runs · never filtereddeliberate: a single combined filteris the likeliest to return nothing.and it is why every failure aboveis silent: an answer still appearsMerge · first occurrence winsa handful of chunks per question reach the modelso pass order is precedence, and thebudget decides what survives the cuta restriction adds a lane; it never closes the open one, so “excludethe class” with no working admission path is deletion, not gating

Four problems worth describing

01

A gate that could only subtract

Symptom

No symptom. This one was caught while checking the design against the corpus before writing the filter, which is the only reason it is a paragraph here rather than a quarter of the library going quietly unreachable.

Diagnosis

The design was the obvious one: exclude vendor documentation from the unrestricted retrieval pass, and admit it through the restricted pass when the question names a product.

Two facts, checked separately, killed it.

First, the tag the restricted pass filters on was present on no document and no chunk in the library. Not sparse, absent. So the exclusion would have removed a large block of chunks from the corpus and the admission path would have matched nothing, ever. Backfilling the tags fixes the arithmetic and not the design.

Second, and worse: I read the actual questions. Buyers write requirements to be vendor-neutral, so the product names live in the answer, not the question. Across a real returnable of several hundred rows the number of rows naming a product the company resells was zero, and the closest near-hit named the buyer’s own internal tooling, which would have opened the gate to the wrong vendor’s documentation entirely.

And none of it would have surfaced. The unrestricted pass always runs, so a restricted pass that matches nothing produces no error and no empty result, just a marginally worse answer, with no signal that a lane was closed.

Decision

Move the admission signal from the question to the bid. Whether a bid proposes a given product is a fact about the bid, known before a single row is answered, and it is one human input that covers every row rather than several hundred failed inferences.

The field for it already existed on the bid, with a doc comment making exactly this argument, and nothing wrote to it. So the change had to include the control that populates it. Otherwise the filter keys off an empty field and the same silent failure recurs one level up, which is precisely how the tag ended up on nothing in the first place. Anything read as a gate needs a visible, intentional writer.

The question-text signal stayed, unioned with the bid’s stack rather than replacing it. On the rare row that does name a product it is correct, and as a bonus rather than the mechanism it is harmless.

The reusable part is not the fix, it’s the check. “Which existing records satisfy this predicate, and which real inputs trigger this branch” is two queries and a read of the input data, and it is the cheapest possible time to find out that a filter has no domain. Both answers here were zero.

02

Two vocabularies that could never match, and an admin screen that said they did

Symptom

A tag applied through the interface and visibly present on a document contributed nothing to retrieval.

Diagnosis

Three mismatches stacked on one field.

The stored tag is one canonical token per product line. The names people actually use are surface forms: a vendor’s name, a product’s marketing name, an abbreviation, a module name that shares no substring with any of them. Vendor product naming is deliberately not systematic, so there is no rule to derive one from the other.

The retrieval filter is a whole-term, case-sensitive keyword match. A tag differing only in case is not a near miss; it is a non-match.

And the database query behind the admin list is a case-insensitive regular expression. So the screen you would use to confirm your tag had worked matches loosely, finds the document, and shows you a success, while the retrieval path that matches strictly finds nothing. The verification surface disagreed with the enforcement surface, in the direction that hides the bug.

Decision

One many-to-one alias map, resolving any surface form to the canonical tag, applied at every boundary that reads or writes a tag: the ingest path, the retag path, the admission set built from the bid, and the question-text scan. A resolver used on three of four paths is a resolver that produces inconsistent data on the fourth.

Normalisation happens on the way in, not on the way out. Fixing this at query time by lowercasing the filter would have left the index holding whatever spellings had already been written, and the next path added would have to remember to lowercase too.

Why a tag that was plainly there did nothing
Three surfaces, three vocabularies
stored tag      whatever spelling the writer used
retrieval       whole-term, case-SENSITIVE keyword
                → differing case is a non-match
admin list      case-insensitive regex
                → finds it, reports success  ← the lie
surface forms   vendor ≠ product ≠ abbreviation
                (no derivable rule between them)
One vocabulary, normalised on write
alias map       many surface forms → one token
applied at      ingest · retag · bid stack ·
                question scan   (all four, or none)
normalise on    write, not read
                → index cannot hold a spelling that
                  only some query paths can match
verification    now agrees with enforcement
03

An indexing worker that stamped nothing, and a corpus that looked healthy

Symptom

The library’s classification coverage was complete. Every document classed, nothing untagged. The index looked exactly like an index whose tagging works.

Diagnosis

The worker that extracts, chunks and indexes runs as a compiled binary in its own container with no source mounted, so it does not pick up code changes until the image is rebuilt. The running image predated the commit that added tag stamping to the indexing path. Every tag in the index had arrived through the separate re-stamping endpoint, which is an update over already-indexed chunks, not through indexing. Past re-stamps are exactly what made the coverage look complete.

So a newly ingested document would be indexed with no tags at all, and that is the worst possible state for this design, because it is wrong in both directions at once. An untagged chunk passes straight through an exclusion filter, so the filter fails open, and is simultaneously invisible to the positive filter that admits it deliberately. Untagged means “always allowed and never admitted”.

Decision

Rebuild the worker image as a prerequisite of the change, not a follow-up check, and verify in the search index rather than in the database. The database row is written by a different code path from the indexed chunk, so a correct-looking record proves nothing about what retrieval will filter on. The check that matters is: ingest one document, then query the index for a chunk carrying the tag.

I wrote the deployment note as a hard ordering, image first and then anything that depends on stamping, because this class of bug is invisible from inside the repository. The code was correct and merged. It just wasn’t running.

Two ingest guards came out of the same read-through, both of the same shape: a failure that writes plausible data. Extraction returns a handful of sentinel strings alongside a nil error for files it cannot read, and the library branch checked only the error, so a scanned image-only document would be chunked, embedded, indexed and marked complete with an error message as its entire content. Separately, deriving the file-type field from the object key rather than the content type made the extractor prepend an “unsupported type” note to plain-text documents, which lands in chunk zero of every one of them, while every other extension kept working, so the ingested tree looked correct.

04

A thousand keys, and a step that deletes

Symptom

Caught in review of my own plan for the reconciler, before it ran on anything.

Diagnosis

The reconciler compares the vendor tree in object storage against what the library has indexed, so it needs to list a prefix. No listing method existed on the storage service. The nearest prior art, a method that walks a prefix for a different feature, has two defects: it issues a single request and never follows the continuation token, so it silently stops at the API’s thousand-key page limit, and it reads a bucket configured for a different purpose.

Copying that, as one does, into a reconciler whose job includes acting on keys the library does not have and keys the library has that the store does not, makes every object past the first page read as absent. In a step that deletes, “absent” means deleted. That is not a coverage gap, it is data loss, and the vendor tree is comfortably large enough to reach the boundary.

Decision

Write the paginated listing properly, and assert the page boundary in a test, because a pagination bug is invisible on any fixture smaller than a page, which is every fixture anyone writes by hand.

Then remove the capability that made it dangerous: the reconciler reports orphaned library entries and never purges them. The caller is handed the identifiers and deletes them deliberately if it wants to, following a pattern already used elsewhere in the codebase. A reconciler is a diff tool; the moment it is also a delete tool, every bug in its comparison is a destructive bug, and the comparison depends on a listing, a tag, and an alias map, three things this piece of work had already caught being wrong.

The related decision I argued against was bulk-ingesting the entire vendor tree. Two reasons, one of them arithmetic: the cost that scales badly is per-page document extraction, not embedding, and the tree is orders of magnitude larger than the library. The other is retrieval quality. A much bigger corpus of highly similar near-miss documents degrades generated answers even when it never contains the answer, so the ingest would have to be justified per vendor rather than assumed. I sized a first tranche and left the rest out.

Where it landed

0 of 114library documents carrying the tag the new filter was to key on, and 0 of 3,307 chunks
0rows in a real several-hundred-row returnable that named a product the company resells
1,000keys the nearest listing prior art returns before silently stopping, in code destined for a delete step
32%of retrieval candidates removed by the exclusion. Measured, and the reason not to change search engines yet
Verified
  • Admission is a property of the bid, set once by a human and applied to every row, with the interface that sets it shipped alongside the filter that reads it
  • One alias map resolving surface forms to canonical tags, applied on ingest, on re-stamping, on the bid-derived admission set and on the question scan
  • Tag stamping confirmed in the search index rather than in the database, after rebuilding the worker image that had never been running it
  • Paginated object listing with the page boundary asserted in a test
  • The reconciler reports orphans and never deletes; the caller acts on the identifiers explicitly
  • Extraction sentinels no longer reach the index as document content, and the file-type field is derived from the content type rather than the object key
What this doesn't solve
  • Excluding a class of document costs candidates the ranker would otherwise have used. Measured at just under a third of candidates removed, which still leaves several times the number the answer budget takes, so the correct action was to write the number down and change nothing. The trigger for revisiting it is the excluded share approaching the point where starvation is arithmetically possible, not a feeling that the filter is expensive.
  • The similarity threshold governing what counts as a match was derived on a corpus about two thirds of its current size, and the harness that derived it measures a lane without the production filter applied. Re-deriving it is worth doing and is not the same task as this one. I specified how rather than leaving it as a feeling: dump every candidate with its question, its human label and its score, plot precision and recall against that score, put the admission threshold at the knee where precision on positives reaches 0.8, and put the higher “strong” threshold where precision approaches 1.0. Two thresholds, because they answer different questions: what is allowed in, and what is trusted enough to short-circuit the rest. The whole curve has to be re-derived after any search-engine or index-library version bump, because the score is not a stable quantity across them.
  • Two other code paths reach the library with no product filter at all. That bounds what the gating may honestly claim, since it constrains the main answering lane rather than every possible read of the corpus, and it is stated rather than implied.
  • Bid-level admission is coarse by construction: a bid proposing several products admits documentation for all of them, and cannot tell which row is about which. Making it finer requires a per-row signal, and the whole point of exhibit 1 is that the rows do not carry one.
  • A re-index after a chunking change leaves the previous chunks behind, and a re-index that dies part-way leaves the document reading as complete. Both are known and both are unfixed.

What I'd do differently

Query the corpus before designing the filter. Every load-bearing fact in exhibit 1, the tag on nothing and the questions naming nothing, was two queries and one read of real input away, and it inverted the design. I did run those checks, but only because the change felt too easy; that instinct should be a step in the process rather than a mood.

Treat “which code is actually running” as part of a change, not part of a deployment. A worker that ships as a baked image is a place where correct, merged, reviewed code does nothing at all, and nothing inside the repository can tell you. The corpus even looked healthier than it was, because a manual repair path had been quietly covering for the automatic one.

Give a comparison tool no destructive power on its first version. The reconciler’s diff depends on a listing, a tag vocabulary and an alias map. This work found each of those three broken. A report-only version of the same tool would have found them too, and could not have cost anything while doing it.