Skip to main content

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 pgvector and keyword search over a Postgres FTS GIN index run in parallel, each over-retrieving ~3× the final k.
  • The Reranker fuses them — shipped defaults 0.55·vector + 0.25·keyword + 0.05·heading (configurable via kb.reranking.*) — then applies a canonical boost and a status penalty, so human-accepted canonical docs outrank auto outrank raw (the anti-hallucination firewall).

Graph expansion + anti-repetition

After reranking, two config-gated steps fold in institutional memory:
  • GraphExpander walks 1 hop of kb_edges from the canonical seeds and adds the neighbours under a 📎 RELATED CONTEXT block.
  • RejectedApproachInjector surfaces dismissed options under a ⚠ REJECTED APPROACHES block so the model stops re-proposing them.
Both degrade to a no-op when a tenant has no canonical docs — see Institutional memory.

The typed prompt + citations

The prompt is composed from resources/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.
The same template also injects an authoritative current date/time (ISO 8601) near the top, so the model has an unambiguous “now” for time-relative reasoning (“documents from last month”, “is this deadline past?”). It is explicitly marked usable even though it is not part of the retrieved Context, so the prompt’s own refusal protocol (__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.phpkb.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 fetches GET /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 a filters 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 typed refusal_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.