Skip to content

Commit 3b366ea

Browse files
authored
Merge pull request #11 from dk3yyyy/fix/rag-answer-diagnostics-repair
Improve RAG answer reliability and diagnostics
2 parents 1acd620 + f6e571a commit 3b366ea

6 files changed

Lines changed: 521 additions & 34 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,30 @@ uv run local-ai-agent evaluate --report-dir evaluation/results/my-run
140140
The versioned case manifest is tied to the dataset SHA-256 and uses immutable
141141
content-derived source IDs for gold relevance. The report records model tags
142142
and immutable Ollama digests, dataset and case-set hashes, retrieval limit,
143-
runtime versions, per-case RAG and BM25 rankings, and aggregate metrics.
143+
runtime versions, per-case RAG and BM25 rankings, and aggregate metrics. Each
144+
RAG observation also retains the first raw model response, any one-shot repair
145+
response, the initial and final structured validation reasons, and whether a
146+
repair was attempted. These diagnostics stay in the evaluation artifact; the
147+
CLI and dashboard continue to expose only validated answers or safe fallback
148+
messages.
149+
150+
Compare answer models without changing the embedding model or case set:
151+
152+
```bash
153+
uv run local-ai-agent evaluate \
154+
--chat-model llama3.2 \
155+
--report-dir /tmp/local-ai-agent-llama3.2
156+
157+
uv run local-ai-agent evaluate \
158+
--chat-model <installed-7b-or-8b-instruct-model> \
159+
--report-dir /tmp/local-ai-agent-stronger-model
160+
```
161+
162+
Keep the reports outside the repository so benchmark artifacts do not make the
163+
worktree dirty. Compare answer success, citation validity, abstention recall,
164+
latency, and the per-case failure reasons rather than optimizing one aggregate
165+
score.
166+
144167
Retrieval quality is reported as recall@k, hit rate@k, and MRR@k for both
145168
semantic search and the BM25 baseline. Relevance judgments are known-positive,
146169
not exhaustive. The generated report is evidence for this fixed benchmark

‎agent.py‎

Lines changed: 124 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,19 @@
1717
)
1818
CITATION_PATTERN = re.compile(r"\[([A-Za-z0-9][A-Za-z0-9_-]*)\]")
1919
INSUFFICIENT_EVIDENCE_TOKEN = "INSUFFICIENT_EVIDENCE"
20+
REPAIRABLE_FAILURE_REASONS = frozenset(
21+
{"missing_citations", "out_of_range_citation", "unknown_citation"}
22+
)
2023

2124
ANSWER_PROMPT = """You are a review analyst.
2225
Answer the question using only the supplied reviews. Do not add facts that are not present.
23-
When evidence is mixed or limited, say so clearly. Every factual claim must cite one or more
24-
retrieved evidence numbers exactly as shown, for example [1]. Never cite an evidence number
25-
that is not supplied. Source IDs are validation metadata; do not copy them into the answer.
26-
If the supplied reviews do not answer the question, reply exactly INSUFFICIENT_EVIDENCE.
26+
Always answer every part that at least one supplied review supports. Mixed or incomplete evidence is
27+
not a reason to abstain; describe the limitation and answer only the supported portion. Every
28+
factual claim must cite one or more retrieved evidence numbers exactly as shown, for example
29+
[1]. Never cite an evidence number that is not supplied. Source IDs are validation metadata;
30+
do not copy them into the answer. Reply exactly INSUFFICIENT_EVIDENCE only when no supplied
31+
review answers any part of the question. Never use INSUFFICIENT_EVIDENCE as prose or place it
32+
beside an answer.
2733
2834
Question:
2935
{question}
@@ -34,6 +40,27 @@
3440
Answer:
3541
"""
3642

43+
REPAIR_PROMPT = """You are repairing one rejected review-analysis response.
44+
Using only the supplied reviews, rewrite it once so every factual claim has one or more valid
45+
evidence citations such as [1]. Use only evidence numbers that appear below. Preserve supported
46+
meaning, remove unsupported claims, and answer every supported part even when evidence is mixed
47+
or incomplete. If no supplied review answers any part, reply exactly INSUFFICIENT_EVIDENCE.
48+
Never use INSUFFICIENT_EVIDENCE as prose or place it beside an answer.
49+
50+
Question:
51+
{question}
52+
53+
Supplied review records:
54+
{context}
55+
56+
Rejected response:
57+
<rejected_response>
58+
{rejected_response}
59+
</rejected_response>
60+
61+
Rewritten answer:
62+
"""
63+
3764

3865
@dataclass(frozen=True)
3966
class CitedReview:
@@ -48,6 +75,11 @@ class AnswerResult:
4875
sources: tuple[CitedReview, ...]
4976
retrieved_source_ids: tuple[str, ...] = ()
5077
abstained: bool = False
78+
raw_response: str = ""
79+
repair_response: str | None = None
80+
initial_failure_reason: str | None = None
81+
failure_reason: str | None = None
82+
repair_attempted: bool = False
5183

5284

5385
def create_chat_model(
@@ -99,27 +131,28 @@ def _remove_standalone_control_token(answer: str) -> str | None:
99131
def _validate_and_number_citations(
100132
answer: str,
101133
matches: list[ReviewMatch],
102-
) -> tuple[str, tuple[CitedReview, ...]] | None:
134+
) -> tuple[tuple[str, tuple[CitedReview, ...]] | None, str | None]:
103135
retrieved: dict[str, ReviewMatch] = {}
104136
evidence_aliases: dict[str, str] = {}
105137
for evidence_number, match in enumerate(matches, start=1):
106138
source_id = str(
107139
match.document.metadata.get("source_id") or match.document.id or ""
108140
)
109141
if not source_id:
110-
return None
142+
return None, "retrieved_source_missing_id"
111143
retrieved[source_id] = match
112144
evidence_aliases[str(evidence_number)] = source_id
113145

114146
cited_tokens = CITATION_PATTERN.findall(answer)
115147
if not cited_tokens:
116-
return None
148+
return None, "missing_citations"
117149

118150
resolved_ids: list[str] = []
119151
for token in cited_tokens:
120152
source_id = evidence_aliases.get(token, token)
121153
if source_id not in retrieved:
122-
return None
154+
reason = "out_of_range_citation" if token.isdigit() else "unknown_citation"
155+
return None, reason
123156
resolved_ids.append(source_id)
124157

125158
ordered_ids = list(dict.fromkeys(resolved_ids))
@@ -140,7 +173,26 @@ def _validate_and_number_citations(
140173
)
141174
for source_id in ordered_ids
142175
)
143-
return numbered_answer, sources
176+
return (numbered_answer, sources), None
177+
178+
179+
def _evaluate_model_response(
180+
response: str,
181+
matches: list[ReviewMatch],
182+
) -> tuple[tuple[str, tuple[CitedReview, ...]] | None, str | None]:
183+
normalized = response.strip()
184+
if normalized == INSUFFICIENT_EVIDENCE_TOKEN:
185+
return None, "clean_abstention"
186+
normalized_without_control = _remove_standalone_control_token(normalized)
187+
if normalized_without_control is None:
188+
return None, "embedded_control_token"
189+
if not normalized_without_control:
190+
return None, "invalid_remainder"
191+
return _validate_and_number_citations(normalized_without_control, matches)
192+
193+
194+
def _response_text(response: Any) -> str:
195+
return str(response.content if hasattr(response, "content") else response)
144196

145197

146198
def answer_question(
@@ -177,47 +229,95 @@ def answer_question(
177229
countries=countries,
178230
)
179231
if not matches:
180-
return AnswerResult(answer=NO_MATCH_MESSAGE, sources=())
232+
return AnswerResult(
233+
answer=NO_MATCH_MESSAGE,
234+
sources=(),
235+
failure_reason="empty_retrieval",
236+
)
181237

182238
retrieved_source_ids = tuple(
183239
str(match.document.metadata.get("source_id") or match.document.id or "")
184240
for match in matches
185241
)
186242
if any(not source_id for source_id in retrieved_source_ids):
187-
return AnswerResult(answer=CITATION_VALIDATION_MESSAGE, sources=())
243+
return AnswerResult(
244+
answer=CITATION_VALIDATION_MESSAGE,
245+
sources=(),
246+
failure_reason="retrieved_source_missing_id",
247+
)
188248

189249
answer_model = model or create_chat_model(model=chat_model, base_url=ollama_host)
250+
context = _format_context(matches)
190251
prompt = ANSWER_PROMPT.format(
191252
question=normalized_question,
192-
context=_format_context(matches),
253+
context=context,
193254
)
194-
response = answer_model.invoke(prompt)
195-
answer = response.content if hasattr(response, "content") else str(response)
196-
normalized_answer = answer.strip()
197-
if normalized_answer == INSUFFICIENT_EVIDENCE_TOKEN:
255+
raw_response = _response_text(answer_model.invoke(prompt))
256+
validated, failure_reason = _evaluate_model_response(raw_response, matches)
257+
if validated is not None:
258+
validated_answer, sources = validated
259+
return AnswerResult(
260+
answer=validated_answer,
261+
sources=sources,
262+
retrieved_source_ids=retrieved_source_ids,
263+
raw_response=raw_response,
264+
)
265+
if failure_reason == "clean_abstention":
198266
return AnswerResult(
199267
answer=NO_MATCH_MESSAGE,
200268
sources=(),
201269
retrieved_source_ids=retrieved_source_ids,
202270
abstained=True,
271+
raw_response=raw_response,
272+
failure_reason=failure_reason,
203273
)
204-
normalized_answer = _remove_standalone_control_token(normalized_answer)
205-
if normalized_answer is None:
274+
if failure_reason not in REPAIRABLE_FAILURE_REASONS:
206275
return AnswerResult(
207276
answer=CITATION_VALIDATION_MESSAGE,
208277
sources=(),
209278
retrieved_source_ids=retrieved_source_ids,
279+
raw_response=raw_response,
280+
failure_reason=failure_reason,
210281
)
211-
validated = _validate_and_number_citations(normalized_answer, matches)
212-
if validated is None:
282+
283+
initial_failure_reason = failure_reason
284+
repair_prompt = REPAIR_PROMPT.format(
285+
question=normalized_question,
286+
context=context,
287+
rejected_response=raw_response,
288+
)
289+
repair_response = _response_text(answer_model.invoke(repair_prompt))
290+
repaired, repair_failure_reason = _evaluate_model_response(repair_response, matches)
291+
if repaired is not None:
292+
repaired_answer, sources = repaired
213293
return AnswerResult(
214-
answer=CITATION_VALIDATION_MESSAGE,
294+
answer=repaired_answer,
295+
sources=sources,
296+
retrieved_source_ids=retrieved_source_ids,
297+
raw_response=raw_response,
298+
repair_response=repair_response,
299+
initial_failure_reason=initial_failure_reason,
300+
repair_attempted=True,
301+
)
302+
if repair_failure_reason == "clean_abstention":
303+
return AnswerResult(
304+
answer=NO_MATCH_MESSAGE,
215305
sources=(),
216306
retrieved_source_ids=retrieved_source_ids,
307+
abstained=True,
308+
raw_response=raw_response,
309+
repair_response=repair_response,
310+
initial_failure_reason=initial_failure_reason,
311+
failure_reason=repair_failure_reason,
312+
repair_attempted=True,
217313
)
218-
validated_answer, sources = validated
219314
return AnswerResult(
220-
answer=validated_answer,
221-
sources=sources,
315+
answer=CITATION_VALIDATION_MESSAGE,
316+
sources=(),
222317
retrieved_source_ids=retrieved_source_ids,
318+
raw_response=raw_response,
319+
repair_response=repair_response,
320+
initial_failure_reason=initial_failure_reason,
321+
failure_reason=repair_failure_reason,
322+
repair_attempted=True,
223323
)

0 commit comments

Comments
 (0)