Zum Inhalt springen

Knowledge base (RAG)

Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.

The knowledge base (KB) is server-side, group-scoped retrieval-augmented generation (RAG). When an assistant-class chat runs for a user whose group has a KB, the server transparently lets the model search that KB and grounds the answer in the retrieved passages — returning structured citations the client can render as sources. The model never receives a wider scope than the caller is entitled to.

A KB is modelled as a server plugin (Kind = "mcp_server", config {"type":"knowledge_base",…}) and is switched on for a group through the existing group-plugin allowlist. The feature is behaviour-neutral until you opt in: with no vector DB configured the tool is never injected, the ingestion worker idles, and the admin KB pages show a “vector DB required” notice.

The KB is guarded by two independent gates — both must be satisfied:

  1. License entitlement — the Knowledge license feature must be granted. Without it the chat path simply runs normal (ungrounded) chat with no error, and the ingestion REST API returns 403 feature_not_licensed.
  2. Vector DB configured — Knowledge:ConnectionString must point at a pgvector database. Without it the KB is operationally disabled (the ingestion API returns 503 knowledge_disabled).

KB content lives in its own PostgreSQL database (the pia-vectordb container, running the pgvector extension), separate from the main application database.

Store Context Holds
Main DB PiaDbContext Users, groups, the plugin catalog + group allowlist, token usage
Vector DB KnowledgeDbContext Documents, chunks + embeddings, the embedding-token ledger

The two stores share no foreign keys — the vector DB references the KB catalog by PluginId value only. The embedding dimension is fixed at compile time (one vector(N) column + one HNSW index); startup validates the configured dimension against it and disables the KB on mismatch, because changing it requires a migration and a full re-embed.

Ingestion is asynchronous. A submitted document is hashed (SHA-256, for idempotent de-duplication), persisted as Pending, and drained by a background worker:

Pending → extract text → semantic double-pass chunk → (contextual retrieval) → embed → store chunks → Ready
  • Chunking uses a semantic double-pass merging splitter (ported from the n8n recipe); the chunker and the embedding model are instance-level settings, never per-KB.
  • Contextual retrieval (optional, on by default) prepends a one-sentence, LLM-generated description of each chunk before embedding (context + delimiter + raw chunk), which measurably improves recall. The raw chunk is what gets stored and cited. Contextualization needs its own chat provider; if none is configured it is auto-disabled at startup with a warning rather than failing ingestion.
  • Resilience: the embedding and contextualizer HTTP calls retry on rate-limit/transient errors and time out; a per-chunk contextualization failure degrades gracefully (the raw chunk is still embedded) instead of failing the whole document; and a worker that crashes mid-document resets stranded Processing rows to Pending on its next start.

A document that cannot be processed is marked Failed with an error message, visible in the admin UI.

A document reaches a KB by one of three routes. All three end as the same Pending row drained by the same worker, so nothing downstream depends on which one was used.

Route Who Scope
Admin console upload An operator Any KB
Ingestion API A pipeline holding the instance ingest key Any KB
KB manager API A KB manager, from the desktop app The KBs granted to their own group

The first two add documents; content identical to a live document resolves to that document’s id instead of creating a second one — unless that copy is Failed, which never blocks identical content: it is removed and the content is ingested afresh. Only the third route also edits a document in place.

A replace keeps the document: its id, creation time and retrieval statistics (count and last-retrieved time) survive, so its usage history stays continuous. It runs in one transaction under the same per-KB advisory lock a submit takes:

  1. The document must be live and belong to that KB, otherwise 404. The write at the end is conditional on the document still being live, so a delete that commits in between also answers 404 instead of writing onto the removed document.
  2. Same hash as the stored text → nothing happens (200 with the current status). The hash is computed exactly as for a submit. The exception is a Failed document: nothing else would ever retry it, so the same text queues it again (202, Pending).
  3. Another live document in the KB already has that hash → 409 duplicate_content, carrying that document’s id. A Failed one never blocks: it is removed with its chunks and the replace goes ahead.
  4. Quotas. The byte limit is checked on the change (current total − old size + new size), so the old copy is not counted twice; the monthly embedding-token pre-check uses the new text’s estimate. A breach is 409 quota_exceeded.
  5. One update writes the new text, hash, size and content type (and the title, if one was sent), re-stamps the token charge onto the KB’s current owner group, clears the error and sets the status to Pending.

The old chunks are not removed at this point. They keep answering searches until the worker has processed the new text and swaps the whole set in one transaction, so the replace itself opens no gap in which the document cannot be found — at the price of answers that can still cite the previous text while the job is queued. Ready again means the swap happened.

The worker keeps the hash stored with the text it dequeued and hands it back when it finishes. Completing is one transaction whose first statement is an UPDATE conditional on the stored hash still being the processed one and the document not being deleted; it sets Ready, and only when it claimed the row does the chunk swap follow.

  • Replaced while processing. The stored hash no longer matches, the update claims nothing, the transaction rolls back and the freshly computed chunks are discarded. The document is already Pending from the replace, so the worker picks it up again with the new text.
  • Deleted while processing. The same: nothing is written for a document that is gone.
  • Failing is conditional too. A run marks a document Failed only while it is still Processing on the hash that run processed, so an error on the old text can neither mark the replacement Failed nor cancel a re-queue.

Because the UPDATE takes the row lock first, a replace or delete that lands mid-run either commits before it (and the hash then differs) or waits for it and then re-queues (replace) or removes (delete) the document. A delete locks the row first and only then removes the chunks, so it always sees the chunks a finishing run just inserted. A discarded result is logged at information level as “changed or was deleted while it was processed”.

Retrieval is hybrid and always scoped in SQL to the caller’s allowed KB ids:

  1. Semantic candidates via pgvector cosine distance over an HNSW index.
  2. Lexical candidates via PostgreSQL full-text search over the raw chunk.
  3. The two lists are fused with Reciprocal Rank Fusion (RRF) and re-ranked (identity re-ranker in v1, behind a swappable seam).

The store never returns a chunk outside the allowed set — the scope predicate is re-applied even on the final re-fetch (defense in depth).

Retrieval is exposed to the model as a single search_knowledge_base tool, injected only when the mode is assistant-class (Assistant / Research — never Optimize or voice) and the user’s group enables a KB. ToolChatOrchestrator runs the loop server-side (the KB is wired in as KnowledgeConnector, the first in-process connector over the unchanged retrieval invoker):

  • KB tool calls are resolved on the server and the conversation loops with the results appended (up to an iteration cap).
  • Any client-declared tools the request carried are passed straight back to the client to execute. If a turn mixes a client tool call with a KB call, the KB call is resolved first and the client call deferred to the next round, so a KB lookup is never silently dropped.
  • The KB tool and its call/result messages are stripped from everything the client sees; the retrieved passages are returned as structured knowledge_citations, never inlined as prose.

When the client requests stream:true, the tool-resolution rounds run buffered (a tool call can’t be resolved before its arguments are complete), then the final grounded answer is streamed as Server-Sent Events. A look-ahead guard guarantees no tool-call bytes leak mid-stream; citations arrive as a trailing knowledge_citations event immediately before data: [DONE].

The number of search_knowledge_base calls resolved during a turn is recorded on that request’s token-usage row, so KB activity surfaces alongside model spend in the KB column of the admin token-usage view (/admin/token-usage). This is distinct from the embedding-token ledger below, which tracks ingestion-time spend.

Per-group quotas (GroupSettings.Quotas, edited in the group editor) bound KB usage:

Quota Enforced
KnowledgeBases When enabling KBs for a group
KnowledgeDocuments / KnowledgeStorageBytes Per KB at ingestion → 409
MonthlyEmbeddingTokens Per group, against a dedicated embedding-token ledger

The effective doc/byte limit for a KB is the most permissive among the groups that enable it (else the instance baseline). For monthly tokens, ingestion pre-checks an estimate against that most-permissive limit and the worker records the actual provider token count afterward; a KB shared across groups attributes its spend to a single deterministic owner (the lowest group id) so usage is never double-counted. A replace checks the byte limit as a delta and re-stamps the charge onto the current owner.

Tenant scoping is server-resolved: the set of allowed KB ids comes from the authenticated user → group → group-plugin allowlist. The model only ever supplies the query text and topK; it cannot widen the scope.

The KB manager API resolves its allowed set the same way, but from the caller’s own user row and group grants on every request — not from the cached group — so a manager who was moved never keeps the KBs of the group they left. A KB outside the set answers 404, never 403; an account without the role answers 403 not_a_kb_manager. A managed persona that widens what chat may search confers no right to change it.

Documentation: see Configuration → Knowledge base and the admin setup guide.