Skip to main content

Knowledge

Overview​

Unified search across a project's documents and memories: one endpoint, ranked by hybrid retrieval — a vector query and a full-text query over each store, fused by reciprocal rank — and tagged by source.

Each result carries source_type ("document" or "memory"). Agents use the same layer for retrieval: Agent with Persistent Memory — Step 8 (Create an agent with knowledge_config) and the Memory & Knowledge Engine deep dive.

In the engine & algorithms pattern: the two stores, the search function, and injection are the engine; chunking and ranking are the algorithms; ingestion rules are the seam for a bring-your-own extraction tool.

See the Permissions Reference for the IAM action strings for this module.

Data Model​

KnowledgeResult​

A KnowledgeResult is a discriminated union on source_type; source-specific fields appear only for the matching type.

Common fields (all source types)​

FieldTypeDescription
source_type"document" | "memory"Discriminant for the knowledge source type
contentstring|nullText content of the result
scorenumberFused relevance ranking; only present when query is used — see Relevance scoring
similarity_scorenumberRaw cosine similarity (0–1); present on every query result, absent only in the embedding-degrade path — see Relevance scoring
signalsobjectWhich channels ranked this result and at what position; only present when query is used — see Reading a result
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

Document result (source_type: "document")​

FieldTypeDescription
document_idstringPublic document ID (doc_ prefix)
document_versionnumberVersion of the document the chunk belongs to — cite it to record which text was read; the document's versions keep that text
file_idstringID of the underlying File record
project_idstringID of the owning project
pathstring|nullLogical path within the project (e.g. /reports/q1.txt)
filenamestringOriginal filename
sizenumberFile size in bytes
titlestring|nullDocument title (if set)
metadataobjectThe document's annotation bag, returned with keys in the exact casing they were written with — not converted between snake_case and camelCase like other fields. See Tags and metadata
tagsobjectKey-value tags associated with the document

Memory result (source_type: "memory")​

FieldTypeDescription
memory_idstringPublic memory ID (mem_ prefix)
memory_store_idstringPublic ID of the parent memory store (mstore_ prefix)
memory_store_namestringHuman-readable name of the parent memory store
tagsobjectThe memory's tags; absent when it has none

Key Concepts​

Search Modes​

The POST /knowledge/search filters (at least one required):

ParameterTypeDescription
querystringSearch query — ranks results by hybrid retrieval
memory_store_idsstring[]Search memories within these specific memory stores
document_pathsstring[]Filter document results to paths starting with these prefixes
document_idsstring[]Filter document results to specific document IDs
tagsobjectFilter both stores to results whose tags contain every one of these key-value pairs (exact, case-sensitive)
include_documentsbooleanDefault true. false leaves the document store out of this search
include_memoriesbooleanDefault true. false leaves the memory store out of this search

With query, results carry score and similarity_score, ordered by descending score; min_similarity, rrf_k and limit apply. Walkthrough: Agent with Persistent Memory — Step 12 (Query the knowledge layer directly).

Platform-written documents​

Each conversation turn is a document under /.system/conversations/, so a bare query would rank chat history against the knowledge a project deliberately uploaded. It does not: a search leaves the reserved root out unless document_paths names a directory inside it.

{ "query": "refund policy", "document_paths": ["/.system/conversations/"] }

Turns reach the vector channel only when their conversation's retrieval is embed; otherwise they are still matched by the full-text channel.

Each turn is stamped with the conversation, its owner, the agent and the role (what a turn is stamped with), so tags selects them without naming a path:

QuestionRequest
What did this customer say before?tags: { "system.actor": "actor_…" } — turns and the facts distilled from them
Earlier in this conversation, past the context windowtags: { "system.conversation": "conv_…" }
Where did users mention refund delays?query + tags: { "system.role": "user" }

A system.* key in tags reaches the reserved root on its own, so it composes with query without document_paths.

Which stores a search reads​

query and tags name no store, so they reach both. The store-specific filters narrow within a store: document_paths, document_ids and metadata for documents, memory_store_ids for memories.

RequestDocumentsMemories
query alone✅✅
tags alone✅✅
query + memory_store_ids✅ (project-wide)✅ (those stores)
document_paths alone✅ (those paths)—
metadata alone✅ (matching bags)—
memory_store_ids alone—✅ (those stores)

include_documents: false and include_memories: false take a store out, and a filter naming the other store never overrides them — { document_paths, include_documents: false } returns nothing rather than quietly re-enabling documents. Both false is 400 VALIDATION_FAILED.

Results from both stores are ranked together before limit applies, so a search reaching both does not reserve slots for either.

tags is a key-value object, the same shape every tagged resource stores and the same one the IAM soat:ResourceTag/<key> condition reads. All pairs must match (JSONB containment), exact and case-sensitive:

{ "query": "quarterly revenue", "tags": { "team": "finance", "env": "prod" } }

metadata is the structured question the document listing reads, in the same shape — one grammar, so a filter written for GET /api/v1/documents holds here. It narrows documents alone, because a memory carries no such bag. See Documents — Metadata filters for the operators; an ordering compares by the operand's own type.

{ "query": "quarterly revenue", "metadata": { "quarter": "Q1", "revision": { "gte": 3 } } }

Against memories tags matches at memory granularity: a memory is returned when its parent store's tags contain the pairs (store-level, every memory returned) or when the memory's own tags do (that memory only) — see Memories — Memory-Level Tag Filtering.

Hybrid retrieval​

A query runs two searches over each store, in parallel:

ChannelQueryRanked byFinds
Vectorembedding <=> $query (pgvector)cosine distanceparaphrase, synonym, topic
Lexicalto_tsvector(content) @@ websearch_to_tsquery($query)ts_rank_cdthe exact token, verbatim

Neither channel can answer for the other. A chunk that literally contains SKU-4711 may sit far from the query in embedding space, and a chunk that answers a paraphrased question may share no token with it.

A row carrying no embedding is not a vector candidate: it is reachable through the lexical channel only, whatever the scope, and it never takes a vector slot from a row that has one.

The lexical channel is an exact-token and exact-phrase channel, by design. The default text search configuration is simple, which removes no stopwords and does no stemming, and websearch_to_tsquery requires every term it produces. So a natural-language question almost never matches lexically — identifiers, error codes, product names and SKUs do. The vector channel carries the rest. Set KNOWLEDGE_TEXT_SEARCH_CONFIG to a language-specific configuration (english, portuguese, …) where the deployment's language is known and stemming is wanted; the trade is that the channel stops being exact.

If the lexical query fails — a text search configuration that does not exist is the realistic case — the search answers from the vector channel alone. If the embedding provider is unreachable, it answers from the lexical channel alone, and every result comes back without similarity_score.

No full-text index ships with this. to_tsvector is computed per candidate row, which is cheaper than an index to maintain until a corpus is large. Promote to a stored tsvector column with a GIN index when either holds: lexical p95 latency exceeds vector p95, or a project passes roughly 100k chunks.

Relevance scoring​

Two fields on every query result, with different contracts:

FieldContract
scoreReciprocal rank fusion value, higher is better. The ordering it produces is the contract; the absolute value is not, and nothing filters on it.
similarity_scoreRaw cosine similarity (0–1) between the query embedding and the result. Pinned to that meaning — it is never redefined.

score is Σ 1 / (k + rank) over the channels that ranked the result, k = 60 by default. A result both channels rank outranks one only a single channel ranks highly, which is the point: agreement is better evidence than either signal alone. The value is deliberately not rescaled into 0–1 — that would lend it a stability it does not have, since RRF encodes position, not quality: 1 / (k + 1) is the same number for the best result of a perfect ranking and the best of a useless one.

  • score is comparable only within one response. Do not persist it, compare it across releases, or show it as a percentage.
  • similarity_score is populated on every result of a query search, a lexical-only hit included. It is absent only when the embedding provider was unreachable and the search answered from the lexical channel alone.
  • For a stable number, read similarity_score.

Each channel produces one ranking over the whole search, not one per store: documents and memories are queried separately because they are separate tables, and each channel's two result sets are merged on that channel's own value before fusion. Fusing them as separate rankings would let each store claim result slots by position — the tenth-best memory scoring the same as the tenth-best chunk, whatever either is worth.

Reading a result​

Three fields, three questions:

FieldAnswers
scoreWhere the result landed — the fused order.
similarity_scoreHow close it is to the query, as raw cosine.
signalsHow it got there — which channels ranked it, and at what position in each.

signals carries a 1-based rank per channel, and omits a channel that did not return the result at all:

{ "signals": { "lexical": 1 } } // pure token hit; the vector channel missed it
{ "signals": { "vector": 1 } } // nearest by cosine; contains none of the query's terms
{ "signals": { "vector": 2, "lexical": 1 } } // both found it — this is what fusion promotes

These are the parts score is the sum of, and the sum cannot be taken apart afterwards: Σ 1/(k + rank) gives 2/61 for a result both channels put first and 1/31 for one a single channel put thirtieth, and those are the same number. signals is what tells them apart.

Use it to answer "why is this here?" without a harness — a result you expected to lead that reads { "vector": 8 } was never found lexically, which is a query-phrasing or text-search-configuration problem, not a ranking one. It is diagnostic only: nothing filters or sorts on it, and a channel that degraded is absent from every result in the response.

Relevance knobs​

ParameterDefaultEffect
min_similaritynoneMinimum raw cosine a vector candidate must reach to be ranked at all, applied before fusion
rrf_kKNOWLEDGE_RRF_K, itself 60The k in 1 / (k + rank); smaller weights the top of each ranking more heavily
recency_half_life_daysKNOWLEDGE_RECENCY_HALF_LIFE_DAYS, itself 0 (off)Half-life of a decay applied to memory results after fusion; 0 disables it

min_similarity filters cosine, never score. A floor on a fused value would be a rank cutoff wearing a similarity knob's clothes.

Lexical candidates are exempt from the floor. A chunk that literally contains the searched token is the evidence; dropping it because its cosine is 0.4 is the failure hybrid retrieval exists to prevent.

The floor filters, it does not refill. It runs over the limit rows each store's vector query already took, so limit: 10 with min_similarity: 0.8 returning three rows means seven of that store's ten nearest fell below the floor — not that the corpus holds only three above it. Raise limit to widen the candidate set the floor is applied to.

The agent record spells the same floor knowledge_config.min_score — one field, two surfaces, so a value set on an agent and a value sent per request mean the same cosine.

Recency blend​

A memory decays in usefulness; a paragraph of a manual does not. With a half-life set, each memory result's fused score is multiplied after fusion by

2 ^ (-age_in_days / recency_half_life_days)

so at equal relevance a fresh fact outranks a stale one. Document results are never touched, and similarity_score — raw cosine — is never touched either.

  • 0 disables the blend, at either level. Zero days is not a meaningful half-life, so the value is a switch rather than a lower bound. It is also the default at both levels, so an upgrade reorders nothing until someone turns it on. A request recency_half_life_days: 0 turns off a deployment-wide decay for that one query — the archival search on a deployment that otherwise wants freshness.
  • Days, as a float. 0.5 is twelve hours; 30 stays readable.
  • Age is read from updated_at, not created_at, so a consolidation merge that re-asserts a fact refreshes it instead of ageing out knowledge the system keeps re-confirming. Every write to the memory counts as a re-assertion — a PATCH of its content and a tag edit included. A tag-only edit therefore resets a fact's age to zero.
  • An invalid value falls back rather than failing. A negative or non-finite recency_half_life_days resolves to the deployment value, then to 0, the same way rrf_k does.
  • The clock is read once per response, so two results in one answer are always ordered against the same instant.
  • Retrieval only. The memory write algorithm's duplicate shortlist reads raw cosine and is unaffected: a memory old enough for the blend to bury is still the duplicate a restatement of it merges into.

How many ranks a half-life costs depends on rrf_k, and on a corpus mixing documents and memories it is never free. Figures and the method for choosing a value: Retrieval Quality.

Ranking is approximate​

Both vector columns carry an HNSW index, so query search is approximate nearest neighbour: it reads a bounded candidate list from the index instead of scanning every vector, keeping cost sub-linear in corpus size. The cost is exactness:

  • Recall against the true top-k is below 1.0. A result that would rank 10th can be missed. Both fields keep their meaning; the set being ordered is not guaranteed to be the exact best k.
  • min_similarity needs re-tuning: the candidate set feeding it changed.
  • Filters do not silently shrink the result set. Scope, paths, document_ids and permission filters apply after the index proposes candidates, so a narrow scope could return fewer than limit rows; SOAT enables pgvector's iterative index scan on every search, widening the candidate list until limit is satisfied post-filter.

The last guarantee needs pgvector 0.8 or newer (hnsw.iterative_scan). On an older extension PostgreSQL discards the setting with a warning and a filtered search can come back short; see Configuration.

Injected knowledge is untrusted input​

Retrieved knowledge is partly user-derived (a memory a memory rule wrote contains what the user said). It is treated as data, never instruction:

  • Never injected with the system role. Agent knowledge injection delivers results as a user message inside a fenced <knowledge> block with a preamble framing it as reference material; the agent's instructions remain the only system input. Otherwise a phrase a user said once could become a persistent system-level instruction.
  • Extraction runs tool-less: a plain completion with no tools and no injection, so quoted text cannot trigger a side effect while becoming memories.

This does not make retrieved content safe to act on: a tool call made after reading it is still authorized only by the agent's boundary policy and guardrails. Scope the boundary policy assuming anything in reachable memory stores and documents may influence the agent.

Project Scoping​

project_id is optional; when omitted, accessible projects come from the caller's identity (API key scope, admin wildcard, or policy grants).

Who pays for the query​

A search with a query embeds it once, billed to the first project in scope, by public id, that the caller may spend embeddings on (embeddings:CreateEmbeddings), as POST /api/v1/embeddings requires. A caller who may only read is refused with 403 FORBIDDEN; a search by tags, metadata, paths or ids alone embeds nothing and needs no such permission. See Embeddings.

Policy Conditions Narrow the Candidate Set​

With project_id, the caller's knowledge:SearchKnowledge policy for documents is compiled into the search query itself: an SRN restriction and a soat:ResourceTag/<key> condition both become part of the filter a chunk has to satisfy to be ranked at all. A document the policy excludes is not a candidate — it does not consume a limit slot and never reaches the agent. Walk it end to end in Tag-Based Access Control.

Without project_id there is no single project policy to compile, and the search is scoped by project only.

Result ceiling​

limit defaults to 10 and is clamped to 100; a larger value returns up to 100 rows rather than being refused.

Retrieval baseline​

Ranking changes are gated on a versioned golden query set (packages/server/tests/eval/knowledge/golden.json, figures in baseline.json); the run exits non-zero when recall@10 or MRR drops below the baseline, overall or for any single kind. It is a contributor harness, not a way to measure a deployment.

pnpm --filter @soat/server eval:knowledge # score and gate
pnpm --filter @soat/server eval:knowledge --update-baseline # rewrite the baseline

Each query carries a kind, and the gate applies per kind as well as overall, so a change that lifts the headline number while breaking one kind still fails. Two kinds are worth knowing before reading a result:

  • freshness pairs two statements of one fact in a single store, close enough that the query cannot separate them and only age_days says which is current. The corpus store raises its own supersede_threshold so the pair survives the write path; on the product defaults the older twin would be invalidated and never reach the corpus. Its MRR is well under 1.0 by construction — no shipped mechanism orders by age, since recency_half_life_days defaults to 0.
  • exact_token is saturated at recall 1.0: the eval's embedder is itself lexical, so it cannot show a lexical-vs-vector win. The harness proves a change regresses nothing; it does not prove a gain.

Metric definitions, the baseline table, its caveats and the per-deployment method: Retrieval Quality, Measuring Retrieval Quality.

Configuration​

Environment VariableRequiredDescription
FILES_STORAGE_DIRYesDirectory where .txt files are stored (shared with Files)
EMBEDDING_PROVIDERYesEmbedding backend: ollama, openai, or bedrock
EMBEDDING_MODELYesModel name, e.g. qwen3-embedding:0.6b
EMBEDDING_DIMENSIONSYesVector dimensions — must match the model output, e.g. 1024, and be at most 2000
OLLAMA_BASE_URLNoOllama server URL, defaults to http://localhost:11434
KNOWLEDGE_TEXT_SEARCH_CONFIGNoPostgreSQL text search configuration for the lexical channel, defaults to simple — see Hybrid retrieval
KNOWLEDGE_RRF_KNoDeployment default for rrf_k, itself 60. A request's own rrf_k wins

Examples​

Semantic search across documents and memories​

soat search-knowledge \
--project-id proj_ABC \
--query "quarterly revenue" \
--memory-store-ids mstore_xyz \
--limit 5

Path-scoped document retrieval (no query)​

soat search-knowledge \
--project-id proj_ABC \
--document-paths /docs/products/