← Knowledge Store

Hybrid search

knowledge-store · workzone

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.

Four search primitives

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).

vector AI
similarity search · ANN
chunks · pgvector / HNSW

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.

lexical
word search · full-text
chunks · tsvector / GIN

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.

graph
relation traversal · 1–3 hops
entity_edge · recursive SQL (CTE)

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.

sql
exact filters · aggregates
entities · relational predicates

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.

Merging lists: four paradigms into one result

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.

vector
lexical
graph
sql
weighted / RRF
fused
Tools by intent tools · agent

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.

Assembled hybrid result RAG retrieval

A ready fused list — one call, fusion inside KS. The backbone of RAG retrieval: from here it only needs reranking and packing into context.

ACL applies once — in storage

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.

Boundary: retrieval here, the answer beyond

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.

Knowledge Store retrieval

Four ACL-filtered primitives and their fusion. Returns candidates — chunks and entities, ranked, under permissions. This is where the storage layer ends.

Query Engine RAG on the query

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.

Empty index: the derived property 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.