Skip to content

Stage 1: HybridRetriever becomes a plain BaseRetriever - #182

Merged
adamjohnwright merged 1 commit into
mainfrom
feat/retriever-stage1
Sep 9, 2026
Merged

adamjohnwright merged 1 commit into
mainfrom
feat/retriever-stage1

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

First stage of the plan. Structural only — expansion and SelfQuery both stay; nothing about what reaches the model changes deliberately.

All five reaches into LangChain internals are gone

before after
subclasses MultiQueryRetriever BaseRetriever
overrides a required field to None via SkipJsonSchema field removed
builds a throwaway MultiQueryRetriever to steal .llm_chain the chain is built directly
assigns _retrievers outside pydantic a declared field
EnsembleRetriever(retrievers=[]) to borrow one method RRF vendored here

Expansion, unique_union and the line parser are reproduced rather than inherited. The original query is still appended after the generated ones — RRF resolves ties by first appearance, so reordering there would change ranking silently.

The exit criterion in the plan was wrong, and that is worth recording

The plan said Stage 1 must show zero difference in an end-to-end capture. That is unachievable:

same Stage 1 code, run twice:   6/8 questions identical
old (main) vs new:              6/8 questions identical

SelfQueryRetriever makes an LLM call, so the noise floor is 2 of 8. The old-vs-new difference sits entirely inside it — the comparison could not have demonstrated anything either way, in either direction. I would have "passed" this criterion by luck or failed it by luck.

Replaced with a decisive test

The vendored RRF is compared directly against EnsembleRetriever.weighted_reciprocal_rank over 2000 random inputs — varying list counts, overlaps, orderings, empty lists — plus the constant itself:

2000 random cases: 2000 identical, 0 differ

Committed as tests/retrievers/test_rrf_equivalence.py. Perturbing RRF_K from 60 to 61 makes it fail, so it is not vacuous.

Also verified

  • Existing characterization tests pass unchanged
  • Full chain through both invoke and ainvoke: identical documents in identical order for a fixed query set
  • 117 tests, gates green

Not in this PR

Stage 2 (D1 — plain semantic search replaces SelfQuery) and Stage 3 (caller-supplied budget). Deliberately separate so a behaviour change is never mixed with a structural one.

🤖 Generated with Claude Code

Structural only. All five reaches into LangChain internals are gone:

  subclassing MultiQueryRetriever                -> BaseRetriever
  overriding a required field via SkipJsonSchema -> field removed
  stealing .llm_chain from a throwaway instance  -> the chain is built directly
  assigning _retrievers outside pydantic         -> a declared field
  EnsembleRetriever(retrievers=[]) for one method-> RRF vendored here

Query expansion, unique_union and the LineListOutputParser are reproduced rather
than inherited. The original query is still appended AFTER the generated ones,
which matters: RRF resolves ties by first appearance, so reordering there would
change the ranking silently.

The plan's exit criterion -- zero difference from an end-to-end capture -- turned
out to be unachievable, and that is worth recording. SelfQueryRetriever makes an
LLM call, so the SAME code run twice already differs on 2 of 8 questions. The
old-vs-new difference was also 2 of 8, i.e. entirely within that noise floor, so
the comparison could not have shown anything either way.

Replaced with a decisive test instead: the vendored RRF is compared directly
against EnsembleRetriever.weighted_reciprocal_rank over 2000 random inputs --
varying list counts, overlaps, orderings and empty lists -- and agrees on all of
them, including the constant. Perturbing RRF_K to 61 makes that test fail, so it
is not vacuous.

Also verified through the full chain: sync and async return identical documents
in identical order for a fixed query set, and invoke() still yields 40 documents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit fd2e6d5 into main Sep 9, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the feat/retriever-stage1 branch September 9, 2026 13:31
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