> ## Documentation Index
> Fetch the complete documentation index at: https://doc.askmydocs.padosoft.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Chat & retrieval

> A chat turn end to end — hybrid retrieval, reranker fusion, graph expansion, rejected-approach injection, the typed prompt, grounded citations, and the refusal contract.

## 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

```mermaid theme={null}
sequenceDiagram
    participant U as User
    participant C as KbChatController
    participant S as KbSearchService
    participant A as AiManager
    U->>C: POST /api/kb/chat {question, filters}
    C->>S: searchWithContext(query)
    S->>S: embed → 3× over-retrieve (vector + FTS)
    S->>S: Reranker fusion (0.55 vec + 0.25 kw + 0.05 head)<br/>+ canonical boost − status penalty
    S->>S: GraphExpander (1-hop) + RejectedApproachInjector
    S-->>C: SearchResult{primary, expanded, rejected, meta}
    C->>C: compose typed prompt (⚠ rejected + 📎 related + ## context)
    C->>A: chat(system, prompt)
    A-->>C: answer + usage
    C-->>U: {answer, citations, meta}  (or a typed refusal)
```

## 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](/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](/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](#the-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 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).

<CardGroup cols={2}>
  <Card title="Retrieval pipeline (architecture)" icon="layer-group" href="/architecture/overview">
    The reranker fusion weights and request lifecycle in depth.
  </Card>

  <Card title="Grounding & evidence tiers" icon="scale-balanced" href="/grounding-and-evidence-tiers">
    Grounded-or-refuse + the evidence-strength axis.
  </Card>
</CardGroup>
