decision records

Decision records

One page per decision that would be expensive to reverse: context, decision, consequences, alternatives.

ADR 001Python with a stdlib-only core

Status: accepted · 2026-09-13

Context

The core will be imported into other people's applications and run next to whatever retrieval stack they already have. The evaluation harness, ragbisect, and its chunker, tokenizer and LLM wrapper are Python. MCP hosts commonly launch servers with npx, which favours TypeScript for distribution.

Decision

Python 3.10+. The core package has zero runtime dependencies. The MCP SDK and pypdf are optional extras (contextpull[mcp], contextpull[pdf]). Distribution through uvx contextpull, which Claude Code and other hosts launch as readily as npx.

Consequences

  • Reuse of ragbisect code and one language across tool and benchmark.
  • Every dependency conflict in a host application is avoided for library users.
  • HTTP is done with urllib; JSON with json; storage with sqlite3. Slightly more code, no supply chain.
  • An npm wrapper can be added later without touching the core.

Alternatives

  • TypeScript first. Better npx story, but splits the codebase from the benchmark and loses the existing corpus code.
  • Allow a small framework dependency. Faster to build, but the tool is supposed to install alongside anything; a pinned pydantic or httpx is exactly the conflict we want to avoid for library users.

ADR 002SQLite with FTS5 as store and lexical index

Status: accepted · 2026-09-13

Context

We need durable storage for documents and sections, a lexical index good enough that search finds identifiers and headings reliably, and a deployment story that is one file. Python's sqlite3 ships FTS5 with built-in bm25 ranking on the platforms we care about; verified on macOS with SQLite 3.53.

Decision

One SQLite file in WAL mode holds documents, sections, an FTS5 virtual table with an identifier-preserving tokenizer, cached summaries, the index text, and optional embeddings as blobs. Read-only at query time.

Consequences

  • Zero dependencies, one file to copy, back up or build in CI.
  • Search scales to hundreds of thousands of sections without an in-memory index.
  • Heading-path weighting is one parameter of bm25().
  • FTS5 availability must be checked at startup with a clear error on exotic Python builds.
  • Vector search past a few tens of thousands of sections needs sqlite-vec or a brute-force pass; acceptable because embeddings are optional.

Alternatives

  • Pure-Python BM25 in memory (what ragbisect uses). Fine for a benchmark, not for a server over a large corpus; slow startup, no persistence.
  • A vector database. Dependency, process, and the wrong primary index: identifiers need lexical matching.
  • Postgres. Right for the shared deployment at scale, wrong as the default; the default must be a file.

ADR 003Layered surfaces: library, MCP server, embedding recipe

Status: accepted · 2026-09-13

Context

Prospective users split three ways: teams that want their own client and will embed the capability; teams that use an open host such as Claude Code, Claude Desktop or Cursor and want a server to attach; and teams that call model APIs directly and want the tools in their own loop. We develop with Claude Code.

Decision

The core library owns the store, the operations and the tool schemas. The MCP server is a thin wrapper over the operations. The embedding recipe is documentation plus one runnable example using the same schemas. All three surfaces share one definition of each tool.

Consequences

  • A custom client is a pip install and five function calls, no server process.
  • Any MCP host works without host-specific code beyond index delivery (ADR 004).
  • The evaluation adapters use the same tool definitions as production, so the benchmark measures what ships.
  • The core cannot import the MCP SDK; it is an extra.

Alternatives

  • MCP server only. Simplest, but excludes the custom-client segment and forces a process boundary onto embedders.
  • Library only. Excludes open hosts, where most early adoption will come from.

ADR 004Index delivered through server instructions, with tool and resource fallback

Status: accepted · 2026-09-13

Context

The pattern depends on the model knowing what exists before it decides what to read. In Claude Code that role is played by files loaded into the system prompt. MCP servers can return instructions at initialize, which Claude Code and Claude Desktop inject into the system prompt. Not every host does, and a large corpus cannot fit any budget.

Decision

The index is delivered three ways from one cached text: in server instructions when it fits the token budget (default 3,000); as an index tool always; as resource contextpull://index always. Past the budget, instructions carry a hierarchical directory listing and tell the model to call index(prefix) to expand. Custom clients call ops.index().

Consequences

  • In Claude Code the index is genuinely always in context with no user-visible file.
  • Hosts that ignore instructions still work; the tool descriptions tell the model to call index first if it sees no index.
  • Budget is a first-class setting and the index header states its mode, so behaviour is never mysterious.
  • Hierarchical mode adds a round trip for large corpora; measured in the scale tiers.

Alternatives

  • Write a CONTEXTPULL.md into the project and reference it from CLAUDE.md. Works only in Claude Code, leaves generated files in the user's repo, drifts from the store. Offered as an optional convenience snippet, not the mechanism.
  • Tool only. Costs a round trip on every conversation and depends on the model remembering to call it.

ADR 005Search returns pointers and snippets, never bodies

Status: accepted · 2026-09-13

Context

The central claim is that the model chooses what it reads. If search returned full sections, the tool would be push RAG with an extra step: top-k bodies land in context whether or not the model wanted them, tokens scale with k, and the trace no longer shows what the model actually consulted.

Decision

search returns id, document, heading path, a short snippet and a score. read returns text. grep returns matching lines with ids. neighbours returns sections because its purpose is context around a section already chosen.

Consequences

  • One extra round trip for the common case of one hit. Accepted; read(id, context=n) recovers most of it.
  • Token cost is proportional to sections read, not to k.
  • The tool trace is a faithful record of what informed the answer; citations are exact.
  • The model must be told the discipline; every tool description carries it.

Measured consequence (2026-09-14)

On the uv docs a small model saw the gold section in its own search results on 88% of questions and read it on 62%; the 20-token snippet was often enough to answer from. The decision stands, since the alternative (returning bodies) would make the gap invisible rather than smaller, but snippet length is now an open design item.

Alternatives

  • Return bodies for the top 1–3 hits. Fewer round trips, but it reintroduces unrequested content and blurs the measurement of what the model chose.

ADR 006Section ids are path plus ordinal, with a content hash

Status: accepted · 2026-09-13

Context

Ids appear in citations, in tool arguments the model types, in ragbisect gold labels, and in logs. They must be readable, stable for an unchanged document, and safe to resolve.

Decision

id = "<relative path>#<ordinal>", ordinal zero-based in document order. Each section also stores a sha256 of its text. Ids resolve only through the database.

Consequences

  • Readable in an answer: [policy-2025.md#3] tells a human where to look.
  • Stable across re-ingest for unchanged documents; changed documents renumber and the hash exposes drift.
  • No path traversal surface: the id is a key, not a path to open.
  • Renaming a file changes every id in it. Acceptable; a rename is a corpus change.

Alternatives

  • Content-hash ids. Stable under reordering, but unreadable and they change on any edit, so citations rot faster, not slower.
  • UUIDs. Unreadable and unstable across re-ingest.
  • Byte offsets. Meaningless after any edit and unreadable.

ADR 007Lexical retrieval first, embeddings optional

Status: accepted · 2026-09-13

Context

In an agentic loop the model rewrites and narrows queries itself, which recovers much of what embeddings add to one-shot retrieval. The shapes that push RAG fails on hardest, exact lookup and table cells, are lexical problems. On the uv documentation ragbisect measured bm25 at recall@5 0.918 against dense at 0.903, and hybrid at 0.952. Embeddings require a provider, credentials and cost at ingest.

Decision

FTS5 bm25 with an identifier-preserving tokenizer is the default and only required search mode. Embeddings are an opt-in ingest flag that enables search(mode="hybrid") through reciprocal rank fusion. Missing embeddings fall back to lexical with a note in the result.

Consequences

  • Ingest works offline and costs nothing by default.
  • Conceptual questions phrased far from the document's vocabulary may need a second search; the loop is designed for that.
  • Hybrid remains available where the corpus shows a gap, and ragbisect tells us where that is.

Alternatives

  • Dense first. Best one-shot conceptual recall, worst identifier recall, mandatory provider dependency.
  • Dense only. Rejected for the same reasons, more strongly.

ADR 008Measure with ragbisect and publish the losses

Status: accepted · 2026-09-13

Context

Agentic retrieval is easy to demo and hard to prove. Prior art shows demos. Buyers and contributors will ask how it compares to a tuned hybrid pipeline, on what question shapes, at what cost. We already own an instrument that answers exactly that.

Decision

Every performance claim about ContextPull is a ragbisect row next to bm25, dense and hybrid, on the same sections (via export-chunks), the same eval set and the same k, with tokens, tool calls and wall time as columns. Results and caches are committed under benchmarks/. Write-ups name where pull loses.

Consequences

  • Development has a number to move from week three.
  • Two adapters to maintain, one through the API and one through Claude Code headless.
  • The positioning is honesty; a flattering-only benchmark would undercut it.
  • ragbisect's own limits, single gold chunk in particular, are inherited and stated.

Alternatives

  • A custom benchmark. Duplicates work and invites the suspicion that it was designed to be won.
  • Demos and anecdotes. What everyone else does.

ADR 009The store file is the cross-language contract

Status: accepted · 2026-09-13

Context

Custom clients will be written in TypeScript, Go, Java and others. Options were: force every language through the Python server as a sidecar; expose a C library over FFI; or make the SQLite store itself the interface and reimplement the small read side per language. The read operations are a few hundred lines; ingest is where the complexity lives.

Decision

The versioned SQLite store format plus precisely specified operation semantics, tools.json, and an executable conformance suite form the contract. Readers may be implemented natively in any language with an FTS5-capable SQLite binding. Ingest stays in the Python reference implementation. The Python server remains available as a sidecar for languages without a native reader.

Consequences

  • npx contextpull serve and a Go single binary become possible without Python at query time.
  • Search semantics must be pinned to the level of tie-breaking, which also makes the Python implementation more rigorous.
  • Every tool change is a coordinated release across SDKs, by design.
  • Regex grep and hybrid search are outside conformance because they depend on the host engine or an external model; the docs say so.
  • Contributors can add a language by passing a suite instead of reading Python.

Alternatives

  • Python sidecar only. Zero duplication, but a second runtime in every deployment and no npx story.
  • Shared C core over FFI. Maximum consistency, maximum build pain; SQLite already is the shared native layer.
  • gRPC or HTTP service as the only interface. Fine for hosted use, wrong for embedded clients and offline tools.