Fix grounding: qwen2.5:1.5b + embedding re-rank + topic-relevance prompt
Browse filesThe 0.5b model hallucinated on questions whose answer isn't the top BM25 hit
(e.g. invented a "car importation tax" figure, parroted excerpt headers). Fixes:
- Re-enable embedding re-rank (auto) — bridges lexical gaps (car tax -> excise/
customs); chunks pre-warmed in a background thread so queries stay fast.
- qwen2.5:1.5b: grounds reliably where 1b/0.5b hallucinated (e.g. deficit).
- Prompt now requires same-subject excerpts and to decline (not invent) when the
topic isn't covered. Verified: car tax correctly declines; maternity/revenue/
deficit/tobacco/motor-vehicle measures all correct.
- Restore recall (top_k 4, budget 4000, num_ctx 4096); EMBED_MODEL pulled at boot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Dockerfile +2 -2
- README.md +1 -1
- app/ollama_client.py +9 -5
- app/rag.py +3 -1
- app/retrieval.py +16 -0
- config.yaml +4 -4
Dockerfile
CHANGED
|
@@ -20,9 +20,9 @@ ENV HOME=/home/user \
|
|
| 20 |
HOST=0.0.0.0 \
|
| 21 |
PORT=7860 \
|
| 22 |
OLLAMA_URL=http://localhost:11434 \
|
| 23 |
-
OLLAMA_MODEL=qwen2.5:
|
|
|
|
| 24 |
INDEX_PATH=/tmp/mru_budget_index.parquet \
|
| 25 |
-
RERANK=off \
|
| 26 |
OMP_NUM_THREADS=2 \
|
| 27 |
OLLAMA_NUM_PARALLEL=1
|
| 28 |
|
|
|
|
| 20 |
HOST=0.0.0.0 \
|
| 21 |
PORT=7860 \
|
| 22 |
OLLAMA_URL=http://localhost:11434 \
|
| 23 |
+
OLLAMA_MODEL=qwen2.5:1.5b \
|
| 24 |
+
EMBED_MODEL=nomic-embed-text \
|
| 25 |
INDEX_PATH=/tmp/mru_budget_index.parquet \
|
|
|
|
| 26 |
OMP_NUM_THREADS=2 \
|
| 27 |
OLLAMA_NUM_PARALLEL=1
|
| 28 |
|
README.md
CHANGED
|
@@ -34,7 +34,7 @@ embeddings** for retrieval, **Ollama** for the LLM, and **Gradio** for the UI.
|
|
| 34 |
Requires [Ollama](https://ollama.com) running on your machine.
|
| 35 |
|
| 36 |
```bash
|
| 37 |
-
ollama pull qwen2.5:
|
| 38 |
ollama pull nomic-embed-text # optional: enables embedding re-rank
|
| 39 |
pip install -r requirements.txt
|
| 40 |
python main.py # serves http://127.0.0.1:7860
|
|
|
|
| 34 |
Requires [Ollama](https://ollama.com) running on your machine.
|
| 35 |
|
| 36 |
```bash
|
| 37 |
+
ollama pull qwen2.5:1.5b # the answer model (any chat model works)
|
| 38 |
ollama pull nomic-embed-text # optional: enables embedding re-rank
|
| 39 |
pip install -r requirements.txt
|
| 40 |
python main.py # serves http://127.0.0.1:7860
|
app/ollama_client.py
CHANGED
|
@@ -38,12 +38,16 @@ def _extra() -> dict:
|
|
| 38 |
|
| 39 |
_SYSTEM = (
|
| 40 |
"You are a precise analyst of the Republic of Mauritius National Budget 2026-2027. "
|
| 41 |
-
"Answer the user's question using ONLY the budget excerpts in the latest message. "
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
|
|
|
|
|
|
| 45 |
"Use earlier conversation turns to resolve follow-up questions (e.g. 'how does it change' "
|
| 46 |
-
"refers to the topic just discussed). If
|
|
|
|
|
|
|
| 47 |
)
|
| 48 |
|
| 49 |
|
|
|
|
| 38 |
|
| 39 |
_SYSTEM = (
|
| 40 |
"You are a precise analyst of the Republic of Mauritius National Budget 2026-2027. "
|
| 41 |
+
"Answer the user's question using ONLY the numbered budget excerpts in the latest message. "
|
| 42 |
+
"Before using an excerpt, check it is about the SAME subject as the question — e.g. do NOT answer "
|
| 43 |
+
"a question about cars or vehicles using an excerpt about plastic bottles or sugar. "
|
| 44 |
+
"Cite the excerpts you actually use inline, e.g. [1]. Monetary figures are in Mauritian Rupees "
|
| 45 |
+
"(Rs) — quote them exactly with their unit (million/billion). Be concise and direct: lead with "
|
| 46 |
+
"the answer, no preamble, and do NOT repeat the excerpt headers (the 'Document: …' lines). "
|
| 47 |
"Use earlier conversation turns to resolve follow-up questions (e.g. 'how does it change' "
|
| 48 |
+
"refers to the topic just discussed). If no excerpt specifically addresses the asked subject, "
|
| 49 |
+
"reply that the budget documents provided do not appear to address it — NEVER invent, guess or "
|
| 50 |
+
"estimate a figure."
|
| 51 |
)
|
| 52 |
|
| 53 |
|
app/rag.py
CHANGED
|
@@ -13,7 +13,7 @@ from typing import Iterator
|
|
| 13 |
from app.config import CONFIG
|
| 14 |
from app.ingest import build_index, corpus_summary
|
| 15 |
from app.ollama_client import chat_stream, pick_model, warm_model
|
| 16 |
-
from app.retrieval import build_context
|
| 17 |
|
| 18 |
log = logging.getLogger("rag")
|
| 19 |
|
|
@@ -31,6 +31,8 @@ def init() -> None:
|
|
| 31 |
_STATE["summary"] = corpus_summary(df)
|
| 32 |
if CONFIG["ollama"].get("warm_on_start", True):
|
| 33 |
threading.Thread(target=warm_model, daemon=True).start()
|
|
|
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
def summary() -> list[dict]:
|
|
|
|
| 13 |
from app.config import CONFIG
|
| 14 |
from app.ingest import build_index, corpus_summary
|
| 15 |
from app.ollama_client import chat_stream, pick_model, warm_model
|
| 16 |
+
from app.retrieval import build_context, prewarm_embeddings
|
| 17 |
|
| 18 |
log = logging.getLogger("rag")
|
| 19 |
|
|
|
|
| 31 |
_STATE["summary"] = corpus_summary(df)
|
| 32 |
if CONFIG["ollama"].get("warm_on_start", True):
|
| 33 |
threading.Thread(target=warm_model, daemon=True).start()
|
| 34 |
+
# Embed all chunks in the background so re-ranked queries are fast.
|
| 35 |
+
threading.Thread(target=prewarm_embeddings, args=(df,), daemon=True).start()
|
| 36 |
|
| 37 |
|
| 38 |
def summary() -> list[dict]:
|
app/retrieval.py
CHANGED
|
@@ -65,6 +65,22 @@ def _embed_docs(texts: list, model: str) -> list:
|
|
| 65 |
return [_emb_store[k] for k in keys]
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def _rerank(question: str, rows: list, idxs: list) -> list:
|
| 69 |
import numpy as np
|
| 70 |
|
|
|
|
| 65 |
return [_emb_store[k] for k in keys]
|
| 66 |
|
| 67 |
|
| 68 |
+
def prewarm_embeddings(df: pd.DataFrame) -> None:
|
| 69 |
+
"""Embed every chunk up front (in batches) so the first re-ranked queries
|
| 70 |
+
don't pay a per-chunk embedding cost. No-op when re-rank is disabled."""
|
| 71 |
+
if not _embeddings_enabled():
|
| 72 |
+
return
|
| 73 |
+
model = _R.get("embed_model", "nomic-embed-text")
|
| 74 |
+
texts = [(r["text"][:1500] or "_") for r in df.to_dict("records")]
|
| 75 |
+
B = 64
|
| 76 |
+
try:
|
| 77 |
+
for i in range(0, len(texts), B):
|
| 78 |
+
_embed_docs(texts[i:i + B], model)
|
| 79 |
+
log.info("prewarmed %d chunk embeddings", len(texts))
|
| 80 |
+
except Exception as e:
|
| 81 |
+
log.warning("embedding prewarm stopped early: %s", e)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
def _rerank(question: str, rows: list, idxs: list) -> list:
|
| 85 |
import numpy as np
|
| 86 |
|
config.yaml
CHANGED
|
@@ -15,15 +15,15 @@ ollama:
|
|
| 15 |
model: null # null = first installed model
|
| 16 |
request_timeout: 300
|
| 17 |
keep_alive: 30m # keep the model resident between questions
|
| 18 |
-
num_ctx:
|
| 19 |
num_predict: 400 # cap answer tokens so long answers don't stall on CPU
|
| 20 |
temperature: 0 # greedy decoding: far more reliable for factual extraction
|
| 21 |
warm_on_start: true # preload the model at startup so the first answer is fast
|
| 22 |
history_turns: 4 # prior chat messages fed back for follow-up questions
|
| 23 |
|
| 24 |
retrieval:
|
| 25 |
-
top_k:
|
| 26 |
-
context_budget:
|
| 27 |
-
rerank:
|
| 28 |
embed_model: nomic-embed-text # Ollama embedding model (auto-used only if installed)
|
| 29 |
rerank_k: 30 # BM25 candidates to re-rank by embedding similarity
|
|
|
|
| 15 |
model: null # null = first installed model
|
| 16 |
request_timeout: 300
|
| 17 |
keep_alive: 30m # keep the model resident between questions
|
| 18 |
+
num_ctx: 4096 # context window: fits a few re-ranked excerpts + answer
|
| 19 |
num_predict: 400 # cap answer tokens so long answers don't stall on CPU
|
| 20 |
temperature: 0 # greedy decoding: far more reliable for factual extraction
|
| 21 |
warm_on_start: true # preload the model at startup so the first answer is fast
|
| 22 |
history_turns: 4 # prior chat messages fed back for follow-up questions
|
| 23 |
|
| 24 |
retrieval:
|
| 25 |
+
top_k: 4
|
| 26 |
+
context_budget: 4000 # chars of excerpt context handed to the model
|
| 27 |
+
rerank: auto # embedding re-rank of BM25 hits: auto | on | off (auto = on when embed model installed)
|
| 28 |
embed_model: nomic-embed-text # Ollama embedding model (auto-used only if installed)
|
| 29 |
rerank_k: 30 # BM25 candidates to re-rank by embedding similarity
|