docir — design documents Graph

Documents / Architecture / arch-03eeae8bf37d

Doc-Index CLI — the read path

How a query becomes results: full-text and vector rankings fused, section-level embeddings, default status visibility, and reads spanning peer stores.

arch-03eeae8bf37darchitectureactive#architecture
View as Markdown◉ View in graph

Read path

Agent → docir context "<task>" → hybrid scoring (FTS5 + semantic) + related-graph traversal → returns a small relevant subset instead of the whole docs/ folder.

Two-tier retrieval (skeleton → body). context, query, and search return skeletons — frontmatter, tags, typed related, and the owner/verified/stale fields, but not the body. The agent scans those to judge relevance and then fetches only the bodies it needs — all of them in one docir get <id> <id> ..., since process start rather than retrieval is what a read costs (issue-9509f9fa3631). Keeping bodies out of result sets is where the context savings come from; the description field (indexed and shown in listings) is what makes the skeleton enough to judge relevance.

Semantic layer (fastembed)

Pure FTS5 misses semantically close but lexically different matches (e.g. a query about "refresh token handling" against a document titled "session renewal strategy"). fastembed (ONNX-based, quantized, CPU-only, no external API call — consistent with the project's privacy-first stance elsewhere) closes this gap without pulling in heavy ML dependencies.

Storage

a document vector over title + description + body, plus one vector per ## section — see "Semantic index: every section is embedded" below for why the document vector alone was not enough. Both are BLOBs in SQLite; each row records the model that produced it, so switching embedder recomputes rather than comparing vectors of different widths. The agent-authored description gives the document vector a concise, high-signal summary to anchor on, improving retrieval over embedding raw body text alone. Brute-force cosine similarity is sufficient at this scale (hundreds to low thousands of documents) — no ANN index needed.

docir context scoring

Reciprocal rank fusion over the FTS5 ranking and the vector ranking, rather than replacing full-text outright — lexical matches are still valuable and cheap, and the two disagree often enough that either alone is measurably worse (benchmarks/).

score is therefore rank-derived and says only where a document placed; similarity carries the raw cosine through and is the one number with absolute meaning. --explain returns both plus the terms behind them.

With several queries — context --also — pooling still decides each document's numbers and taking turns decides the order, so no one query fills the head of the result (adr-4c21693aac55).

Where it runs

entirely inside the daemon (arch-1cfb1b212237, "Daemon process"), so the model is loaded once and reused; per-call added latency is on the order of single-digit to tens of milliseconds, not the cold-start cost of loading the model fresh.

Recompute triggers

the embedding is tied to what changed, not to every docir update call — recomputing on metadata-only changes (e.g. a status transition) would be wasted work with no benefit:

  • docir add → embedding scheduled (new document).
  • docir update changing title, description, or the body (--append-section, --replace-section, --replace-body) → embedding scheduled.
  • docir update touching only other frontmatter fields (--status, --tags, related) → embedding left untouched.
  • docir archive / deletion → the vector is removed from the index along with its FTS and relation rows, so an archived/deleted document can't resurface via similarity search or Tier 2 DRY checks.

Async recompute (not on the write's critical path)

an agent rarely writes a document in a single call — it does docir add then several --append-section edits over a long session. Recomputing the vector synchronously on each write would re-embed the same document repeatedly and put model inference on the write path, serializing the agent's rapid successive calls behind it. Instead, two levels of consistency are separated:

  • Synchronous, immediately consistent: file write, metadata, FTS5, relations. Cheap, and search correctness depends on it — ready by the time the command returns.
  • Deferred, eventually consistent (embeddings only): a content change sets an embedding_dirty flag on the row (persisted in SQLite, so it survives a daemon crash/restart) and returns immediately. A background worker inside the daemon drains dirty rows with a short debounce window (a few seconds), coalescing a burst of edits to one document into a single recompute.

Consistency window

between the write and the vector being ready, the document is still found via FTS (lexically) — the semantic contribution simply joins a few seconds later. A brand-new document is FTS-only until its vector lands; an updated one temporarily keeps its previous vector. On daemon restart, the worker re-embeds anything still flagged dirty, so nothing is silently lost.

Escape hatch

--wait-embeddings on a write, or docir embed --flush, forces a synchronous recompute when a semantically-heavy query must run immediately afterward (e.g. in tests).

Model version changes

if the embedding model is upgraded, existing vectors become stale — but nothing has to name them. Each row records the model that produced it, so a foreign model_id reads as dirty and docir embed --flush recomputes exactly those. A full docir reindex does it too, since it re-embeds every document it re-saves and reports the count as embeddings_recomputed; there is no separate flag for it (adr-6a4718fa7a7d).

Also powers Tier 2 DRY linting

(docir lint --deep): the same vectors are reused to flag content-similarity across documents, so there's no duplicate infrastructure for search vs. lint.

Semantic index: every section is embedded

The embedding model (bge-small-en-v1.5) reads about 512 tokens — roughly 1,900 characters of prose — and silently ignores the rest: appending text past that boundary returns a bit-identical vector. 84 of docir's own 103 documents at the time of measurement exceeded it, so more than half the corpus was absent from the semantic index while FTS5 hid the problem by covering the whole body.

So the embedding pass writes a document vector and one vector per ## section (chunk_embeddings, keyed (doc_id, ordinal)). The scorer accepts repeated ids from the semantic ranking and keeps each document's best chunk — RRF fuses rankings of documents, so the collapse has to happen before fusion, not after. The winning candidate survives the collapse, not just its score, which is what lets a ranked hit name the heading that matched (matched_section) and an agent read only that span with docir get <id> --section "<heading>".

Load-bearing details:

  • MAX_CHUNK_CHARS (1200) is derived from the measured window, not chosen — and each chunk carries the document title as a prefix, which eats into the budget. A chunk allowed to overflow reintroduces the original bug one level down.
  • The splitter tracks fenced code blocks, because a ## comment inside one is not a heading, and cutting there yields two invalid chunks.
  • There is no second dirty flag: chunks are rewritten wholesale under the existing embeddings.dirty queue, in the same transaction.
  • Tier 2 similarity linting deliberately still compares document vectors only. Chunk vectors would answer "do these two share a section", not "are these the same document".
  • indexing may not import documents, so the entity is the seam: Document.embedding_chunks() hands the scheduler positional (ordinal, heading, text) triples.

Because max-pooling structurally favours documents with more sections, the recall gate on the benchmark corpus is not optional — keep it when touching ranking.

Status filtering (default visibility)

Closed documents are not deleted or hidden physically — they stay in docs/, in git history, and remain fully queryable. Visibility is controlled purely at the read path:

  • docir context, docir query and docir search filter to active statuses by default. "Closed" is per type, not one hardcoded status: each type declares its own inactive_statusessuperseded/rejected for a decision, resolved for an issue, deprecated for architecture.
  • --include-inactive widens any of them, and docir get <id> always returns the document whatever its status — an agent checking whether a similar bug was already fixed needs exactly that. (--include-resolved is the old spelling, still accepted; it named a status only two types have.)
  • docir archive goes further, removing a document from active search entirely (see arch-0368cc754c15, "Archiving vs. deletion") — for volume rather than for status.

There is exactly one visibility predicate, and expansion runs both ways. DocumentService._is_visible (archived + inactive status) is called by the ranked fusion loop and by the graph-expansion pass; do not inline the check into either. They used to differ — expansion tested only archived — so a resolved issue the caller had excluded came back through a neighbour edge, and the filter that held on three read paths leaked on the fourth.

Expansion follows outgoing edges and incoming successor edges, successors first in each seed's list. A supersedes edge points from the new document to the old one, so before this the replacement sat one hop away backwards and the graph could not answer "is this decision still current?" — the question it exists for. Which kinds count is schema data (successor: true), not a hardcoded pair, so a custom kind with that shape is followed too.

Down-weighting closed documents in scoring instead of excluding them was considered and not built: a status filter is a statement about whether a document is current, and a score is a statement about relevance. Blending them makes neither answerable.

Reading across stores

In a multi-repo organisation the decision governing the service you are editing often lives in the platform repo, and an agent reading context in the service repo cannot see it — so it re-decides. .docir/stores.yaml, committed beside docs-schema.yaml, declares peer store homes; --store PATH (repeatable) adds one for a single invocation on top of that list, without editing the file. Relative paths resolve against this store's home, so a file one person commits works for everyone who clones the same layout, and the list is read per request rather than cached at daemon startup.

Reads federate; writes never do. Exactly four commands fan out — get, query, search, context — and that set is asserted against the dispatcher's own command list in the suite, so a new command joins by decision rather than by omission. Every write and every maintenance command runs against the local store alone.

Three properties are load-bearing:

Peers are opened read-only at the database

(sqlite:///file:<path>?mode=ro&uri=true), so "docir does not write to a peer" is enforced by SQLite rather than promised by a comment. This is also why peers get their own construction path: the normal container build runs migrations and creates directories, both of which write.

An unreadable peer is skipped with a warning on stderr, never an error.

A peer's index is derived and gitignored, so a fresh clone of it simply has none; failing the read would make one repository's state everyone's outage.

The merge sorts on similarity, never score.

RRF ranks within one store, so comparing two stores' scores compares the sizes of their corpora. Rows carry a store field only while federating — it is pure cost with one store, which is why the read paths never carried it before.

To amend: Re-verify: