P0 — highest priority. Accuracy is the top concern for this cycle. Ship this before the agentic work (see the agentic execution ticket).
Problem
The same factual question returns different — and often fabricated — answers on repeated asks. Reproduced end-to-end against the production Ollama host (http://10.0.0.128:11434) with the production model.
Prompt "did Taylor Swift get married" sent straight to llama3.2 with the production sampling params (temperature=0.7, top_k=50, top_p=0.9, repeat_penalty=1.1, num_ctx=4096), three consecutive runs:
run 0: "As of my knowledge cutoff in December 2023, Taylor Swift is not married."
run 1: "Yes, Taylor Swift is married. She tied the knot with Joe Alwyn, a British
actor, on March 18, 2023." <-- entirely fabricated
run 2: "Taylor Swift is not married."
This is exactly the reported "three different answers, one correct" behaviour. Run 1 invents a wedding that never happened, with a specific date, stated confidently.
Reproduction 2 — search results are non-deterministic and sometimes poisoned
DuckDuckGoSearchRun().run("did Taylor Swift get married"), three consecutive calls, returned three materially different blobs:
run 0 (1082 chars) — correct: married Travis Kelce, July 3, Madison Square Garden.
run 1 (714 chars) — poisoned: "fake, AI-generated photos of Taylor Swift and Travis Kelce in wedding attire", "Rumors are running wild online following a new blind item claim", plus an unrelated couple (Hurtado and Bochman) who married at a Swift concert. A small model handed only this blob will reasonably answer "no, those were fakes / just rumours".
run 2 (1136 chars) — correct.
The blob has no URLs, no per-result delimiters, no publication dates, and no ranking. It is injected verbatim as HumanMessage(content=f"Search Results: {search_results}").
Reproduction 3 — when search does land cleanly, answers are correct and stable
Four end-to-end runs through the real AsyncLLMService template with a fresh search each time all produced the correct answer (married Travis Kelce, July 3, Madison Square Garden). Grounding works. The problem is that grounding is optional, silent when it fails, and unstructured when it succeeds.
Root causes
1. Search is gated behind three independent conditions, any of which silently disables it
llm_be/chat_backend/consumers.py:481-497 (and the mirror in consumers_graph.py:280-294):
ifprompt_type==PromptType.SEARCH:# Check modelName first - if FAST, we skip search regardless of settingsifinput_dict.get("model_name")=="FAST":pass# Skip searchelifgetattr(settings,"ALLOW_INTERNET_ACCESS",False):try:search=DuckDuckGoSearchRun()search_results=search.run(input_dict["message"])messages.append(HumanMessage(content=f"Search Results: {search_results}"))exceptExceptionase:logger.error(f"Search failed: {e}")pass
Search runs only if all of: classifier returns SEARCH, andmodelName != "FAST", andALLOW_INTERNET_ACCESS. Any miss falls through to Reproduction 1. The except swallows failures with no user-visible signal, so a DuckDuckGo rate-limit degrades straight to confident hallucination.
2. Production runs the smallest model on the box, for everything
llama3.2:latest is 3.2B params, Q4_K_M, 2.0 GB. It backs every LLM call: chat generation, moderation, prompt classification, title generation, RAG synthesis, and data analysis.
Meanwhile settings.py:154-157 defaults to gpt-oss:20b when DEBUG is on. Development runs a 20B thinking model; production runs a 3B model. Nothing that works in dev is validated against what users actually hit.
Available on the GPU host today (/api/tags):
Model
Params
Size
Capabilities
Context
gpt-oss:20b
20.9B
13.8 GB
completion, tools, thinking
131072
llama3.3:latest
70.6B
42.5 GB
completion, tools
131072
gemma4:latest
8.0B
9.6 GB
completion, tools, thinking
—
qwen2.5-coder:7b
7.6B
4.7 GB
completion, tools, insert
32768
llama3.2:latest
3.2B
2.0 GB
completion, tools
131072
nomic-embed-text
137M
274 MB
embedding (768d)
2048
embeddinggemma
308M
622 MB
embedding (768d)
2048
3. A causal LM is being used as the embedding model
OLLAMA_EMBED_MODEL=llama3.2 means RAG embeddings come from mean-pooled hidden states of a chat model, while two purpose-built embedding models (nomic-embed-text, embeddinggemma) sit unused on the same host. This degrades every retrieval in the RAG path. settings.py:158 makes this the silent default: OLLAMA_EMBED_MODEL = env("OLLAMA_EMBED_MODEL", OLLAMA_MODEL).
4. num_ctx=4096 hard-coded, and history is rendered into the prompt twice
services/llm_service.py:19-26 pins num_ctx=4096 even though llama3.2 and gpt-oss:20b both advertise 131072.
Worse, _setup_chain (llm_service.py:92-123) fills {context} from _format_history(conversation) (all messages) and {recent_history} from _get_recent_messages(conversation[-6:]). The search blob is a member of messages, so it is serialised into the prompt twice. On a long conversation this overflows 4096 tokens and Ollama truncates from the front — dropping the system prompt and the search block while keeping the trailing question.
Secondary: the template says "Last 3 messages" but slices [-6:].
5. No citations anywhere
Neither the web-search path nor the RAG path attaches source URLs, titles, or dates. Users cannot tell a grounded answer from a fabricated one — which is precisely how Reproduction 1 run 1 slips through.
6. Metrics cannot distinguish models
consumers.py:55 and consumers_graph.py:46 hard-code model_name="llama3.2" on PromptMetric. Any model change is invisible in analytics, so we cannot measure whether this ticket worked.
Proposed implementation
Phase 1 — Configuration and model routing
1.1 Split model configuration by role. Replace the single OLLAMA_MODEL with role-scoped settings in llm_be/llm_be/settings.py, each independently overridable:
Keep OLLAMA_MODEL honoured as a fallback for all four so existing deploys do not break. Remove the DEBUG-conditional default at settings.py:154-157 so dev and prod resolve identically unless explicitly overridden.
Rationale for OLLAMA_MODEL_UTILITY=llama3.2: classification was measured stable on the small model (see Testing below), and keeping it small preserves latency on the hot path.
1.2 Confirm VRAM headroom on 10.0.0.128 before promoting gpt-oss:20b (13.8 GB) as the THINKING default, accounting for a concurrently loaded FAST model and the embedding model. If headroom is tight, set OLLAMA_KEEP_ALIVE and stagger, or fall back to gemma4:latest for THINKING. Record the measured numbers on this ticket.
1.3 Migrate embeddings to nomic-embed-text. This changes vector dimensionality, so existing Chroma collections are invalid. Ship a management command reindex_embeddings that recreates the collection and re-ingests every active Document, preserving the workspace_id / company_id / document_id / active / source metadata written in rag_services.py:118-124. Refuse to start (loud error, not a silent fallback) if the persisted collection dimensionality does not match the configured embedding model.
1.4 Thread the real model name into metrics. Replace the hard-coded "llama3.2" at consumers.py:55 and consumers_graph.py:46 with the model actually resolved for the request. Without this, none of the acceptance criteria below are measurable.
Phase 2 — Always-on grounded retrieval
2.1 Invert the default: retrieval is on unless proven unnecessary. Replace the PromptType.SEARCH-only trigger with an explicit groundedness decision that runs for every turn. A query needs retrieval unless it is self-contained — creative writing, math, code, chit-chat, or a pure follow-up on text already in the conversation.
Implement as a small dedicated GroundingDecider on OLLAMA_MODEL_UTILITY at temperature=0.0, returning strict JSON:
{"needs_retrieval":true,"reason":"asks about a real-world fact that can change","queries":["Taylor Swift Travis Kelce wedding date"]}
Bias the prompt explicitly toward retrieval: any question about a person, organisation, product, price, event, date, statistic, or anything post-training-cutoff must set needs_retrieval=true. Add a deterministic pre-pass that forces true on temporal markers (latest, current, today, now, this year, did … yet, a year >= training cutoff) so the model cannot veto obvious cases. On parse failure or timeout, fail open to retrieval — the expensive error is skipping search, not running it.
2.2 Remove the FAST search bypass. FAST must select a smaller/faster model, not disable grounding. This directly fixes "Flash mode can't answer because it was trained before it happened." Delete the if input_dict.get("model_name") == "FAST": pass branch in both consumers.
2.3 Generate multiple focused search queries rather than passing the raw user message to the search engine. queries from 2.1, capped at 3, executed concurrently.
2.4 Replace DuckDuckGoSearchRun with a structured, pluggable search layer. Introduce services/search/ with a SearchProvider protocol returning structured results — title, url, snippet, published_at, rank — never a flat string. Implement:
DDGSProvider — use ddgs directly (already a dependency, pyproject.toml:44) so we get per-result fields instead of the concatenated blob DuckDuckGoSearchRun produces.
SearxNGProvider — self-hosted, no rate limit, deterministic. Recommended as the production default given the observed DDG variance and the Impersonate 'edge_131' does not exist, using 'random' fallback warnings.
Select via SEARCH_PROVIDER env var, with automatic failover to the secondary provider.
2.5 Rank, deduplicate, and date-filter results. Deduplicate by registrable domain, prefer results with a parseable publication date, and for temporally-marked queries sort recent-first. Drop results whose snippet is dominated by hedging/rumour markers (rumor, speculation, AI-generated, blind item, fake) when better-scored alternatives exist — this is what would have suppressed the Reproduction 2 run-1 poisoning.
2.6 Inject results as a delimited, numbered, dated context block — not f"Search Results: {blob}":
[1] "Taylor Swift and Travis Kelce Are Married" — people.com — 2026-07-03
Taylor Swift and Travis Kelce, both 36, married on Friday, July 3 at
Madison Square Garden in New York City.
[2] ...
Carry the source list alongside the generation so it can be emitted as citations (2.8) rather than being reconstructed from the model's prose.
2.7 Add a grounded-answer system prompt. Extend services/assistant_identity.py with a retrieval-specific instruction: answer only from the numbered sources; cite the source index inline; if the sources do not settle the question, say so explicitly rather than filling the gap from memory; prefer the most recent source on conflict; never state a date or number that does not appear in the sources.
2.8 Stream citations to the client. Emit a structured citations frame in the WebSocket protocol after END_OF_THE_STREAM_ENDER_GAME_42, carrying [{index, title, url, published_at}], and persist it on the assistant Prompt row so citations survive a page reload. Coordinate the frame shape with the frontend progress work (chat_web_app progress-feedback issue) — both add frames to the same stream and should land together.
2.9 Make search failure visible. When retrieval was required but every provider failed, do not silently fall through to parametric generation. Either surface a non-fatal notice alongside a clearly-hedged answer, or return an explicit "couldn't reach live sources" state. Log the failure with the query at WARNING.
Phase 3 — Context window and prompt hygiene
3.1 Make num_ctx configurable per role (OLLAMA_NUM_CTX_THINKING, OLLAMA_NUM_CTX_FAST), defaulting to at least 16384 for the THINKING path. Remove the hard-coded 4096 in llm_service.py:25.
3.2 Stop double-rendering history.{context} and {recent_history} currently serialise overlapping message sets. Send one windowed history. Fix the "Last 3 messages" / [-6:] mismatch.
3.3 Budget the prompt. Compute an approximate token budget and trim oldest history first, never the system prompt and never the retrieved-source block. Today truncation eats exactly the parts that make the answer correct.
3.4 Lower sampling temperature on factual turns. When the grounding decider says needs_retrieval=true, generate at temperature<=0.3. 0.7 on a factual lookup is a large part of the run-to-run variance in Reproduction 1.
Phase 4 — Evaluation harness
4.1 Add llm_be/chat_backend/evals/ with a YAML/JSON set of at least 40 graded questions: post-cutoff facts (the Taylor Swift case included verbatim), stable facts that must not regress, questions the system should refuse or hedge, RAG questions against fixture documents, and multi-turn follow-ups.
4.2 Add a run_evals management command that executes the suite N times (default 3) per question against a configurable model and reports accuracy, self-consistency (identical verdict across runs), citation coverage, hallucinated-fact rate, and p50/p95 latency.
4.3 Wire it into .gitea/workflows/ as a manually-triggered job — it needs the GPU host, so it should not gate every PR, but it must be one click before any model or prompt change ships.
Acceptance criteria
Accuracy and grounding
The exact prompt "did Taylor Swift get married" returns the correct answer (married Travis Kelce, 2026-07-03, Madison Square Garden) on 10 out of 10 consecutive runs, in both FAST and THINKING modes.
The same prompt returns the correct answer with at least one search provider forced offline, exercising failover.
With all search providers forced offline, the system explicitly states it could not reach live sources. It must not answer from parametric memory, and must never produce the fabricated "married Joe Alwyn on March 18, 2023" class of response.
Eval suite accuracy on post-cutoff factual questions is >= 90%, up from the current measured baseline (record the baseline on this ticket before changing anything).
Eval suite self-consistency is >= 95% across 3 runs per question.
Stable-fact questions show no regression against baseline.
Retrieval behaviour
Grounding is evaluated on every turn; PromptType.SEARCH is no longer the sole trigger.
FAST mode never disables retrieval. Verified by test asserting the removed bypass.
The grounding decider fails open — a timeout, exception, or unparseable response results in retrieval running. Covered by unit test.
Temporal-marker pre-pass forces retrieval regardless of model output. Covered by unit test over a table of phrasings.
Search results reach the prompt as structured, numbered, dated, delimited entries with URLs — never as a single concatenated string. Asserted on the rendered prompt.
Provider failover is covered by a test that makes the primary raise and asserts the secondary is used.
Retrieval failure after all providers is surfaced to the user, not swallowed.
Citations
Every answer produced from retrieval carries at least one citation with a resolvable URL.
Citations are emitted as a structured WebSocket frame and persisted on the assistant Prompt, surviving reload.
RAG answers cite source documents using the existing source metadata.
Models and configuration
OLLAMA_MODEL_THINKING, OLLAMA_MODEL_FAST, OLLAMA_MODEL_UTILITY, OLLAMA_EMBED_MODEL are independently configurable and documented in .env.example, .env.prod.example, and README.md.
OLLAMA_EMBED_MODEL defaults to nomic-embed-text and never silently falls back to a chat model.
Startup fails loudly if the persisted Chroma collection dimensionality does not match the configured embedding model.
reindex_embeddings re-ingests all active documents with metadata preserved, verified against a fixture workspace.
Dev and prod resolve to the same model defaults; the DEBUG-conditional default is removed.
VRAM headroom on 10.0.0.128 is measured and recorded on this ticket for the chosen THINKING + FAST + embedding combination.
PromptMetric.model_name records the model actually used, in both consumers. No hard-coded "llama3.2" remains.
Context handling
num_ctx is configurable per role and defaults to >= 16384 for THINKING.
Conversation history is serialised into the prompt exactly once.
Under prompt-budget pressure, oldest history is dropped first; the system prompt and retrieved-source block are never truncated. Covered by a test with an oversized history.
Factual (retrieval-backed) turns generate at temperature <= 0.3.
The eval suite contains >= 40 questions across all five listed categories.
A manually-triggered Gitea workflow runs the suite against the GPU host.
Existing offline unit tests still pass without a live Ollama or network (extend the SKIP_RAG_INIT pattern to search).
Testing notes
Measurements already taken against http://10.0.0.128:11434 on 2026-08-02, for reference when building the baseline:
Prompt classifier is not the bottleneck.llama3.2 at temperature=0.1 returned SEARCH on 15/15 runs across three phrasings of the Taylor Swift question. Classification is stable and correct; the failure is entirely downstream. Keeping the small model for utility roles is safe.
Grounded generation is not the bottleneck either. Four end-to-end runs with a fresh successful search all produced the correct answer.
The failure is the ungrounded path and the unstructured blob. See Reproductions 1 and 2.
Out of scope
Multi-step agentic tool loops — separate ticket.
Frontend rendering of citations beyond agreeing the WebSocket frame shape — see the chat_web_app progress-feedback issue.
Broader file-format parsing — see the file-parsing coverage issue.
## Priority
**P0 — highest priority.** Accuracy is the top concern for this cycle. Ship this before the agentic work (see the agentic execution ticket).
---
## Problem
The same factual question returns different — and often fabricated — answers on repeated asks. Reproduced end-to-end against the production Ollama host (`http://10.0.0.128:11434`) with the production model.
### Reproduction 1 — no-search path fabricates, inconsistently
Prompt `"did Taylor Swift get married"` sent straight to `llama3.2` with the production sampling params (`temperature=0.7, top_k=50, top_p=0.9, repeat_penalty=1.1, num_ctx=4096`), three consecutive runs:
```
run 0: "As of my knowledge cutoff in December 2023, Taylor Swift is not married."
run 1: "Yes, Taylor Swift is married. She tied the knot with Joe Alwyn, a British
actor, on March 18, 2023." <-- entirely fabricated
run 2: "Taylor Swift is not married."
```
This is exactly the reported "three different answers, one correct" behaviour. Run 1 invents a wedding that never happened, with a specific date, stated confidently.
### Reproduction 2 — search results are non-deterministic and sometimes poisoned
`DuckDuckGoSearchRun().run("did Taylor Swift get married")`, three consecutive calls, returned three materially different blobs:
- **run 0** (1082 chars) — correct: married Travis Kelce, July 3, Madison Square Garden.
- **run 1** (714 chars) — poisoned: *"fake, AI-generated photos of Taylor Swift and Travis Kelce in wedding attire"*, *"Rumors are running wild online following a new blind item claim"*, plus an unrelated couple (Hurtado and Bochman) who married at a Swift concert. A small model handed only this blob will reasonably answer "no, those were fakes / just rumours".
- **run 2** (1136 chars) — correct.
The blob has no URLs, no per-result delimiters, no publication dates, and no ranking. It is injected verbatim as `HumanMessage(content=f"Search Results: {search_results}")`.
### Reproduction 3 — when search does land cleanly, answers are correct and stable
Four end-to-end runs through the real `AsyncLLMService` template with a fresh search each time all produced the correct answer (married Travis Kelce, July 3, Madison Square Garden). **Grounding works. The problem is that grounding is optional, silent when it fails, and unstructured when it succeeds.**
---
## Root causes
### 1. Search is gated behind three independent conditions, any of which silently disables it
`llm_be/chat_backend/consumers.py:481-497` (and the mirror in `consumers_graph.py:280-294`):
```python
if prompt_type == PromptType.SEARCH:
# Check modelName first - if FAST, we skip search regardless of settings
if input_dict.get("model_name") == "FAST":
pass # Skip search
elif getattr(settings, "ALLOW_INTERNET_ACCESS", False):
try:
search = DuckDuckGoSearchRun()
search_results = search.run(input_dict["message"])
messages.append(HumanMessage(content=f"Search Results: {search_results}"))
except Exception as e:
logger.error(f"Search failed: {e}")
pass
```
Search runs only if **all** of: classifier returns `SEARCH`, **and** `modelName != "FAST"`, **and** `ALLOW_INTERNET_ACCESS`. Any miss falls through to Reproduction 1. The `except` swallows failures with no user-visible signal, so a DuckDuckGo rate-limit degrades straight to confident hallucination.
### 2. Production runs the smallest model on the box, for everything
Production secrets:
```
OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2
OLLAMA_EMBED_MODEL=llama3.2
```
`llama3.2:latest` is **3.2B params, Q4_K_M, 2.0 GB**. It backs *every* LLM call: chat generation, moderation, prompt classification, title generation, RAG synthesis, and data analysis.
Meanwhile `settings.py:154-157` defaults to `gpt-oss:20b` when `DEBUG` is on. **Development runs a 20B thinking model; production runs a 3B model.** Nothing that works in dev is validated against what users actually hit.
Available on the GPU host today (`/api/tags`):
| Model | Params | Size | Capabilities | Context |
|---|---|---|---|---|
| `gpt-oss:20b` | 20.9B | 13.8 GB | completion, **tools**, **thinking** | 131072 |
| `llama3.3:latest` | 70.6B | 42.5 GB | completion, tools | 131072 |
| `gemma4:latest` | 8.0B | 9.6 GB | completion, **tools**, **thinking** | — |
| `qwen2.5-coder:7b` | 7.6B | 4.7 GB | completion, tools, insert | 32768 |
| `llama3.2:latest` | 3.2B | 2.0 GB | completion, tools | 131072 |
| `nomic-embed-text` | 137M | 274 MB | **embedding** (768d) | 2048 |
| `embeddinggemma` | 308M | 622 MB | **embedding** (768d) | 2048 |
### 3. A causal LM is being used as the embedding model
`OLLAMA_EMBED_MODEL=llama3.2` means RAG embeddings come from mean-pooled hidden states of a chat model, while two purpose-built embedding models (`nomic-embed-text`, `embeddinggemma`) sit unused on the same host. This degrades every retrieval in the RAG path. `settings.py:158` makes this the silent default: `OLLAMA_EMBED_MODEL = env("OLLAMA_EMBED_MODEL", OLLAMA_MODEL)`.
### 4. `num_ctx=4096` hard-coded, and history is rendered into the prompt twice
`services/llm_service.py:19-26` pins `num_ctx=4096` even though `llama3.2` and `gpt-oss:20b` both advertise 131072.
Worse, `_setup_chain` (`llm_service.py:92-123`) fills `{context}` from `_format_history(conversation)` (**all** messages) and `{recent_history}` from `_get_recent_messages(conversation[-6:])`. The search blob is a member of `messages`, so it is serialised into the prompt **twice**. On a long conversation this overflows 4096 tokens and Ollama truncates from the front — dropping the system prompt and the search block while keeping the trailing question.
Secondary: the template says "Last 3 messages" but slices `[-6:]`.
### 5. No citations anywhere
Neither the web-search path nor the RAG path attaches source URLs, titles, or dates. Users cannot tell a grounded answer from a fabricated one — which is precisely how Reproduction 1 run 1 slips through.
### 6. Metrics cannot distinguish models
`consumers.py:55` and `consumers_graph.py:46` hard-code `model_name="llama3.2"` on `PromptMetric`. Any model change is invisible in analytics, so we cannot measure whether this ticket worked.
---
## Proposed implementation
### Phase 1 — Configuration and model routing
**1.1 Split model configuration by role.** Replace the single `OLLAMA_MODEL` with role-scoped settings in `llm_be/llm_be/settings.py`, each independently overridable:
```python
OLLAMA_MODEL_THINKING = env("OLLAMA_MODEL_THINKING", "gpt-oss:20b")
OLLAMA_MODEL_FAST = env("OLLAMA_MODEL_FAST", "gemma4:latest")
OLLAMA_MODEL_UTILITY = env("OLLAMA_MODEL_UTILITY", "llama3.2") # classify/moderate/title
OLLAMA_EMBED_MODEL = env("OLLAMA_EMBED_MODEL", "nomic-embed-text")
```
Keep `OLLAMA_MODEL` honoured as a fallback for all four so existing deploys do not break. Remove the `DEBUG`-conditional default at `settings.py:154-157` so dev and prod resolve identically unless explicitly overridden.
Rationale for `OLLAMA_MODEL_UTILITY=llama3.2`: classification was measured stable on the small model (see Testing below), and keeping it small preserves latency on the hot path.
**1.2 Confirm VRAM headroom on 10.0.0.128** before promoting `gpt-oss:20b` (13.8 GB) as the THINKING default, accounting for a concurrently loaded FAST model and the embedding model. If headroom is tight, set `OLLAMA_KEEP_ALIVE` and stagger, or fall back to `gemma4:latest` for THINKING. Record the measured numbers on this ticket.
**1.3 Migrate embeddings to `nomic-embed-text`.** This changes vector dimensionality, so existing Chroma collections are invalid. Ship a management command `reindex_embeddings` that recreates the collection and re-ingests every active `Document`, preserving the `workspace_id` / `company_id` / `document_id` / `active` / `source` metadata written in `rag_services.py:118-124`. Refuse to start (loud error, not a silent fallback) if the persisted collection dimensionality does not match the configured embedding model.
**1.4 Thread the real model name into metrics.** Replace the hard-coded `"llama3.2"` at `consumers.py:55` and `consumers_graph.py:46` with the model actually resolved for the request. Without this, none of the acceptance criteria below are measurable.
### Phase 2 — Always-on grounded retrieval
**2.1 Invert the default: retrieval is on unless proven unnecessary.** Replace the `PromptType.SEARCH`-only trigger with an explicit *groundedness* decision that runs for every turn. A query needs retrieval unless it is self-contained — creative writing, math, code, chit-chat, or a pure follow-up on text already in the conversation.
Implement as a small dedicated `GroundingDecider` on `OLLAMA_MODEL_UTILITY` at `temperature=0.0`, returning strict JSON:
```json
{"needs_retrieval": true, "reason": "asks about a real-world fact that can change", "queries": ["Taylor Swift Travis Kelce wedding date"]}
```
Bias the prompt explicitly toward retrieval: any question about a person, organisation, product, price, event, date, statistic, or anything post-training-cutoff must set `needs_retrieval=true`. Add a deterministic pre-pass that forces `true` on temporal markers (`latest`, `current`, `today`, `now`, `this year`, `did … yet`, a year >= training cutoff) so the model cannot veto obvious cases. On parse failure or timeout, **fail open to retrieval** — the expensive error is skipping search, not running it.
**2.2 Remove the `FAST` search bypass.** FAST must select a smaller/faster model, not disable grounding. This directly fixes "Flash mode can't answer because it was trained before it happened." Delete the `if input_dict.get("model_name") == "FAST": pass` branch in both consumers.
**2.3 Generate multiple focused search queries** rather than passing the raw user message to the search engine. `queries` from 2.1, capped at 3, executed concurrently.
**2.4 Replace `DuckDuckGoSearchRun` with a structured, pluggable search layer.** Introduce `services/search/` with a `SearchProvider` protocol returning structured results — `title`, `url`, `snippet`, `published_at`, `rank` — never a flat string. Implement:
- `DDGSProvider` — use `ddgs` directly (already a dependency, `pyproject.toml:44`) so we get per-result fields instead of the concatenated blob `DuckDuckGoSearchRun` produces.
- `SearxNGProvider` — self-hosted, no rate limit, deterministic. Recommended as the production default given the observed DDG variance and the `Impersonate 'edge_131' does not exist, using 'random'` fallback warnings.
Select via `SEARCH_PROVIDER` env var, with automatic failover to the secondary provider.
**2.5 Rank, deduplicate, and date-filter results.** Deduplicate by registrable domain, prefer results with a parseable publication date, and for temporally-marked queries sort recent-first. Drop results whose snippet is dominated by hedging/rumour markers (`rumor`, `speculation`, `AI-generated`, `blind item`, `fake`) when better-scored alternatives exist — this is what would have suppressed the Reproduction 2 run-1 poisoning.
**2.6 Inject results as a delimited, numbered, dated context block** — not `f"Search Results: {blob}"`:
```
[1] "Taylor Swift and Travis Kelce Are Married" — people.com — 2026-07-03
Taylor Swift and Travis Kelce, both 36, married on Friday, July 3 at
Madison Square Garden in New York City.
[2] ...
```
Carry the source list alongside the generation so it can be emitted as citations (2.8) rather than being reconstructed from the model's prose.
**2.7 Add a grounded-answer system prompt.** Extend `services/assistant_identity.py` with a retrieval-specific instruction: answer only from the numbered sources; cite the source index inline; if the sources do not settle the question, say so explicitly rather than filling the gap from memory; prefer the most recent source on conflict; never state a date or number that does not appear in the sources.
**2.8 Stream citations to the client.** Emit a structured citations frame in the WebSocket protocol after `END_OF_THE_STREAM_ENDER_GAME_42`, carrying `[{index, title, url, published_at}]`, and persist it on the assistant `Prompt` row so citations survive a page reload. Coordinate the frame shape with the frontend progress work (`chat_web_app` progress-feedback issue) — both add frames to the same stream and should land together.
**2.9 Make search failure visible.** When retrieval was required but every provider failed, do **not** silently fall through to parametric generation. Either surface a non-fatal notice alongside a clearly-hedged answer, or return an explicit "couldn't reach live sources" state. Log the failure with the query at `WARNING`.
### Phase 3 — Context window and prompt hygiene
**3.1 Make `num_ctx` configurable per role** (`OLLAMA_NUM_CTX_THINKING`, `OLLAMA_NUM_CTX_FAST`), defaulting to at least 16384 for the THINKING path. Remove the hard-coded `4096` in `llm_service.py:25`.
**3.2 Stop double-rendering history.** `{context}` and `{recent_history}` currently serialise overlapping message sets. Send one windowed history. Fix the "Last 3 messages" / `[-6:]` mismatch.
**3.3 Budget the prompt.** Compute an approximate token budget and trim *oldest history first*, never the system prompt and never the retrieved-source block. Today truncation eats exactly the parts that make the answer correct.
**3.4 Lower sampling temperature on factual turns.** When the grounding decider says `needs_retrieval=true`, generate at `temperature<=0.3`. `0.7` on a factual lookup is a large part of the run-to-run variance in Reproduction 1.
### Phase 4 — Evaluation harness
**4.1 Add `llm_be/chat_backend/evals/`** with a YAML/JSON set of at least 40 graded questions: post-cutoff facts (the Taylor Swift case included verbatim), stable facts that must *not* regress, questions the system should refuse or hedge, RAG questions against fixture documents, and multi-turn follow-ups.
**4.2 Add a `run_evals` management command** that executes the suite N times (default 3) per question against a configurable model and reports **accuracy**, **self-consistency** (identical verdict across runs), **citation coverage**, **hallucinated-fact rate**, and **p50/p95 latency**.
**4.3 Wire it into `.gitea/workflows/` as a manually-triggered job** — it needs the GPU host, so it should not gate every PR, but it must be one click before any model or prompt change ships.
---
## Acceptance criteria
### Accuracy and grounding
- [ ] The exact prompt `"did Taylor Swift get married"` returns the correct answer (married Travis Kelce, 2026-07-03, Madison Square Garden) on **10 out of 10** consecutive runs, in **both** FAST and THINKING modes.
- [ ] The same prompt returns the correct answer with at least one search provider forced offline, exercising failover.
- [ ] With **all** search providers forced offline, the system explicitly states it could not reach live sources. It must **not** answer from parametric memory, and must never produce the fabricated "married Joe Alwyn on March 18, 2023" class of response.
- [ ] Eval suite accuracy on post-cutoff factual questions is **>= 90%**, up from the current measured baseline (record the baseline on this ticket before changing anything).
- [ ] Eval suite self-consistency is **>= 95%** across 3 runs per question.
- [ ] Stable-fact questions show **no regression** against baseline.
### Retrieval behaviour
- [ ] Grounding is evaluated on every turn; `PromptType.SEARCH` is no longer the sole trigger.
- [ ] `FAST` mode never disables retrieval. Verified by test asserting the removed bypass.
- [ ] The grounding decider fails **open** — a timeout, exception, or unparseable response results in retrieval running. Covered by unit test.
- [ ] Temporal-marker pre-pass forces retrieval regardless of model output. Covered by unit test over a table of phrasings.
- [ ] Search results reach the prompt as structured, numbered, dated, delimited entries with URLs — never as a single concatenated string. Asserted on the rendered prompt.
- [ ] Provider failover is covered by a test that makes the primary raise and asserts the secondary is used.
- [ ] Retrieval failure after all providers is surfaced to the user, not swallowed.
### Citations
- [ ] Every answer produced from retrieval carries at least one citation with a resolvable URL.
- [ ] Citations are emitted as a structured WebSocket frame and persisted on the assistant `Prompt`, surviving reload.
- [ ] RAG answers cite source documents using the existing `source` metadata.
### Models and configuration
- [ ] `OLLAMA_MODEL_THINKING`, `OLLAMA_MODEL_FAST`, `OLLAMA_MODEL_UTILITY`, `OLLAMA_EMBED_MODEL` are independently configurable and documented in `.env.example`, `.env.prod.example`, and `README.md`.
- [ ] `OLLAMA_EMBED_MODEL` defaults to `nomic-embed-text` and **never** silently falls back to a chat model.
- [ ] Startup fails loudly if the persisted Chroma collection dimensionality does not match the configured embedding model.
- [ ] `reindex_embeddings` re-ingests all active documents with metadata preserved, verified against a fixture workspace.
- [ ] Dev and prod resolve to the same model defaults; the `DEBUG`-conditional default is removed.
- [ ] VRAM headroom on 10.0.0.128 is measured and recorded on this ticket for the chosen THINKING + FAST + embedding combination.
- [ ] `PromptMetric.model_name` records the model actually used, in both consumers. No hard-coded `"llama3.2"` remains.
### Context handling
- [ ] `num_ctx` is configurable per role and defaults to >= 16384 for THINKING.
- [ ] Conversation history is serialised into the prompt exactly once.
- [ ] Under prompt-budget pressure, oldest history is dropped first; the system prompt and retrieved-source block are never truncated. Covered by a test with an oversized history.
- [ ] Factual (retrieval-backed) turns generate at `temperature <= 0.3`.
### Tooling
- [ ] `run_evals` reports accuracy, self-consistency, citation coverage, hallucinated-fact rate, and p50/p95 latency.
- [ ] The eval suite contains >= 40 questions across all five listed categories.
- [ ] A manually-triggered Gitea workflow runs the suite against the GPU host.
- [ ] Existing offline unit tests still pass without a live Ollama or network (extend the `SKIP_RAG_INIT` pattern to search).
---
## Testing notes
Measurements already taken against `http://10.0.0.128:11434` on 2026-08-02, for reference when building the baseline:
- **Prompt classifier is not the bottleneck.** `llama3.2` at `temperature=0.1` returned `SEARCH` on **15/15** runs across three phrasings of the Taylor Swift question. Classification is stable and correct; the failure is entirely downstream. Keeping the small model for utility roles is safe.
- **Grounded generation is not the bottleneck either.** Four end-to-end runs with a fresh successful search all produced the correct answer.
- **The failure is the ungrounded path and the unstructured blob.** See Reproductions 1 and 2.
---
## Out of scope
- Multi-step agentic tool loops — separate ticket.
- Frontend rendering of citations beyond agreeing the WebSocket frame shape — see the `chat_web_app` progress-feedback issue.
- Broader file-format parsing — see the file-parsing coverage issue.
Frontend citation rendering ticket: chat_web_app#98 — Sources list + clickable [n] for the WS/persisted citations frame from this epic.
Also related: activity frames chat_web_app#96 (same versioned envelope; citations out of scope there).
Frontend citation rendering ticket: [chat_web_app#98](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/98) — Sources list + clickable `[n]` for the WS/persisted citations frame from this epic.
Also related: activity frames [chat_web_app#96](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/96) (same versioned envelope; citations out of scope there).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Priority
P0 — highest priority. Accuracy is the top concern for this cycle. Ship this before the agentic work (see the agentic execution ticket).
Problem
The same factual question returns different — and often fabricated — answers on repeated asks. Reproduced end-to-end against the production Ollama host (
http://10.0.0.128:11434) with the production model.Reproduction 1 — no-search path fabricates, inconsistently
Prompt
"did Taylor Swift get married"sent straight tollama3.2with the production sampling params (temperature=0.7, top_k=50, top_p=0.9, repeat_penalty=1.1, num_ctx=4096), three consecutive runs:This is exactly the reported "three different answers, one correct" behaviour. Run 1 invents a wedding that never happened, with a specific date, stated confidently.
Reproduction 2 — search results are non-deterministic and sometimes poisoned
DuckDuckGoSearchRun().run("did Taylor Swift get married"), three consecutive calls, returned three materially different blobs:The blob has no URLs, no per-result delimiters, no publication dates, and no ranking. It is injected verbatim as
HumanMessage(content=f"Search Results: {search_results}").Reproduction 3 — when search does land cleanly, answers are correct and stable
Four end-to-end runs through the real
AsyncLLMServicetemplate with a fresh search each time all produced the correct answer (married Travis Kelce, July 3, Madison Square Garden). Grounding works. The problem is that grounding is optional, silent when it fails, and unstructured when it succeeds.Root causes
1. Search is gated behind three independent conditions, any of which silently disables it
llm_be/chat_backend/consumers.py:481-497(and the mirror inconsumers_graph.py:280-294):Search runs only if all of: classifier returns
SEARCH, andmodelName != "FAST", andALLOW_INTERNET_ACCESS. Any miss falls through to Reproduction 1. Theexceptswallows failures with no user-visible signal, so a DuckDuckGo rate-limit degrades straight to confident hallucination.2. Production runs the smallest model on the box, for everything
Production secrets:
llama3.2:latestis 3.2B params, Q4_K_M, 2.0 GB. It backs every LLM call: chat generation, moderation, prompt classification, title generation, RAG synthesis, and data analysis.Meanwhile
settings.py:154-157defaults togpt-oss:20bwhenDEBUGis on. Development runs a 20B thinking model; production runs a 3B model. Nothing that works in dev is validated against what users actually hit.Available on the GPU host today (
/api/tags):gpt-oss:20bllama3.3:latestgemma4:latestqwen2.5-coder:7bllama3.2:latestnomic-embed-textembeddinggemma3. A causal LM is being used as the embedding model
OLLAMA_EMBED_MODEL=llama3.2means RAG embeddings come from mean-pooled hidden states of a chat model, while two purpose-built embedding models (nomic-embed-text,embeddinggemma) sit unused on the same host. This degrades every retrieval in the RAG path.settings.py:158makes this the silent default:OLLAMA_EMBED_MODEL = env("OLLAMA_EMBED_MODEL", OLLAMA_MODEL).4.
num_ctx=4096hard-coded, and history is rendered into the prompt twiceservices/llm_service.py:19-26pinsnum_ctx=4096even thoughllama3.2andgpt-oss:20bboth advertise 131072.Worse,
_setup_chain(llm_service.py:92-123) fills{context}from_format_history(conversation)(all messages) and{recent_history}from_get_recent_messages(conversation[-6:]). The search blob is a member ofmessages, so it is serialised into the prompt twice. On a long conversation this overflows 4096 tokens and Ollama truncates from the front — dropping the system prompt and the search block while keeping the trailing question.Secondary: the template says "Last 3 messages" but slices
[-6:].5. No citations anywhere
Neither the web-search path nor the RAG path attaches source URLs, titles, or dates. Users cannot tell a grounded answer from a fabricated one — which is precisely how Reproduction 1 run 1 slips through.
6. Metrics cannot distinguish models
consumers.py:55andconsumers_graph.py:46hard-codemodel_name="llama3.2"onPromptMetric. Any model change is invisible in analytics, so we cannot measure whether this ticket worked.Proposed implementation
Phase 1 — Configuration and model routing
1.1 Split model configuration by role. Replace the single
OLLAMA_MODELwith role-scoped settings inllm_be/llm_be/settings.py, each independently overridable:Keep
OLLAMA_MODELhonoured as a fallback for all four so existing deploys do not break. Remove theDEBUG-conditional default atsettings.py:154-157so dev and prod resolve identically unless explicitly overridden.Rationale for
OLLAMA_MODEL_UTILITY=llama3.2: classification was measured stable on the small model (see Testing below), and keeping it small preserves latency on the hot path.1.2 Confirm VRAM headroom on 10.0.0.128 before promoting
gpt-oss:20b(13.8 GB) as the THINKING default, accounting for a concurrently loaded FAST model and the embedding model. If headroom is tight, setOLLAMA_KEEP_ALIVEand stagger, or fall back togemma4:latestfor THINKING. Record the measured numbers on this ticket.1.3 Migrate embeddings to
nomic-embed-text. This changes vector dimensionality, so existing Chroma collections are invalid. Ship a management commandreindex_embeddingsthat recreates the collection and re-ingests every activeDocument, preserving theworkspace_id/company_id/document_id/active/sourcemetadata written inrag_services.py:118-124. Refuse to start (loud error, not a silent fallback) if the persisted collection dimensionality does not match the configured embedding model.1.4 Thread the real model name into metrics. Replace the hard-coded
"llama3.2"atconsumers.py:55andconsumers_graph.py:46with the model actually resolved for the request. Without this, none of the acceptance criteria below are measurable.Phase 2 — Always-on grounded retrieval
2.1 Invert the default: retrieval is on unless proven unnecessary. Replace the
PromptType.SEARCH-only trigger with an explicit groundedness decision that runs for every turn. A query needs retrieval unless it is self-contained — creative writing, math, code, chit-chat, or a pure follow-up on text already in the conversation.Implement as a small dedicated
GroundingDecideronOLLAMA_MODEL_UTILITYattemperature=0.0, returning strict JSON:Bias the prompt explicitly toward retrieval: any question about a person, organisation, product, price, event, date, statistic, or anything post-training-cutoff must set
needs_retrieval=true. Add a deterministic pre-pass that forcestrueon temporal markers (latest,current,today,now,this year,did … yet, a year >= training cutoff) so the model cannot veto obvious cases. On parse failure or timeout, fail open to retrieval — the expensive error is skipping search, not running it.2.2 Remove the
FASTsearch bypass. FAST must select a smaller/faster model, not disable grounding. This directly fixes "Flash mode can't answer because it was trained before it happened." Delete theif input_dict.get("model_name") == "FAST": passbranch in both consumers.2.3 Generate multiple focused search queries rather than passing the raw user message to the search engine.
queriesfrom 2.1, capped at 3, executed concurrently.2.4 Replace
DuckDuckGoSearchRunwith a structured, pluggable search layer. Introduceservices/search/with aSearchProviderprotocol returning structured results —title,url,snippet,published_at,rank— never a flat string. Implement:DDGSProvider— useddgsdirectly (already a dependency,pyproject.toml:44) so we get per-result fields instead of the concatenated blobDuckDuckGoSearchRunproduces.SearxNGProvider— self-hosted, no rate limit, deterministic. Recommended as the production default given the observed DDG variance and theImpersonate 'edge_131' does not exist, using 'random'fallback warnings.Select via
SEARCH_PROVIDERenv var, with automatic failover to the secondary provider.2.5 Rank, deduplicate, and date-filter results. Deduplicate by registrable domain, prefer results with a parseable publication date, and for temporally-marked queries sort recent-first. Drop results whose snippet is dominated by hedging/rumour markers (
rumor,speculation,AI-generated,blind item,fake) when better-scored alternatives exist — this is what would have suppressed the Reproduction 2 run-1 poisoning.2.6 Inject results as a delimited, numbered, dated context block — not
f"Search Results: {blob}":Carry the source list alongside the generation so it can be emitted as citations (2.8) rather than being reconstructed from the model's prose.
2.7 Add a grounded-answer system prompt. Extend
services/assistant_identity.pywith a retrieval-specific instruction: answer only from the numbered sources; cite the source index inline; if the sources do not settle the question, say so explicitly rather than filling the gap from memory; prefer the most recent source on conflict; never state a date or number that does not appear in the sources.2.8 Stream citations to the client. Emit a structured citations frame in the WebSocket protocol after
END_OF_THE_STREAM_ENDER_GAME_42, carrying[{index, title, url, published_at}], and persist it on the assistantPromptrow so citations survive a page reload. Coordinate the frame shape with the frontend progress work (chat_web_appprogress-feedback issue) — both add frames to the same stream and should land together.2.9 Make search failure visible. When retrieval was required but every provider failed, do not silently fall through to parametric generation. Either surface a non-fatal notice alongside a clearly-hedged answer, or return an explicit "couldn't reach live sources" state. Log the failure with the query at
WARNING.Phase 3 — Context window and prompt hygiene
3.1 Make
num_ctxconfigurable per role (OLLAMA_NUM_CTX_THINKING,OLLAMA_NUM_CTX_FAST), defaulting to at least 16384 for the THINKING path. Remove the hard-coded4096inllm_service.py:25.3.2 Stop double-rendering history.
{context}and{recent_history}currently serialise overlapping message sets. Send one windowed history. Fix the "Last 3 messages" /[-6:]mismatch.3.3 Budget the prompt. Compute an approximate token budget and trim oldest history first, never the system prompt and never the retrieved-source block. Today truncation eats exactly the parts that make the answer correct.
3.4 Lower sampling temperature on factual turns. When the grounding decider says
needs_retrieval=true, generate attemperature<=0.3.0.7on a factual lookup is a large part of the run-to-run variance in Reproduction 1.Phase 4 — Evaluation harness
4.1 Add
llm_be/chat_backend/evals/with a YAML/JSON set of at least 40 graded questions: post-cutoff facts (the Taylor Swift case included verbatim), stable facts that must not regress, questions the system should refuse or hedge, RAG questions against fixture documents, and multi-turn follow-ups.4.2 Add a
run_evalsmanagement command that executes the suite N times (default 3) per question against a configurable model and reports accuracy, self-consistency (identical verdict across runs), citation coverage, hallucinated-fact rate, and p50/p95 latency.4.3 Wire it into
.gitea/workflows/as a manually-triggered job — it needs the GPU host, so it should not gate every PR, but it must be one click before any model or prompt change ships.Acceptance criteria
Accuracy and grounding
"did Taylor Swift get married"returns the correct answer (married Travis Kelce, 2026-07-03, Madison Square Garden) on 10 out of 10 consecutive runs, in both FAST and THINKING modes.Retrieval behaviour
PromptType.SEARCHis no longer the sole trigger.FASTmode never disables retrieval. Verified by test asserting the removed bypass.Citations
Prompt, surviving reload.sourcemetadata.Models and configuration
OLLAMA_MODEL_THINKING,OLLAMA_MODEL_FAST,OLLAMA_MODEL_UTILITY,OLLAMA_EMBED_MODELare independently configurable and documented in.env.example,.env.prod.example, andREADME.md.OLLAMA_EMBED_MODELdefaults tonomic-embed-textand never silently falls back to a chat model.reindex_embeddingsre-ingests all active documents with metadata preserved, verified against a fixture workspace.DEBUG-conditional default is removed.PromptMetric.model_namerecords the model actually used, in both consumers. No hard-coded"llama3.2"remains.Context handling
num_ctxis configurable per role and defaults to >= 16384 for THINKING.temperature <= 0.3.Tooling
run_evalsreports accuracy, self-consistency, citation coverage, hallucinated-fact rate, and p50/p95 latency.SKIP_RAG_INITpattern to search).Testing notes
Measurements already taken against
http://10.0.0.128:11434on 2026-08-02, for reference when building the baseline:llama3.2attemperature=0.1returnedSEARCHon 15/15 runs across three phrasings of the Taylor Swift question. Classification is stable and correct; the failure is entirely downstream. Keeping the small model for utility roles is safe.Out of scope
chat_web_appprogress-feedback issue.Frontend citation rendering ticket: chat_web_app#98 — Sources list + clickable
[n]for the WS/persisted citations frame from this epic.Also related: activity frames chat_web_app#96 (same versioned envelope; citations out of scope there).