Skip to content

Python context layer: lazy BM25 retrieval and prewarm - #241

Open
jat255 wants to merge 4 commits into
jat255/context-layer-constructionfrom
jat255/context-layer-retrieval
Open

Python context layer: lazy BM25 retrieval and prewarm#241
jat255 wants to merge 4 commits into
jat255/context-layer-constructionfrom
jat255/context-layer-retrieval

Conversation

@jat255

@jat255 jat255 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Second of two PRs for M4, the Python context layer (kata g1bf and hmh8). Stacked on #240, which adds the class this builds on. Python only.

ContextLayer.search(query, top_k=3) retrieves over a raghilda DuckDBStore with embed=None and a BM25 index. The index is built on first search rather than at construction, because store setup is the most expensive part of building an agent and many conversations never search. prewarm() builds it ahead of time for callers that know a search is coming.

Two constraints of the retrieval library drove the implementation, and both are covered by tests that fail without the corresponding code.

retrieve_bm25 pads its result up to top_k with unscored rows instead of dropping them, so a query matching nothing still returns a full result set. search() keeps only rows with a non-null bm25 metric. Without that filter the agent receives arbitrary chunks presented as relevant. ragnar_retrieve_bm25 returns zero rows in the same situation, so this is a difference between the engines rather than between the packages.

ingest() upserts on a document origin that it also requires to be non-empty, so each document gets a distinct synthetic origin. Sharing one collapses two files with identical text into a single chunk.

search() and prewarm() are synchronous. The milestone scope floated putting the build behind asyncio.to_thread; the thread hop belongs at the tool boundary in M5, which knows whether it is on an event loop, rather than inside the layer where it would force every caller async for a call that is a no-op on a warm store.

Nothing here is added to tests/shared/. Retrieval ranking is engine-specific and is not a cross-language contract.

@jat255 jat255 changed the title jat255/context layer retrieval Python context layer: lazy BM25 retrieval and prewarm Sep 2, 2026
@jat255
jat255 marked this pull request as draft September 2, 2026 00:04
@jat255 jat255 added the py Affects the Python implementation label Sep 2, 2026
@jat255 jat255 added this to the py-M4: context layer milestone Sep 4, 2026
@jat255
jat255 force-pushed the jat255/context-layer-retrieval branch from 411c5d5 to 77713c6 Compare September 4, 2026 17:27
@jat255
jat255 marked this pull request as ready for review September 4, 2026 17:52
@jat255
jat255 force-pushed the jat255/context-layer-retrieval branch from 77713c6 to 47a78c1 Compare September 4, 2026 18:03
@jat255
jat255 force-pushed the jat255/context-layer-retrieval branch from 47a78c1 to 7be4e17 Compare September 6, 2026 01:39
The index is built on first search rather than at construction: it is the
most expensive part of building an agent, and many conversations never search.

Two constraints of the retrieval library shape this.

retrieve_bm25 pads its result up to top_k with unscored rows rather than
dropping them, so a query matching nothing still returns a full result set.
Without the metric filter, search() would hand the agent arbitrary context and
present it as relevant.

ingest() upserts on a document origin it also requires to be non-empty, so
each document gets a distinct synthetic origin. Sharing one would make two
files with identical text collapse into a single chunk.
Builds the index ahead of the first search, for callers that know a search is
coming and would rather not pay for it mid-conversation.

Kept synchronous. The milestone scope floated putting the build behind
asyncio.to_thread, but the thread hop belongs at the tool boundary, which
knows whether it is on an event loop. Inside the layer it would force every
caller async for a call that is a no-op on a warm store.
- Build the store under a lock so concurrent first searches on a shared
  layer cannot each build (and all but one discard) a store.
- search() validates top_k >= 1 instead of leaking a duckdb
  BinderException, and its docstring states the return shape, ordering,
  empty-result behavior, and the top_k bound; prewarm() says when it is
  worth calling.
- Correct the ingest() comment: a shared origin fails the ingest with a
  duplicate-origin error; it does not collapse chunks.
- Note that search() is public ahead of the R counterpart's internal
  context_search(n = ...), and leave a TODO for the
  commons_context_store_build span once pkg-py has tracing.
- Tests: top_k validation, prewarm/search consistency, and a threaded
  test that a cold layer builds exactly one store (fails without the
  lock).
@jat255
jat255 force-pushed the jat255/context-layer-retrieval branch from 7be4e17 to 9c22826 Compare September 6, 2026 23:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

py Affects the Python implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant