Motivation
The whole point of AskMyDocs is a grounded answer: every claim traceable to retrieved, cited context — or an honest refusal. A chat turn is therefore a fixed pipeline, not a single vector lookup.The chat turn
Hybrid retrieval
Retrieval never relies on vector similarity alone:- Vector search over
pgvectorand keyword search over a Postgres FTS GIN index run in parallel, each over-retrieving ~3× the finalk. - The
Rerankerfuses them — shipped defaults0.55·vector + 0.25·keyword + 0.05·heading(configurable viakb.reranking.*) — then applies a canonical boost and a status penalty, so human-acceptedcanonical docs outrankautooutrank raw (the anti-hallucination firewall).
Graph expansion + anti-repetition
After reranking, two config-gated steps fold in institutional memory:GraphExpanderwalks 1 hop ofkb_edgesfrom the canonical seeds and adds the neighbours under a 📎 RELATED CONTEXT block.RejectedApproachInjectorsurfaces dismissed options under a ⚠ REJECTED APPROACHES block so the model stops re-proposing them.
The typed prompt + citations
The prompt is composed fromresources/views/prompts/kb_rag.blade.php with typed
blocks (⚠ rejected, 📎 related, primary ## Context). The response carries:
answer— the grounded text;citations— the exact chunks that grounded it;meta— provider, model, latency, retrieved-chunk count, filters echoed back.
__NO_GROUNDED_ANSWER__) does not treat the time as
ungrounded — once a turn already has context, the model can satisfy a
time-relative ask instead of self-refusing. This does not bypass the
controller-level refusal contract: a question that
retrieves nothing relevant is still refused before the LLM is ever called, so
the date line only matters once a turn has context. The app runs on UTC; the
timezone the bot presents is set by KB_PROMPT_TIMEZONE (config/kb.php →
kb.prompt.timezone, default Europe/Rome) — a single render point shared by
every kb_rag surface (chat, conversation, streaming, agent, eval, benchmark; the
embeddable KITT widget renders its own prompt).
The citation document modal
Citations are not just labels — clicking one opens an in-chat document modal (available to every authenticated reader, not only admins) that shows the cited document’s full text without leaving the conversation. It fetchesGET /api/kb/documents/{documentId}/preview, which returns the document’s identity
(document_id, slug, title, project_key, canonical fields) plus a content
string reconstructed from the document’s chunks in chunk_order. Those chunks
are the text retrieval grounded on, so the modal is the most faithful “what the
answer actually used” view — and it needs no KB-disk access, so it works on any
deployment (local or S3).
Isolation (R30) is the load-bearing detail. The document is resolved with
forTenant(current()) AND the model’s global AccessScopeScope (project
isolation) AND SoftDeletes — exactly the boundary the real retrieval path
applies. So “a document you could see cited” is precisely “a document you may
open”: a citation in team A can never surface team B’s bytes. Missing,
foreign-tenant, out-of-scope and soft-deleted all collapse to the same 404
(no existence oracle); a document that legitimately exists but has no chunks
yields a 200 with an empty state, never a fake 404. On the FE the modal
renders content through react-markdown (no raw-HTML pass, so document bytes
can’t inject script) with distinct loading / error (+ retry) / empty /
ready states.
The preview is a deliberate R44 single-surface exception — a chat-UI read
affordance over the existing document-read capability (already on the admin
KbDocumentController HTTP surface and the MCP retrieval tools), so it ships
HTTP-only by design.
Filters
The chat request accepts afilters object (project keys, tags, source types,
date windows, evidence tiers, explicit doc_ids). Legacy callers using the bare
{question, project_key} payload keep working — project_key is wrapped into
filters.project_keys internally.
The refusal contract
When retrieval surfaces nothing relevant above threshold, the controller returns a deterministic refusal — a typedrefusal_reason (e.g. no_relevant_context),
not a fabricated answer and not an HTTP error. The machine-readable reason never
localizes; only the human-visible body does. Every refusal also increments a
content-gap rollup so editors know what to write next. The refusal path also
short-circuits the expensive LLM call — proven by tests that assert the
provider shouldNotReceive('chat').
Streaming UI
The React chat at/app/chat streams over SSE on the Vercel AI SDK v6
UIMessageChunk wire format, with stop / regenerate / branch / inline-edit /
token-cost meter / suggested-follow-ups, and inline citations. The stateless JSON
API (POST /api/kb/chat) is the headless equivalent.
Gotchas & operations
- Logging never breaks the user path —
ChatLogManager::log()is wrapped in try/catch; never hoist logging into the hot path. - A refusal is not an error — map it to a 200 with the typed reason, never a 4xx/5xx, and never an empty answer.
- New retrieval services must honour the reranker’s canonical boost + status penalty (or add an ADR explaining the deviation).
Retrieval pipeline (architecture)
The reranker fusion weights and request lifecycle in depth.
Grounding & evidence tiers
Grounded-or-refuse + the evidence-strength axis.