Knowledge Store owns the retrieval layer — selecting candidates from storage; the RAG that sits on top (model rerank, context packing, LLM call) is Query Engine's job. Search stands on four primitives over the three projections of a single database: text is searched by two complementary paradigms at once — a dense vector and sparse lexical — while the graph and the relational body are searched by their own nature. KS exposes the primitives two ways: individually (as tools) and fused into one result (RRF / weighted). Permissions apply once — in storage, as a pre-filter, before ranking. Where KS ends and Query / Agent Engine begins is the boundary.
Not one way to search, but four that complement one another. There are three projections, yet text deserves two paradigms at once: the dense vector catches meaning, sparse lexical the exact word form that an embedding smooths over; plus graph traversal along relations and exact filters on the body. Each is a self-contained SQL over its own table; they can be fused into one result or handed out as tools by intent (below).
The user's query is first embedded by a model (inference), then the nearest chunks are found by vector — proximity by meaning, not word overlap. Returns chunks ranked by distance.
The same chunks, but searched by word form rather than meaning:
exact terms, acronyms, codes, names — what the vector smooths over.
Postgres full-text (tsvector + GIN), ranked by
ts_rank. A sparse signal alongside the dense one:
together they recover what each misses alone.
Recursive traversal of edges from starting nodes for near context (1–3 hops): thread, hierarchy, mentions. Brings in the related that neighbours in the graph but isn't similar in text. The caller supplies the starting nodes: as a tool — the passed entities; in fused mode — the top hits of the text primitives and sql.
Targeted selection on the record body: type, source, status, dates. A deterministic filter — what you already know for certain, without guessing at meaning. On top of the selection — aggregates (count, grouping, trend) from a closed set: Postgres computes the numbers deterministically, not the model in its head.
Each primitive returns its own ranked list; fusion reduces the four to one — via RRF (reciprocal rank fusion: a position's weight, not the raw score, which is incomparable across paradigms) or weighted. Merging goes by rank, not by score — which is why it isn't "score-level": there's no way to compare the raw scores of vector, lexical and traversal. Fusing four paradigms under unified permissions is a distinguishing trait of KS: "Query Engine assembles results" reads on the RAG layer, not as raw cross-store merging from several DBMSs.
The lists are of different granularity: vector and lexical rank
chunks, sql and
graph rank entities. The common merge key is the entity: chunk hits
fold up to their parent by entity_id, the best chunk stays
with it as evidence for context packing; an entity that arrived via
several primitives is deduplicated by RRF, which sums its ranks from the
different lists.
The agent is given three tools by intent: search
(text retrieval — vector+lexical, RRF-fused
in code, the model doesn't reconcile dense+sparse by hand),
graph and sql. Qualitatively different
operations are kept apart, and text fusion isn't dumped on the LLM —
the agent combines them deliberately.
A ready fused list — one call, fusion inside KS. The backbone of RAG retrieval: from here it only needs reranking and packing into context.
Permissions aren't a separate step after search but a condition of the query itself. Vectors, graph and the relational body live in one database, so the ACL pre-filter is the same SQL: candidates are narrowed by access before ranking, in a single JOIN, with no second DBMS and no cross-database permission reconciliation. One pass covers all four primitives and their fusion — what's revoked never reaches fusion, rather than being filtered out after the fact.
The caller's identity is an input to retrieval: the consumer passes an identity, and KS expands it into a resolution join and applies the filter in a single SQL. The contract is narrow — pass an identity, get candidates under permissions; the consumer neither builds the join nor sees the permission schema.
The query is a value, not code. The primitives are
parameterized SQL: query, vector and filters go as bound
values, not concatenated into the query body; the fields of the
sql filter (type, source, status, dates) are a closed list,
not free input; the aggregates too are a closed set (metric × dimension
from a catalog), not free-form SQL. No input — whether from RAG or from
an agent — can turn into SQL injection.
A strict ACL filter over the approximate HNSW index has a pitfall — it's
worked through
right at the chunks table.
KS ends at candidate selection. What comes on top is beyond the module's
boundary, with two consumers: one takes the assembled hybrid result in
one call and carries it through to an answer (Query Engine — at
the chat model's discretion), the other takes tools by intent —
search · graph · sql — in an
agentic loop (Agent Engine).
sql also returns a second format: not candidates for
fusion, but a ready aggregate (count, grouping, trend)
over the ACL-prefiltered slice. This is outside fusion — a separate tool
mode; it's the agent that interprets the result.
Four ACL-filtered primitives and their fusion. Returns candidates — chunks and entities, ranked, under permissions. This is where the storage layer ends.
When the chat model calls search, it takes the assembled hybrid result and carries it through to a grounded answer: rerank (RRF from KS in v1), context packing and AI LLM generation. One optional call, no agentic loop.
Both consumers of the retrieval layer are still stubs: the assembled result goes to RAG in → Query Engine, and the tools by intent (search · graph · sql) go to → Agent Engine.
is_empty
The database may be empty — the company deployed the platform but hasn't
connected sources, or ingestion hasn't yielded a single chunk yet. Then
chunks holds not
a row: retrieval has nothing to work on, and there's no point calling
it. KS exposes this outward as the derived property
is_empty — whether there are any chunks to search at all.
It's not a flag or a "mode" that someone turns on, but a consequence of
data being present — exactly like SMTP's
is_available(): a property of the model, not a toggle. The
implementation may keep a cheap cached metric instead of counting on the
fly.
The property is read by the shared
tool-calling harness: when is_empty, it doesn't put
KS tools into the model's schema at all — on both surfaces,
in chat (search_knowledge) and for the agent (the core
search · graph · sql). The model
doesn't see search and answers like an ordinary assistant; grounding
isn't "switched off" — its precondition simply hasn't been met. The
first accepted chunk flips the property itself — the tools appear
without a single setting.