Index¶
Two search backends, plus the store both are rebuilt from. Neither backend
persists its own on-disk form: BM25Index.from_store() and a freshly
populated in-memory vector index are both derived, at open time, from
SQLiteMetadataStore — the
durable truth for documents and chunks (ADR-0002). That is a deliberate
anti-pattern guard, not an optimization deferred: no in-process index state
may shadow persisted state (SPEC.md §2), so postings and vectors can never
drift out of sync with what SQLite actually holds.
Lexical (BM25)¶
Ported near-verbatim from ARP (ADR-0001) — pure Python, zero dependencies.
BM25Index.search returns (Chunk, score) pairs rather than constructing a
RetrievalResult itself; joining a chunk back to its document's source path
is the retrieval layer's job; see Retrieval.
groundkit.index.bm25 ¶
Persisted BM25 lexical index (Phase 1). Library choice justified in an ADR.
Ported from ARP's agentic_v2.rag.retrieval.BM25Index (ADR-0001: pure-Python
BM25, promoted near-verbatim — zero deps, already pinned by ARP's behavioral
tests). Two deliberate changes for groundkit's layering:
- :meth:
BM25Index.searchreturns(Chunk, score)pairs instead of constructingRetrievalResultdirectly.RetrievalResult.sourcehas no counterpart onChunk(onlyDocumentcarries the source path/URL), so assembling a citation-bearing result means joining against the metadata store — that join is the retrieval layer's job, not this module's. - :meth:
BM25Index.from_storerebuilds a fresh in-memory index from :class:~groundkit.index.metadata.SQLiteMetadataStore(ADR-0002): SQLite is the durable truth for chunks; BM25 postings are never pickled to disk.
BM25Index ¶
Pure-Python in-memory BM25 keyword index.
Builds an inverted index from chunks — a term -> [chunk index]
postings map (_postings) beside the document-frequency counter
(_doc_freqs) and the length statistics — tokenizes on lowercased word
characters, and scores candidates with Okapi BM25.
ADR-0002 decision 2 named all three of those from its first version, and
until GK-018 only two of them existed — so :meth:search scored every
indexed chunk however selective the query was, making query cost a
function of corpus size. The postings map closes that, recorded as an
erratum on the decision itself rather than silently. What it buys is
score-identical, not an approximation: a chunk holding no query term
skips every term inside :meth:_score_document, scores exactly 0.0,
and was already dropped by that method's caller, so narrowing the walk to
the union of the query terms' postings removes work and never a result —
the low-scoring-but-nonzero tail included, which is what an approximate
candidate scheme would have cost and why none was taken.
Not safe to mutate while a search is running.
:class:~groundkit.retrieval.search.Retriever dispatches :meth:search to
a worker thread, where it reads the parallel per-chunk lists and the
postings map below live; :meth:index_chunks extends them in separate
statements, so an append landing mid-scan would be observed torn — a
posting, or len(self._chunks), past the matching _doc_lengths or
_doc_term_freqs entry, raising IndexError from
:meth:_score_document. Build the index fully (normally via
:meth:from_store) and treat it as frozen thereafter; rebuilding means
constructing a new instance, which is what
:class:~groundkit.runtime.CollectionRuntime does.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
k1
|
float
|
Term-frequency saturation parameter. |
1.5
|
b
|
float
|
Length-normalization parameter. |
0.75
|
Source code in src/groundkit/index/bm25.py
from_store
async
classmethod
¶
Rebuild a fresh in-memory BM25 index from persisted chunks.
ADR-0002: BM25 postings are never pickled — SQLite is durable truth, and the in-memory index is reconstructed from it at process start. Cost is O(corpus size); acceptable for v1 and reconsidered if rebuild time is ever measured to be a problem.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
store
|
MetadataStoreProtocol
|
The metadata store to read every persisted chunk from. |
required |
k1
|
float
|
Term-frequency saturation parameter for the new index. |
1.5
|
b
|
float
|
Length-normalization parameter for the new index. |
0.75
|
Returns:
| Type | Description |
|---|---|
BM25Index
|
A new :class: |
BM25Index
|
persisted in |
Source code in src/groundkit/index/bm25.py
index_chunks ¶
Incrementally add chunks to the BM25 index.
Existing indexed chunks are preserved — this is an accumulation,
not a replacement. Rebuilding from scratch means constructing a new
:class:BM25Index (see :meth:from_store).
A chunk's index joins the postings list of every term it holds, on
that term's first occurrence in the chunk rather than on every
occurrence — the same branch that increments the document-frequency
counter, so the two cannot disagree about which chunks hold a term.
Appending in chunk order then leaves each postings list strictly
ascending and duplicate-free, which is the property :meth:search
relies on to visit candidates in the order a whole-corpus scan
would have.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chunks
|
list[Chunk]
|
Chunks to add. |
required |
Source code in src/groundkit/index/bm25.py
search ¶
Search the index with a BM25 query.
Only chunks holding at least one query term are scored (GK-018), so the cost is proportional to the number of matching chunks rather than to the corpus. The returned list is exactly what scoring every chunk produced, ties included — see the class docstring for why that is an identity rather than an approximation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query
|
str
|
The search query string. |
required |
top_k
|
int
|
Maximum number of results to return. |
5
|
Returns:
| Type | Description |
|---|---|
list[tuple[Chunk, float]]
|
|
list[tuple[Chunk, float]]
|
score |
list[tuple[Chunk, float]]
|
|
list[tuple[Chunk, float]]
|
broken by ascending |
list[tuple[Chunk, float]]
|
across re-ingestion of the same corpus (see the sort below). |
list[tuple[Chunk, float]]
|
The one case this can't disambiguate: two byte-identical chunks |
list[tuple[Chunk, float]]
|
share a |
list[tuple[Chunk, float]]
|
content-derived key can distinguish them. |
Source code in src/groundkit/index/bm25.py
Dense (vector)¶
InMemoryVectorStore is the offline/dev double, importable with no optional
extra installed. LanceDBVectorStore is the persisted backend behind the
dense extra, with a lazily-imported lancedb/pyarrow so a BM25-only
install never pays for either. Both implementations close two ported ARP
defects (ADR-0001): hazard 3, a metadata filter accepted through **kwargs
and silently never applied, and hazard 5, a document ID reaching an
unescaped LanceDB delete predicate. Filtering happens before the top-k
truncation on both stores, not after — filter-then-truncate, so a filtered
query can't lose real matches to unfiltered ones that happened to score
higher.
groundkit.index.dense ¶
Dense vector index behind :class:~groundkit.index.protocols.VectorStoreProtocol
(Phase 3, ADR-0001, ADR-0004).
Two implementations land together, never separately (SPEC.md §5.3: "Metadata filtering is implemented on both in-memory and LanceDB paths from the first dense-store commit, with regression tests on both paths"):
- :class:
InMemoryVectorStore— pure Python, nolancedbimport. The dev/test double for offline work and CI where the optionaldenseextra may be absent. - :class:
LanceDBVectorStore— persisted, backed by LanceDB. Thelancedb/pyarrowimport is lazy (see :func:_import_lancedb): a BM25-only install can stillimport groundkit.index.densefreely, and only constructing a :class:LanceDBVectorStorewithout thedenseextra installed raises a clear :class:~groundkit.errors.StorageErrorinstead of a bareImportErrorsurfacing from deep inside this module.
Both implementations close two ported ARP defects (ADR-0001):
- Hazard 3 — silent filter drop. ARP's
searchaccepted a filter argument via**kwargsand never read it; any call spelling, keyword or positional, silently returned unfiltered results.VectorStoreProtocolalready closes the spelling half of this (no**kwargs, so a misspelled keyword is aTypeErrorat the call site) — the implementation half is this module's job: :func:_matches_filteris the single place ametadata_filteris ever applied, on both stores, with identical semantics (subset match: every(key, value)pair in the filter must be present and equal in the chunk's metadata). - Hazard 5 — unescaped delete predicate. LanceDB deletes through a SQL
expression string built by the caller. ADR-0004 decision 6: a document ID
is validated against a strict identifier pattern (:func:
_validate_document_id, mirroringindex/metadata.py'svalidate_collection_namein spirit) before it can reach :func:_build_delete_predicate— the single function that builds that string. Escaping is defence in depth, applied after validation, never the primary defence. Verified live against this repo's pinned LanceDB 0.37.1:t.delete("document_id = 'nope\' OR \'1\'=\'1'")wipes every row in the table; the regression test intests/test_dense.pyasserts that input cannot reach a predicate at all.
Filter-then-truncate. Filtering a vector search result after asking
the backend for top_k nearest neighbors returns fewer than top_k
whenever some of those neighbors don't match the filter — a silently short
result set, which SPEC.md §5.3 and ADR-0001 hazard 3 both call a defect, not
a quirk. Both stores over-fetch instead: when metadata_filter is given,
every stored vector's similarity is scored (:class:InMemoryVectorStore
already does this — it is a linear scan by construction) and the filter is
applied before truncating to top_k, so a top_k slice is only ever
short when fewer than top_k chunks actually match. This is the same
"correctness over cleverness, revisit if it's ever measured to matter"
tradeoff index/bm25.py already accepts for its O(corpus) rebuild
(ADR-0002) — filtered dense search costs O(corpus) instead of O(log corpus)
per query; unfiltered search stays a cheap top-k vector query.
Score mapping. LanceDB reports a distance, not a similarity, and
RetrievalResult.score is Field(ge=0.0) (contracts.py). Both stores
compute cosine similarity and then floor it at zero via :func:_clamp_score
— similarity is queried from LanceDB as 1 - cosine_distance (verified
live: an identical vector pair reports _distance == 0.0, orthogonal
reports 1.0, opposite reports 2.0, matching distance = 1 -
similarity), or computed directly in :func:_cosine_similarity for the
in-memory path. A negative similarity (near-opposite vectors) floors to
0.0 rather than crashing the contract or being silently coerced —
ranking order among genuinely relevant, positive-similarity results is
unaffected; only the below-zero tail collapses to a shared floor.
Determinism. Ties are broken by ascending Chunk.content_hash, the
exact convention index/bm25.py already pins (its module docstring: tied
scores fall back to insertion order only for two byte-identical chunks,
which share a hash and can't be told apart by content alone). Both stores
reconstruct full :class:~groundkit.contracts.Chunk objects internally
(from stored rows, for LanceDB) specifically so this tie-break key and
Pydantic's own offset/length re-validation come "for free" at read time.
No citation building here. search returns (Chunk, score) pairs,
exactly as BM25Index.search does, and leaves the document-source join to
retrieval/search.py. This is a correctness requirement, not symmetry for
its own sake.
ingestion/chunking.py does seed every chunk's metadata with its
document's source (metadata={"source": document.source, ...}), so a
store could read source from chunk.metadata. It must not. That
value is a snapshot taken at chunk time, while documents.source in
SQLite is the durable truth (ADR-0002) that Retriever.search already
joins against and fails closed on. Re-ingest a document from a new path and
the two disagree — a dense hit would cite the stale path while a BM25 hit
for the same document cites the current one, in a single response. Routing
both seams through the same join makes that divergence unrepresentable.
It also keeps fusion simple: Wave C's RRF consumes two lists of the same shape, and the source join happens once, after fusion, on the surviving results.
InMemoryVectorStore ¶
Pure-Python in-memory vector store — the dev/test double (ADR-0001 promote decision).
No lancedb import anywhere in this class; safe to use in a BM25-only
install. Every search is an exhaustive linear scan over all stored
vectors — by construction, not an optimization to revisit, since that
is what makes its filter-then-truncate behavior trivially correct (see
the module docstring). Appropriate for the corpus sizes this repo
targets pre-Phase-6 scale work, matching index/bm25.py's identical
O(corpus) tradeoff.
Satisfies :class:~groundkit.index.protocols.VectorStoreProtocol.
Source code in src/groundkit/index/dense.py
add
async
¶
Store chunks with their embeddings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chunks
|
list[Chunk]
|
Chunks to store. |
required |
embeddings
|
list[list[float]]
|
One embedding per chunk, same order. |
required |
Raises:
| Type | Description |
|---|---|
StorageError
|
|
Source code in src/groundkit/index/dense.py
delete
async
¶
Delete all vectors for document_id.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
document_id
|
str
|
The document whose chunks should be removed. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The number of vectors actually removed (counted, not assumed). |
Raises:
| Type | Description |
|---|---|
StorageError
|
|
Source code in src/groundkit/index/dense.py
search
async
¶
search(
query_embedding: list[float],
top_k: int = 5,
metadata_filter: dict[str, Any] | None = None,
) -> list[tuple[Chunk, float]]
Return the most similar chunks, most similar first.
The exhaustive scan runs in a worker thread, not on the caller's
event loop. This method was async def with no await in its
body: an await on it therefore never yielded, and a pure-Python
cosine over every stored vector ran to completion before any other
coroutine got the loop back. That is the same defect
:meth:~groundkit.retrieval.search.Retriever._bm25_search documents,
on the other always-in-process store.
The (chunk, vector) pairing is materialized here, on the
calling thread, and only the scoring is dispatched. :meth:add
extends the two backing lists in two separate statements, so a
worker thread that zipped them itself could observe the first
extended and the second not — turning a benign interleaving into a
strict=True length mismatch. Pairing before dispatch keeps the
snapshot exactly as consistent as the fully-inline version was,
and it is cheap next to what moves: tuple construction per chunk
versus a full-width dot product per chunk.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query_embedding
|
list[float]
|
The query vector. |
required |
top_k
|
int
|
Maximum number of results. |
5
|
metadata_filter
|
dict[str, Any] | None
|
Keep only chunks whose metadata contains every
given key/value pair; |
None
|
Returns:
| Type | Description |
|---|---|
list[tuple[Chunk, float]]
|
Up to |
list[tuple[Chunk, float]]
|
ranked by descending score, ties broken by ascending |
list[tuple[Chunk, float]]
|
|
list[tuple[Chunk, float]]
|
returns |
Raises:
| Type | Description |
|---|---|
StorageError
|
|
Source code in src/groundkit/index/dense.py
LanceDBVectorStore ¶
Persisted vector store backed by LanceDB (ADR-0001 adapt decision).
Construct via :meth:open, not directly — mirrors
index/metadata.py's SQLiteMetadataStore.open() convention. All
LanceDB calls are synchronous; every one is run via asyncio.to_thread
and serialized through one asyncio.Lock held across the awaited
call, for the same reason SQLiteMetadataStore does this: concurrent
coroutines can genuinely run these calls on different threads
simultaneously, and the underlying table object is not documented as
safe for that.
Satisfies :class:~groundkit.index.protocols.VectorStoreProtocol.
Source code in src/groundkit/index/dense.py
add
async
¶
Store chunks with their embeddings.
Creates the backing table on the first call, with a schema fixed to
this call's embedding width — LanceDB tables are fixed-width, so
every later add() must agree with it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chunks
|
list[Chunk]
|
Chunks to store. |
required |
embeddings
|
list[list[float]]
|
One embedding per chunk, same order. |
required |
Raises:
| Type | Description |
|---|---|
StorageError
|
|
Source code in src/groundkit/index/dense.py
delete
async
¶
Delete all vectors for document_id.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
document_id
|
str
|
The document whose chunks should be removed. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The number of vectors actually removed, computed by counting |
int
|
rows before and after the delete (counted, not assumed) — never |
int
|
read off LanceDB's own delete-result object, which is not |
int
|
guaranteed present across this repo's supported |
int
|
version range ( |
Raises:
| Type | Description |
|---|---|
StorageError
|
|
Source code in src/groundkit/index/dense.py
explain_search
async
¶
explain_search(
query_embedding: list[float],
top_k: int = 5,
metadata_filter: dict[str, Any] | None = None,
) -> str
Return LanceDB's physical plan for exactly the query :meth:search would run.
Built through :meth:_resolve_fetch_limit and :meth:_build_query,
the same two calls :meth:search makes, so the plan cannot describe
a different query than the app executes.
Raises:
| Type | Description |
|---|---|
StorageError
|
The table does not exist yet, |
Source code in src/groundkit/index/dense.py
open
async
classmethod
¶
Open (creating if absent) the LanceDB-backed store at db_path.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
db_path
|
str | Path
|
Directory LanceDB should use for this collection's table(s). Created on first write if it does not exist. |
required |
table_name
|
str
|
Table name within that directory. |
_DEFAULT_TABLE_NAME
|
Returns:
| Type | Description |
|---|---|
LanceDBVectorStore
|
A ready-to-use store. If a table named |
LanceDBVectorStore
|
exists at |
LanceDBVectorStore
|
and this store's dimensions are inferred from its schema; |
LanceDBVectorStore
|
otherwise the table is created lazily on the first :meth: |
Raises:
| Type | Description |
|---|---|
StorageError
|
The optional |
Source code in src/groundkit/index/dense.py
search
async
¶
search(
query_embedding: list[float],
top_k: int = 5,
metadata_filter: dict[str, Any] | None = None,
) -> list[tuple[Chunk, float]]
Return the most similar chunks, most similar first.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query_embedding
|
list[float]
|
The query vector. |
required |
top_k
|
int
|
Maximum number of results. |
5
|
metadata_filter
|
dict[str, Any] | None
|
Keep only chunks whose metadata contains every
given key/value pair; |
None
|
Returns:
| Type | Description |
|---|---|
list[tuple[Chunk, float]]
|
Up to |
list[tuple[Chunk, float]]
|
ranked by descending score, ties broken by ascending |
list[tuple[Chunk, float]]
|
|
list[tuple[Chunk, float]]
|
returns |
Raises:
| Type | Description |
|---|---|
StorageError
|
|
Source code in src/groundkit/index/dense.py
vector_index_stats
async
¶
(distance_type, unindexed_rows) for the vector column's index, or None.
distance_type is lower-cased ("cosine", "l2", ...).
unindexed_rows counts rows added after the index was built: they
are scored by brute force and unioned with the ANN results — correct,
but a growing flat-scanned tail. Incremental ingest produces exactly
this.
Source code in src/groundkit/index/dense.py
VectorIndexFallbackError ¶
Bases: StorageError
An unfiltered dense search would not be served by the vector index.
Raised by :func:assert_search_uses_vector_index. A flat scan returns
correct results, so nothing else notices: latency grows with the corpus
and no test or log line fails. This error is the loud version.
assert_search_uses_vector_index
async
¶
assert_search_uses_vector_index(
store: LanceDBVectorStore,
query_embedding: list[float],
top_k: int = 5,
*,
max_unindexed_rows: int = 0,
) -> str
Fail loudly unless :meth:LanceDBVectorStore.search would use the ANN index.
Checks the plan of the query the app actually runs (via
:meth:LanceDBVectorStore.explain_search), not a hand-written one. Three
conditions must hold:
- The vector index, if any, was built for the query's cosine metric. Checked from the index's own stats, not the plan, because a mismatch fails differently across the supported LanceDB range: 0.37 logs a warning and brute-force scans, but 0.18 uses an L2 index for a cosine query and ranks results by the wrong metric — verified live, and the plan line there does not name the metric at all.
- The plan contains an ANN index operator. It will not when no index exists, or on a metric mismatch under newer LanceDB.
- At most
max_unindexed_rowsrows sit outside the index. Rows added after the index was built are brute-force scored and unioned in, so a stale index degrades gradually rather than all at once.
Only the unfiltered path is checked. A filtered search is an exhaustive
scan by design (see the module docstring's "Filter-then-truncate"
section and :meth:LanceDBVectorStore._build_query).
The plan and the index stats are read under separate lock acquisitions,
so a concurrent add() can land between them and the two can describe
slightly different table states. That is harmless for a diagnostic
meant to run against a quiescent collection (CI, a deploy gate).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
store
|
LanceDBVectorStore
|
An opened LanceDB store with a populated table. |
required |
query_embedding
|
list[float]
|
A representative query vector of the store's width. |
required |
top_k
|
int
|
The |
5
|
max_unindexed_rows
|
int
|
Unindexed-row allowance before the check fails. |
0
|
Returns:
| Type | Description |
|---|---|
str
|
The verified plan, for logging. |
Raises:
| Type | Description |
|---|---|
VectorIndexFallbackError
|
Either condition above fails. The message carries the full plan. |
StorageError
|
The store has no table, the query width is wrong,
|
Source code in src/groundkit/index/dense.py
979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 | |
verify_dense_side_present
async
¶
verify_dense_side_present(
store: MetadataStoreProtocol,
vector_store: VectorStoreProtocol,
dimensions: int,
*,
manifest: CollectionManifest | None,
) -> None
Fail closed when a dense-bound collection has lost its vectors.
The dense analogue of the incremental skip key's blind spot. SQLite is
the durable truth for documents and chunks (ADR-0002) and its
content_hash decides what gets re-indexed — but that hash records
only whether the content changed, never whether the vectors derived
from it still exist. A collection whose SQLite file persisted while its
vectors did not therefore reports every document as unchanged, re-embeds
nothing, and returns nothing from the dense side, permanently and with
no error: exactly the silent-absence class SPEC.md §2 fails closed
against.
Two ways to reach that state. The library one is pairing an
:class:InMemoryVectorStore with a file-backed
:class:~groundkit.index.metadata.SQLiteMetadataStore, where the dense
side empties on every restart by construction (the grk CLI cannot:
it wires :class:LanceDBVectorStore unconditionally). The operational
one, and the likelier one, is the collection's .lance directory
being deleted, moved, or restored from a backup that omitted it.
ADR-0004's manifest is what makes the check unambiguous: it is written on the collection's first dense write, so a bound manifest proves vectors once existed. Manifest bound + documents in SQLite + an empty vector store is a lost dense side, and is distinguishable from the legitimate cases — an unbound manifest means the collection was only ever BM25-only (whose upgrade path is the documented no-backfill limitation, not a defect), and no documents means an empty collection.
Emptiness is probed with search(top_k=1) rather than a count: a
dense search never drops zero-scored results, so an empty result means
an empty store, and this needs no addition to
:class:~groundkit.index.protocols.VectorStoreProtocol.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
store
|
MetadataStoreProtocol
|
The collection's metadata store. |
required |
vector_store
|
VectorStoreProtocol
|
The dense store paired with it. |
required |
dimensions
|
int
|
Embedding width, used to shape the probe vector. |
required |
manifest
|
CollectionManifest | None
|
The collection's manifest as the caller last read it, or
|
required |
Raises:
| Type | Description |
|---|---|
StorageError
|
The collection is manifest-bound and holds documents, but its vector store is empty. |
Source code in src/groundkit/index/dense.py
880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 | |
Metadata store¶
The durable truth for documents and chunks (ADR-0002): one SQLite connection
per collection, foreign_keys=ON, journal_mode=WAL, every call serialized
through a single asyncio.Lock held across the awaited call — the lock, not
the flag, is what makes concurrent awaits safe against sqlite3's own
thread-safety limits. Also owns the ADR-0004 collection manifest, which binds
a collection to the embedding (provider, model_name, dimensions) triple it
was built with; see Configuration for the identity model itself.
groundkit.index.metadata ¶
Document/chunk metadata store backed by SQLite, behind an interface (Phase 1).
Implements :class:~groundkit.index.protocols.MetadataStoreProtocol. SQLite
is the durable truth for documents and chunks (ADR-0002); the BM25 index is
rebuilt from this store at process start rather than persisted itself.
Concurrency model (ADR-0002): a single sqlite3.Connection opened with
check_same_thread=False, with every call serialized through one
asyncio.Lock. sqlite3 connections are not safe to use concurrently from
multiple threads, and asyncio.to_thread hands each call to a fresh worker
thread — the lock, held for the duration of the coroutine (including the
awaited to_thread call), guarantees only one thread ever touches the
connection at a time. This was chosen over per-call connections to avoid
repeated file-handle churn and "database is locked" contention under WAL for
groundkit's expected single-process local deployment.
Also implements the ADR-0004 collection manifest: a single-row
collection_manifest table binding a collection to the embedding
(provider, model_name, dimensions) triple it was built with, plus the
PRAGMA application_id/user_version stamp that lets :meth:open
recognize a store created before that manifest existed and refuse it for
dense work rather than trust an identity it never recorded.
SQLiteMetadataStore ¶
Durable store for documents, chunks, and ingest state, backed by SQLite.
Construct via :meth:open, not directly — the constructor takes an
already-configured connection and its precomputed schema-currency verdict.
Attributes:
| Name | Type | Description |
|---|---|---|
db_path |
Path to the collection's SQLite file, kept for diagnostics. |
Source code in src/groundkit/index/metadata.py
add_chunks
async
¶
Persist chunks for a document.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chunks
|
list[Chunk]
|
Chunks to persist. A no-op if empty. |
required |
source
|
str
|
The source identifier the document was upserted under —
used to resolve the expected |
required |
Raises:
| Type | Description |
|---|---|
StorageError
|
|
Source code in src/groundkit/index/metadata.py
close
async
¶
Close the underlying connection.
Routed through :meth:_run rather than a bare
async with self._lock: await asyncio.to_thread(self._conn.close),
for the same cancellation reason every other method on this class
goes through it: cancelling that plain async with block does not
stop the worker thread mid-close() -- it only unwinds the
awaiting coroutine, and __aexit__ would release the lock right
then, while the worker was still inside sqlite3.Connection.close()
on a second thread. A coroutine that then acquired the lock could
touch the same check_same_thread=False connection concurrently --
exactly the undefined behaviour the lock exists to rule out (see this
module's header docstring: "the lock, not the flag, is what makes
concurrent awaits safe"). _run's add_done_callback-gated
release ties the lock to the worker's actual completion instead, so
that race cannot open here either.
_run's rollback-on-exception wrapper is safe to run for a close:
the only way it fires is if self._conn.close() itself raises, and
if that leaves the connection already unusable, the follow-up
rollback() attempt is caught and logged rather than allowed to
replace the original exception (see _run's _guarded) -- the
same guard that already covers "operation racing store teardown"
for every other method.
Source code in src/groundkit/index/metadata.py
count_chunks
async
¶
Return how many chunks are persisted, without reading any of them.
Deliberately not a :class:~groundkit.index.protocols.MetadataStoreProtocol
member. That protocol is held to exact signature parity by
tests/test_protocol_conformance.py and implemented by hand-built
doubles across the suite; widening it for a reporting
convenience is the trade ADR-0012 decision 3 already refused for
model_name. It is instead declared on the optional
:class:~groundkit.index.protocols.DocumentRecordStoreProtocol
(GK-019), which only a store that can genuinely answer cheaply
implements — so the capability is now stated in the type system
rather than reached for by holding the concrete class.
What it replaces: len(await store.get_chunks()), which
materialized every chunk's full text — the entire corpus, in memory,
as :class:~groundkit.contracts.Chunk models, each re-validated on
construction — to produce one integer. That is the read behind
index_status, the cheapest-looking call on an unauthenticated
service surface, so its cost was both the largest on that surface
and the least apparent from its name.
Returns:
| Type | Description |
|---|---|
int
|
The number of rows in |
Raises:
| Type | Description |
|---|---|
StorageError
|
On a backend failure. |
Source code in src/groundkit/index/metadata.py
count_documents
async
¶
Return how many documents are persisted, without reading their sources.
The sibling of :meth:count_chunks, and off
:class:~groundkit.index.protocols.MetadataStoreProtocol for the same
reason. It replaces len(await store.get_document_sources()), which
built a full {document_id: source} dict so that index_status
could take its length — the same materialize-to-measure shape as the
chunk half, one line above it in service/tools.py and missed when
that half was fixed.
Declared on the optional
:class:~groundkit.index.protocols.DocumentRecordStoreProtocol since
GK-019, alongside the keyed document read: both describe the same
property of a store — that it can answer a bounded question without
materializing a table — and neither belongs on the protocol every
store must satisfy.
Returns:
| Type | Description |
|---|---|
int
|
The number of rows in |
Raises:
| Type | Description |
|---|---|
StorageError
|
On a backend failure. |
Source code in src/groundkit/index/metadata.py
delete_document
async
¶
Delete a document and its chunks.
Chunks are deleted explicitly rather than relying solely on the
ON DELETE CASCADE foreign key, so the deleted-chunk count is
known without a second query.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
document_id
|
str
|
The document's unique identifier. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The number of chunks deleted (0 if the document did not exist). |
Raises:
| Type | Description |
|---|---|
StorageError
|
On a backend failure. |
Source code in src/groundkit/index/metadata.py
get_chunk
async
¶
Return one chunk by ID, or None.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chunk_id
|
str
|
The chunk's unique identifier. |
required |
Raises:
| Type | Description |
|---|---|
StorageError
|
On a backend failure. |
Source code in src/groundkit/index/metadata.py
get_chunks
async
¶
Return all persisted chunks in the collection.
Returns:
| Type | Description |
|---|---|
list[Chunk]
|
Chunks ordered by document then position, for deterministic |
list[Chunk]
|
downstream indexing (e.g. BM25 rebuild). |
Raises:
| Type | Description |
|---|---|
StorageError
|
On a backend failure. |
Source code in src/groundkit/index/metadata.py
get_document_hash
async
¶
Return the stored content hash for source, or None if unseen.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
File path, URL, or other source identifier. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The stored content hash, or |
str | None
|
been ingested. |
Raises:
| Type | Description |
|---|---|
StorageError
|
On a backend failure. |
Source code in src/groundkit/index/metadata.py
get_document_id
async
¶
Return the stored document ID for source, or None if unseen.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
File path, URL, or other source identifier. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The stored document ID, or |
str | None
|
been ingested. |
Raises:
| Type | Description |
|---|---|
StorageError
|
On a backend failure. |
Source code in src/groundkit/index/metadata.py
get_document_record
async
¶
Return one document's :class:DocumentRecord, or None if there is no such row.
The keyed form of :meth:get_document_records, added by GK-019.
document_id is the documents primary key, so this is an index
seek: what it replaces is a full table scan that built a validated
model per stored row so that its two callers —
:meth:~groundkit.retrieval.search.Retriever.search, on every
query, and service.tools.handle_fetch_chunk, for a single ID —
could look up at most top_k keys in the result. Cost scaled with
the corpus while the question scaled with top_k.
Returning None for an unknown ID rather than raising is what lets
those callers keep their fail-closed branch: a chunk whose document
row is gone must be refused, and refusing is their decision to make
with their own error vocabulary, not this store's.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
document_id
|
str
|
The document's unique identifier. |
required |
Raises:
| Type | Description |
|---|---|
StorageError
|
This store predates the |
Source code in src/groundkit/index/metadata.py
get_document_records
async
¶
Return a document_id -> DocumentRecord map for every stored document.
A sibling of :meth:get_document_sources, not a widening of it
(ADR-0016). Every one of that method's five existing call sites reads
only the source string or the key set, so changing its return type
would touch five call sites for a field none of them need; adding
this method instead confined the change to the two call sites that
actually need the richer read.
Whole-table by construction. Since GK-019 the two callers that wanted
a bounded set of keys use :meth:get_document_record instead, and
this remains for callers that genuinely want every row: a diagnostic
or export, and Retriever's fallback branch for a store that does
not implement :class:~groundkit.index.protocols.DocumentRecordStoreProtocol
at all.
Raises:
| Type | Description |
|---|---|
StorageError
|
This store predates the |
Source code in src/groundkit/index/metadata.py
get_document_sources
async
¶
Return a document_id -> source map for every stored document.
Deliberately narrower than :meth:get_document_records: this query
never selects source_class/extractor, so it keeps working
unchanged against a v1, v2, or v3 store — none of this store's five
pre-ADR-0016 callers (Indexer's two prune paths, the dense-side
presence check, and two evals/runner.py reads) need the richer
record, and widening this method's return type would force every one
of them to change for a field they never use. See
:meth:get_document_records's docstring for the fuller read.
Raises:
| Type | Description |
|---|---|
StorageError
|
On a backend failure. |
Source code in src/groundkit/index/metadata.py
get_generation
async
¶
Return the collection's staleness marker, or None if unanswerable (ADR-0013).
The marker advances on every commit that changes durable state, and the only operation defined on it is equality against a previously observed value. Nothing compares generations for ordering or distance; a caller that has one may ask "is this still the state I built against", and nothing else.
None means freshness cannot be asserted, never unchanged. It
is returned for a store that is not schema-current — one predating
ADR-0013, or predating ADR-0004's stamp entirely — and for a v2 store
whose row is somehow absent. A caller must treat None as "assume
it changed" and rebuild: answering a freshness question that cannot
be answered is how a cache ends up serving an index it has no basis
to vouch for. This deliberately degrades to reopen-per-request rather
than refusing, because nothing is wrong on a legacy store — only
uncacheable — and SPEC.md §2's fail-closed rule governs wrong
answers, not slow ones.
Returns:
| Type | Description |
|---|---|
int | None
|
The current generation, or |
int | None
|
unanswerable. |
Raises:
| Type | Description |
|---|---|
StorageError
|
On a backend failure. |
Source code in src/groundkit/index/metadata.py
get_manifest
async
¶
Return the collection's embedding-identity manifest, or None if unset.
Unlike :meth:write_manifest and :meth:verify_manifest, this is a
plain read and does not require the store to be manifest-capable: a
legacy pre-ADR-0004 store never has a manifest row, so it reports
None here rather than raising — useful for diagnostics (e.g. a
future index_status tool) without needing to catch
:class:~groundkit.errors.IndexIdentityError just to check.
Raises:
| Type | Description |
|---|---|
StorageError
|
On a backend failure. |
Source code in src/groundkit/index/metadata.py
open
async
classmethod
¶
Open (creating if absent) the SQLite store for a collection.
Also determines whether this store predates the ADR-0004 collection
manifest, by comparing its PRAGMA application_id/user_version
against :data:APPLICATION_ID/:data:SCHEMA_VERSION. A freshly
created file is stamped with both, inside the same transaction that
applies the schema, as the last statements before commit. This
never blocks opening the store — BM25-only collections must keep
working unchanged — it only gates :meth:write_manifest and
:meth:verify_manifest.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
index_dir
|
Path
|
Directory holding the collection's persisted state. Created (with parents) if it does not exist. |
required |
collection
|
str
|
Collection name; backs the file |
required |
Returns:
| Type | Description |
|---|---|
SQLiteMetadataStore
|
A ready-to-use store with the schema applied. |
Raises:
| Type | Description |
|---|---|
ConfigurationError
|
|
StorageError
|
The directory cannot be created, the database file cannot be opened, or the schema cannot be applied (e.g. a corrupted file or a path occupied by something other than a regular SQLite file). |
Source code in src/groundkit/index/metadata.py
277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 | |
replace_document
async
¶
replace_document(
source: str,
document_id: str,
content_hash: str,
chunks: list[Chunk],
*,
source_class: SourceClass = "text",
extractor: str | None = None,
) -> None
Atomically replace a document's row and its chunks in one transaction.
Equivalent to calling :meth:upsert_document followed by
:meth:add_chunks, but as a single commit. That matters because the
two-call sequence has a durable-partial-write hazard: a crash (or any
exception) between the two calls leaves the document row committed
with its new content hash but zero chunks, and the incremental
re-index skip (get_document_hash matching) then treats that
document as already up to date forever. This method closes that gap
by doing the delete-old-row, insert-document, and insert-chunks steps
inside one :meth:_run call, so any failure — including a chunk with
a mismatched document_id or non-JSON-serializable metadata —
rolls back the whole thing and leaves the prior state (if any)
untouched. Callers that need document and chunks written together
(the indexer) should use this instead of the two-call sequence.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
File path, URL, or other source identifier. Unique. If
|
required |
document_id
|
str
|
Identifier to store the document under. |
required |
content_hash
|
str
|
Hash of the document's current content. |
required |
chunks
|
list[Chunk]
|
Chunks to persist for this document. May be empty. |
required |
source_class
|
SourceClass
|
How |
'text'
|
extractor
|
str | None
|
Extractor identity for an |
None
|
Raises:
| Type | Description |
|---|---|
StorageError
|
This store predates the |
Source code in src/groundkit/index/metadata.py
upsert_document
async
¶
upsert_document(
source: str,
document_id: str,
content_hash: str,
*,
source_class: SourceClass = "text",
extractor: str | None = None,
) -> None
Record (or replace) a document's ingest state.
If source already has a document row (possibly under a different
document_id — e.g. re-ingestion that regenerates IDs), that row
and all of its chunks are explicitly deleted before the new row is
inserted, so no stale chunks from a prior version of the document
survive.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
File path, URL, or other source identifier. Unique. |
required |
document_id
|
str
|
Identifier to store the document under. |
required |
content_hash
|
str
|
Hash of the document's current content. |
required |
source_class
|
SourceClass
|
How |
'text'
|
extractor
|
str | None
|
Extractor identity for an |
None
|
Raises:
| Type | Description |
|---|---|
StorageError
|
This store predates the |
Source code in src/groundkit/index/metadata.py
verify_manifest
async
¶
Verify identity matches the collection's stored identity manifest.
A collection with no manifest yet — no dense write has ever
happened — has nothing to conflict with, so verification passes
trivially; :meth:write_manifest is what establishes the manifest,
not this method. Never a re-embed, never a fallback, never a
warn-and-continue (SPEC.md §2): a real mismatch always raises.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
identity
|
EmbeddingIdentity
|
The active embedding identity to check. |
required |
Returns:
| Type | Description |
|---|---|
CollectionManifest | None
|
The manifest this call verified |
CollectionManifest | None
|
|
CollectionManifest | None
|
manifest come out of the same read, inside one :meth: |
CollectionManifest | None
|
which is what lets a caller decide "is this collection |
CollectionManifest | None
|
dense-bound" without a second read that could observe a |
CollectionManifest | None
|
different state — see |
CollectionManifest | None
|
this closes. |
Raises:
| Type | Description |
|---|---|
IndexIdentityError
|
This store predates the embedding-identity
manifest (ADR-0004) and cannot be used for dense work, or
the stored manifest's identity triple does not match
|
StorageError
|
On a backend failure. |
Source code in src/groundkit/index/metadata.py
write_manifest
async
¶
Write the collection's embedding-identity manifest, once (ADR-0004).
Called on the collection's first dense write. The manifest is
immutable for the collection's lifetime thereafter: a later call
with the same (provider, model_name, dimensions) triple as the
stored manifest is a no-op — re-ingesting into an already-bound
collection must keep working — but a call with a different
triple is refused outright. Never a silent overwrite, never a
re-embed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
identity
|
EmbeddingIdentity
|
The embedding identity establishing (or, on a later call, being checked against) this collection's identity. |
required |
Raises:
| Type | Description |
|---|---|
IndexIdentityError
|
This store predates the embedding-identity manifest (ADR-0004) and cannot be used for dense work, or a manifest already exists with a different identity triple. |
StorageError
|
On a backend failure. |
Source code in src/groundkit/index/metadata.py
is_groundkit_store ¶
True when db_path is a groundkit collection, checked without writing.
Exists because :meth:SQLiteMetadataStore.open applies _SCHEMA
unconditionally: pointed at an unrelated SQLite file it would create
documents, chunks, collection_manifest and collection_state inside
it. That made two read-only operations mutate a stranger's database — a
*.sqlite3 a user happened to leave in the index directory would be
advertised by list_collections and then written to by index_status.
The check opens the file with mode=ro&immutable=1, and both flags are
load-bearing. mode=ro alone is not write-free against a WAL database:
SQLite needs the -shm shared-memory file to read one, and creates
-shm/-wal beside the store if they are absent — which is a write,
and exactly the write this function exists to avoid.
tests/test_service_tools.py::test_list_collections_opens_nothing
catches it. immutable=1 promises the file cannot change, so SQLite
skips the WAL machinery entirely and touches nothing.
The cost of immutable=1 is that any uncheckpointed WAL content is
invisible. That does not matter here: application_id lives in the
database header, and the sqlite_master read below is only reached on
the unstamped path, where "no tables visible" and "tables visible" both
answer True.
Recognized as groundkit:
PRAGMA application_idequal to :data:APPLICATION_ID— the normal case, whateveruser_versionsays. Keying on the stamp alone is deliberate: a pre-v3 store is still ours and must stay readable (ADR-0016 refuses its writes, not its reads), so foldinguser_versioninto this test would lock out the very stores that guard was written to keep working.application_idof0with both :data:_LEGACY_MARKER_TABLESpresent — a groundkit store predating the stamp.application_idof0with no tables at all — an empty file, which :meth:openmay legitimately adopt.
Anything else — including an unreadable or corrupt file — is not ours.
Returning False rather than raising keeps the caller's choice open:
listing skips it, opening refuses it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
db_path
|
Path
|
The candidate file. Must already exist; a missing path is
not this function's question and returns |
required |
Returns:
| Type | Description |
|---|---|
bool
|
Whether the file may be treated as a groundkit collection. |
Source code in src/groundkit/index/metadata.py
validate_collection_name ¶
Validate that collection is safe to interpolate into a file path.
This is the first of two containment layers for :meth:SQLiteMetadataStore.open
(the second is the resolved-path check via
:func:~groundkit.utils.path_safety.ensure_within_base): it states the
contract on the name itself, up front, so a bad value fails with a
specific message rather than relying solely on the later path check.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
collection
|
str
|
Candidate collection name. |
required |
Raises:
| Type | Description |
|---|---|
ConfigurationError
|
|