Skip to content

Cover the async retrieval path, and record a de-duplication asymmetry - #227

Merged
adamjohnwright merged 2 commits into
mainfrom
test/async-retrieval-equivalence
Sep 17, 2026
Merged

adamjohnwright merged 2 commits into
mainfrom
test/async-retrieval-equivalence

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

An adversarial pass over the retriever — the code every question goes through, and the thing spec 009 proposes changing.

The gap

HybridRetriever implements retrieval twice: retrieve_documents and aretrieve_documents, the second gathering coroutines so collections are queried concurrently. Nothing exercised the async one — and that's the one the application serves through, while bin/retrieval_baseline drives the sync one.

That asymmetry is worse than ordinary duplication. retrieval_baseline is the tool the constitution names for measuring retrieval changes, and spec 009's whole verification plan rests on it. If the paths drift, every measurement is of a path no user takes.

They don't drift today — same documents, same order, across three query shapes. Now pinned.

Two things this test had to be argued out of claiming

It first reported the two paths returning different document sets. That was FakeEmbeddings, which returns a fresh random vector per call, so the same query embedded differently on the second run. DeterministicFakeEmbedding fixes it. The near-miss is in the docstring, because the false result is more instructive than the true one.

It does not cover the per-collection cap, and says so. Deterministic fake vectors carry no relation to the text, so every query retrieves much the same set and fusion lands on exactly the cap however many queries are supplied — changing the cap on one path alone stays invisible. I tried 12 and 60 documents per collection with one, three and seven queries: ten per collection every time. Rather than leave apparent coverage, the limit is written down; covering it needs real embeddings, which is retrieval_baseline's job.

A finding I measured and chose not to act on

dedupe_by_entity keys on st_id, falling back to page_content. But BM25 documents come from CSVLoader, whose metadata is {source, row} — no st_id at all. Verified against the real bundle: 0 of the first 2,000 reaction documents carry one.

So the two halves of the pipeline de-duplicate by different identities. Vector hits collapse to one row per stable ID; BM25 hits don't, and reactions has 17,004 rows for 16,107 distinct reactions, so the same reaction appears under several pathways.

Measured on real questions rather than argued about:

question distinct reactions in BM25's top 10
What does CDK5 phosphorylate in Alzheimer disease? 10/10
How does TP53 regulate PTEN transcription? 9/10
cholesterol transport 10/10
glycolysis regulation 10/10
interferon signalling 9/10

Worst case one slot in ten. Real, minor, and not worth a blind change to retrieval — "this should be better" is not a finding. Recorded here so spec 009 knows it's there, since routing work touches exactly this code.

329 tests, ruff and mypy clean.

🤖 Generated with Claude Code

adamjohnwright and others added 2 commits September 17, 2026 13:41
HybridRetriever implements retrieval twice -- retrieve_documents and
aretrieve_documents, the second gathering coroutines so collections are queried
concurrently. Nothing exercised the async one, and that is the one the
application serves through. bin/retrieval_baseline, the tool the constitution
names for measuring retrieval changes, drives the sync one. If they drift,
every measurement is of a path no user takes.

They do not drift today: same documents, same order, checked across three query
shapes.

Two things this test had to be argued out of claiming. It first reported the
paths returning different document sets -- that was FakeEmbeddings returning a
fresh random vector per call, so the same query embedded differently on the
second run. DeterministicFakeEmbedding fixes it, and the near-miss is recorded
in the docstring because the false result is more instructive than the true one.

And it does not cover the per-collection cap. Deterministic fake vectors carry
no relation to the text, so every query retrieves much the same set and fusion
lands on exactly the cap however many queries are given -- changing the cap on
one path alone stays invisible. Tried at 12 and 60 documents per collection with
one, three and seven queries; ten per collection every time. That limit is
written into the docstring rather than left as apparent coverage, and covering
it needs real embeddings, which is retrieval_baseline's job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The new equivalence test failed on GitHub and passed here. BM25 tokenises with
word_tokenize(..., language="english"), which needs punkt_tab; the Dockerfile
installs it at build time and the CI test job never did. So the suite passed on
any machine where a developer had downloaded it once and failed on a clean one
-- the classic shape, and it was my test that exposed it rather than caused it.
Any future test that drives retrieval would have hit the same wall.

CI now fetches the same resource the Dockerfile does, so the async path is
genuinely covered there rather than skipped.

The test also skips with a message naming the download command when the
resource is absent, so a developer without it gets that instead of a LookupError
raised from inside nltk. Checked by patching nltk.data.find to raise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit 3d87075 into main Sep 17, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the test/async-retrieval-equivalence branch September 17, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant