Skip to content

Build1 publisher3 min readPublished

Numbering six retrieved chunks turned one handbook into three agreeing sources

A Discord question about duplicate context sent the author of Ossian's MCP server back into the code, where content-hash dedup at ingest turned out to sit upstream of the numbered citations the model actually reads.

The Engineer · Build desk

Photograph accompanying Numbering six retrieved chunks turned one handbook into three agreeing sources
Photo: wikipedia.org

What happened

  • A developer who saw Ossian's MCP server in a community showcase asked how it handles retrieved docs that conflict with stored memories, and context that all comes from one underlying source.
  • Retrieval took the top six chunks and numbered them, so three passages from engineering-handbook.txt reached the model as citations 1, 2 and 3 beside one from platform-architecture.md.
  • The fix groups chunks by document id before the prompt is built, giving each document one number, keeping every passage under it and ranking the document by its best chunk.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Content-hash dedup runs on whole documents while retrieval hands the model chunks, so a corpus with no duplicate files at all can still put one file in front of the model as a majority of the cited sources.
  • decision Any team whose system prompt asks the model to say which sources disagree has to check whether its citation numbers identify documents or passages, because the instruction is unanswerable when they identify passages.
  • exposure A test that builds its fixture by writing the same column the code reads can only confirm the code; the blind spot is inherited, and the suite stays green.
  • precedent Retry with backoff converts a once-per-document write failure into a passing run. The backend log is then the only place the failure is visible.

Grouping is on document id, so two files that merely share a filename still get separate numbers [9]. Each document is ranked by its best chunk, and every retrieved passage from it sits under that one number [8]. On the single live question the post reports, six retrieved chunks became five citations, with one runbook contributing two passages [10]. That is one collapse, which means five of the six chunks came from distinct documents [11]. How much bigger the effect gets elsewhere depends on chunk size against document length and on how many chunks you take: retrieval returns chunks, and Ossian took the top six [3].

The instruction that needed the fix was already in the prompt. It tells the model "if the context conflicts with itself, say which sources disagree", and before the change there was no way for it to tell one source from several [6]. Ingest-time hashing could not help, because these were distinct chunks of one legitimate document [7].

Memory is ranked by similarity times importance times 0.5^(age / 30 days) [12]. Age came from created_at [14]. An agent that restates a fact, such as "the user still prefers British English", hits a deduplicating upsert that updates updated_at and nothing the ranking reads [15]. So a preference confirmed every day for three months decayed exactly as if it had been said once, three months ago [16]. Ninety days is three halvings, which puts that sentence into the ranking at 0.125 of the weight it would have had today [17].

Refreshing on read was the wrong fix. Recall records last_used_at, and a comment on it claimed that recording use "keeps a live memory from decaying away" [18]. Recall returns everything that matches, so an old preference and the newer one contradicting it come back together; refresh both on read and they tie on recency, and recency is the one signal that lets the newer one win [19]. Age now runs from the last time something was said, not the last time it was read [20].

The post opens with the reason none of this showed up in CI: "Every test passed. The tests had the same blind spots as the code." [33] A recency test existed and passed throughout, because it backdated created_at, the same column the buggy query read [21]. The author checked the replacement by putting the old line back, and it fails against the old query [22].

Then came the Kafka Connect sink. It turns a table Debezium streams into Kafka into a corpus that follows the database [23]. Its first end-to-end run failed twice with HTTP 500 and succeeded on the third attempt [24]. The backend log named a foreign key on ingest_events, with a document_id not present in documents [25]. A DELETE event removed the document, then wrote the event row pointing at the id it had just deleted [26]. Because the batch loop did not catch it, every event in that batch failed with it [27]. It happened once per document: the retry found nothing to delete, recorded a null id, and succeeded [28]. Any pipeline that retries hid it completely [34], and there were no tests for the event API at all [29].

One decision in the sink is worth copying. The event API is idempotent on a caller-supplied id, so the sink needs an id that is stable under redelivery [30]. Debezium's source position will not serve: every row of an initial snapshot shares one LSN, so two rows collapse into one id and the second is silently discarded as a duplicate [31]. The id comes from Kafka instead [32].

What to watch

  • A second measurement, on a corpus with more chunks per document, would show whether five citations from six chunks is typical or specific to that question.
  • A production snapshot replay would test whether a Kafka-derived event id really holds up under redelivery.
  • Coverage for the event API is the next place a write failure could pass as a green run.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories