Skip to content

Collection selection, and the config route the data model got wrong - #255

Merged
adamjohnwright merged 1 commit into
mainfrom
009-collection-routing
Sep 18, 2026
Merged

adamjohnwright merged 1 commit into
mainfrom
009-collection-routing

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

The machinery for spec 009 collection routing. Behaviour is unchanged — nothing sets a selection yet, so every question still searches every collection. Wiring the classifier is the next task.

resolve_collections, with the asymmetry that matters

selection result
empty or absent all collections
every name known just those, in bundle order
any name unknown all collections, and a WARNING naming them

Every failure widens; none narrows. An unrecognised name means the classifier's prompt and the installed bundle disagree about what exists — narrowing on that disagreement drops a collection the question needed, while widening only costs latency. That is Principle IV, and it is why an empty list is the safe default for an omitted field, an older prompt and a parse failure alike.

The data model describes a route that does not exist

It sends the selection through RunnableConfig["configurable"]["collections"] into HybridRetriever.retrieve_documents. Measured against langchain-core 0.2.14, the pinned version:

  • config is not passed to _get_relevant_documents as a kwarg — a retriever declared with **kwargs receives an empty dict
  • var_child_runnable_config is unset inside a retriever run, so the ambient config cannot be read either

Two alternatives rejected: a per-request attribute on the retriever races, because it is built once at startup and shared by every request; rebuilding a filtered retriever per request throws away the constructed BM25 indexes.

So the selection travels in a ContextVar, which asyncio copies per task. That isolation is the entire justification, so it is pinned rather than assumed — a test runs a narrow request and a wide one concurrently and asserts neither sees the other's selection.

Recorded in research.md, with the data model's diagram left as the intent.

Characterization first

A test records today's behaviour — every collection searched, always — so that narrowing becomes a visible change rather than a silent one. If it starts failing without a selection being set, something narrowed retrieval by accident, which is the failure that costs answers rather than time.

12 tests. ruff, mypy (134 files), full suite with no API keys set.

🤖 Generated with Claude Code

resolve_collections with the spec's asymmetry: empty or absent searches
everything, a valid selection narrows, and any unknown name widens back to
everything with a WARNING naming it. Narrowing on a selection we do not
understand costs the answer; widening costs latency, so every failure widens.

**The data model's route does not exist.** It sends the selection through
`RunnableConfig["configurable"]["collections"]` into the retriever. Measured
against langchain-core 0.2.14: config is not passed to
`_get_relevant_documents` as a kwarg, and `var_child_runnable_config` is unset
inside a retriever run. A per-request attribute would race, because the retriever
is built once and shared. Rebuilding per request discards the BM25 indexes.

So the selection travels in a ContextVar, which asyncio copies per task. A test
runs a narrow request and a wide one concurrently and asserts neither sees the
other's selection -- that isolation is the whole reason for the choice, so it is
pinned rather than assumed.

A characterization test records today's behaviour, every collection searched, so
that narrowing becomes a visible change rather than a silent one.

Not yet wired: QueryIntent has no `collections` field and nothing sets the
ContextVar, so behaviour is unchanged. That is the next task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adamjohnwright

Copy link
Copy Markdown
Contributor Author

Adversarial review

Three things checked, all on the real code rather than the fakes the unit tests use.

Does the ContextVar actually reach the retriever? This is the risk that would make the feature silently not work. LangChain may run a sync retriever in an executor, and loop.run_in_executor does not copy contextvars. Tested all three paths:

path contextvar seen
sync-only retriever via ainvoke (executor) ✓
async retriever via ainvoke ✓
sync retriever via invoke ✓

Are the two edits correct? They were made programmatically on two functions and only exercised by a fake class. Read both: indentation correct, retrievers bound from the dict, loop over the chosen list.

Does the real HybridRetriever narrow? The decisive one:

selection time docs collections searched
none 8.2s 50 all five
["reactions"] 2.5s 10 reactions only
["not_a_collection"] 6.7s 50 all five — widened correctly

The caveat that goes with those numbers

Narrowing drops the context from 50 documents to 10. The time saving is real and large, but fewer documents in context can cost answer quality, and that is precisely what this spec's before/after comparison exists to measure. I am not claiming routing is a win on the strength of a latency number.

The timings are one sample each. The collection sets and document counts are deterministic and are the part worth trusting; the seconds are directional only.

So the next task is not more plumbing — it is re-capturing against before.json and comparing recall, before anything sets a selection in production.

@adamjohnwright
adamjohnwright merged commit 5dbde5e into main Sep 18, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the 009-collection-routing branch September 18, 2026 19:22
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