Skip to content

Commit b45ba2b

Browse files
committed
Stabilize retrieval-grounded benchmark pipeline and cleanup artifacts
1 parent c59225c commit b45ba2b

21 files changed

Lines changed: 986 additions & 318 deletions

.gitignore

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,16 @@ evaluation/results_latest.json
6262
evaluation/results_latest.jsonl
6363
evaluation/results_latest.csv
6464
chunk_analysis_report.json
65-
reports
65+
reports
66+
67+
# Evaluation artifacts
68+
.deepeval/
69+
evaluation/.deepeval/
70+
evaluation/results/
71+
evaluation/evaluation/
72+
evaluation/benchmark_dataset.json
73+
evaluation/benchmark_corpus.json
74+
uvicorn.log
6675

6776
# Local ingestion plans and manifests
6877
scripts/ingest_corpus/*_INGESTION.md

api/routers/query.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ class QueryResponse(BaseModel):
5151
topic: str
5252
explanations: dict[str, str]
5353
cached: bool = False
54+
contexts: list[dict[str, Any]] = Field(default_factory=list)
55+
citations: list[dict[str, Any]] = Field(default_factory=list)
56+
metadata: dict[str, Any] = Field(default_factory=dict)
5457

5558

5659
def _history_prompt_specs(_topic: str, _levels: list[str], explicit_spec: Any) -> list[dict[str, Any]]:
@@ -255,6 +258,43 @@ async def query_topic(
255258
if not model_alias and isinstance(telemetry.get("model_alias"), str):
256259
model_alias = str(telemetry.get("model_alias"))
257260

261+
contexts: list[dict[str, Any]] = []
262+
seen_context_ids: set[str] = set()
263+
for telemetry in level_telemetry.values():
264+
retrieved = telemetry.get("retrieved_contexts")
265+
if not isinstance(retrieved, list):
266+
continue
267+
for item in retrieved:
268+
if not isinstance(item, dict):
269+
continue
270+
chunk_id = str(item.get("chunk_id") or item.get("id") or "")
271+
if chunk_id and chunk_id in seen_context_ids:
272+
continue
273+
if chunk_id:
274+
seen_context_ids.add(chunk_id)
275+
citation = item.get("citation") if isinstance(item.get("citation"), dict) else {}
276+
contexts.append({
277+
"doc_id": item.get("document_id"),
278+
"chunk_id": item.get("chunk_id") or item.get("id"),
279+
"text": item.get("content", ""),
280+
"score": item.get("score"),
281+
"vector_similarity": item.get("vector_similarity"),
282+
"match_source": item.get("match_source"),
283+
"source": citation.get("source_url") or citation.get("filename") or citation.get("source_tier"),
284+
"metadata": item.get("metadata") or {},
285+
"citation": citation,
286+
})
287+
citations = [
288+
{
289+
"doc_id": ctx.get("doc_id"),
290+
"chunk_id": ctx.get("chunk_id"),
291+
"source": ctx.get("source"),
292+
"score": ctx.get("score"),
293+
"metadata": ctx.get("metadata") or {},
294+
}
295+
for ctx in contexts
296+
]
297+
258298
queue_time_ms = round((time.perf_counter() - request_started) * 1000, 2)
259299
model_inference_ms = round(max(model_inference_values), 2) if model_inference_values else None
260300
log_sampled_success(
@@ -272,7 +312,20 @@ async def query_topic(
272312
sampled=True,
273313
)
274314

275-
return QueryResponse(topic=topic, explanations=explanations, cached=False)
315+
return QueryResponse(
316+
topic=topic,
317+
explanations=explanations,
318+
cached=False,
319+
contexts=contexts,
320+
citations=citations,
321+
metadata={
322+
"token_usage": token_usage,
323+
"estimated_cost_usd": round(estimated_cost_usd, 8) if has_cost else None,
324+
"model_inference_ms": model_inference_ms,
325+
"queue_time_ms": queue_time_ms,
326+
"model_alias": model_alias or mode,
327+
},
328+
)
276329

277330

278331
@router.post("/query/stream")

api/services/inference/inference.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ async def technical_mode_handler(topic: str, **kwargs: Any) -> str:
234234
query_mode="technical",
235235
)
236236
rag_context = format_rag_context(rag_results)
237+
if isinstance(kwargs.get("telemetry_sink"), dict):
238+
kwargs["telemetry_sink"]["retrieved_contexts"] = rag_results
237239
except Exception as exc:
238240
logger.error(f"technical_mode_rag_failed: {str(exc)}", request_id=kwargs.get("request_id"))
239241

@@ -434,6 +436,8 @@ async def generate_explanation(topic: str, level: str, model: str | None = None,
434436
query_mode="conceptual",
435437
)
436438
rag_context = format_rag_context(rag_results)
439+
if isinstance(kwargs.get("telemetry_sink"), dict):
440+
kwargs["telemetry_sink"]["retrieved_contexts"] = rag_results
437441
except Exception as exc:
438442
logger.error(f"learn_mode_rag_failed: {str(exc)}", request_id=kwargs.get("request_id"))
439443

api/services/rag/knowledge_retrieval.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,15 +121,19 @@ async def retrieve_context(
121121
expanded_content = await self._expand_context(source_db, cand, neighbor_window)
122122
final_context.append({
123123
"id": cand["chunk_id"],
124+
"chunk_id": cand.get("chunk_id"),
125+
"document_id": cand.get("document_id"),
124126
"content": expanded_content,
125127
"citation": {
126128
"filename": cand.get("filename"),
127129
"source_url": cand.get("source_url"),
128130
"chunk_order": cand.get("chunk_order"),
129131
"source_tier": cand.get("source_tier"),
130132
},
133+
"metadata": cand.get("metadata") or {},
131134
"score": cand.get("rrf_score"),
132135
"vector_similarity": cand.get("vector_similarity"),
136+
"match_source": cand.get("match_source"),
133137
})
134138

135139
return final_context

api/services/rag/rag_backend_router.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,5 @@ async def retrieve_context(query: str, api_key_id: str, **kwargs):
9090
]
9191
else:
9292
# Supabase/pgvector backend
93+
kwargs.setdefault("min_similarity", float(os.getenv("RAG_MIN_SIMILARITY", "0.65")))
9394
return await backend.retrieve_context(query, api_key_id, **kwargs)

evaluation/analyze_results.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import json
2+
from pathlib import Path
3+
from typing import List, Dict, Any
4+
5+
import pandas as pd
6+
7+
8+
def _safe_mean(series: pd.Series):
9+
s = pd.to_numeric(series, errors="coerce")
10+
return s.mean()
11+
12+
13+
def _coverage(series: pd.Series):
14+
s = pd.to_numeric(series, errors="coerce")
15+
return s.notna().mean()
16+
17+
18+
def _retrieved_ids(row: pd.Series, key: str) -> list[str]:
19+
contexts = row.get("contexts")
20+
if not isinstance(contexts, list):
21+
return []
22+
values = []
23+
for ctx in contexts:
24+
if isinstance(ctx, dict) and ctx.get(key):
25+
values.append(str(ctx.get(key)))
26+
return values
27+
28+
29+
def _expected_ids(row: pd.Series, key: str) -> list[str]:
30+
values = row.get(key)
31+
if isinstance(values, list):
32+
return [str(v) for v in values if v]
33+
return []
34+
35+
36+
def _recall_at_k(expected: list[str], retrieved: list[str], k: int = 5):
37+
if not expected:
38+
return None
39+
return len(set(expected) & set(retrieved[:k])) / len(set(expected))
40+
41+
42+
def _mrr(expected: list[str], retrieved: list[str]):
43+
if not expected:
44+
return None
45+
expected_set = set(expected)
46+
for idx, item in enumerate(retrieved, start=1):
47+
if item in expected_set:
48+
return 1 / idx
49+
return 0.0
50+
51+
52+
def _precision_at_k(expected: list[str], retrieved: list[str], k: int = 5):
53+
retrieved_k = [item for item in retrieved[:k] if item]
54+
if not retrieved_k:
55+
return None
56+
if not expected:
57+
return None
58+
return len(set(expected) & set(retrieved_k)) / len(set(retrieved_k))
59+
60+
61+
def _citation_ids(row: pd.Series, key: str) -> list[str]:
62+
citations = row.get("citations")
63+
if not isinstance(citations, list):
64+
return []
65+
values = []
66+
for citation in citations:
67+
if isinstance(citation, dict) and citation.get(key):
68+
values.append(str(citation.get(key)))
69+
return values
70+
71+
72+
def analyze_and_report(results: List[Dict[str, Any]], output_dir: str):
73+
Path(output_dir).mkdir(parents=True, exist_ok=True)
74+
raw_path = Path(output_dir) / "raw_results.json"
75+
with open(raw_path, "w") as f:
76+
json.dump(results, f, indent=2, default=float)
77+
if not results:
78+
return
79+
df = pd.json_normalize(results)
80+
for id_kind, expected_col, metric_prefix in [
81+
("chunk_id", "relevant_chunk_ids", "chunk"),
82+
("doc_id", "relevant_doc_ids", "doc"),
83+
]:
84+
recalls = []
85+
mrrs = []
86+
precisions = []
87+
for _, row in df.iterrows():
88+
expected = _expected_ids(row, expected_col)
89+
retrieved = _retrieved_ids(row, id_kind)
90+
recalls.append(_recall_at_k(expected, retrieved, 5))
91+
mrrs.append(_mrr(expected, retrieved))
92+
precisions.append(_precision_at_k(expected, retrieved, 5))
93+
df[f"retrieval.{metric_prefix}_recall_at_5"] = recalls
94+
df[f"retrieval.{metric_prefix}_mrr"] = mrrs
95+
df[f"retrieval.{metric_prefix}_precision_at_5"] = precisions
96+
df["retrieval.context_count"] = df.apply(lambda r: len(r.get("contexts")) if isinstance(r.get("contexts"), list) else 0, axis=1)
97+
df["retrieval.citation_grounding_accuracy"] = df.apply(
98+
lambda r: _precision_at_k(
99+
_expected_ids(r, "relevant_chunk_ids") + _expected_ids(r, "relevant_doc_ids"),
100+
_citation_ids(r, "chunk_id") + _citation_ids(r, "doc_id"),
101+
5,
102+
),
103+
axis=1,
104+
)
105+
df.to_csv(Path(output_dir) / "results.csv", index=False)
106+
107+
metrics = [
108+
"judge.depth_compliance",
109+
"judge.answer_quality",
110+
"judge.citation_accuracy",
111+
"judge.faithfulness",
112+
"deepeval.deepeval_relevancy",
113+
"deepeval.deepeval_faithfulness",
114+
"ragas.ragas_answer_relevancy",
115+
"ragas.ragas_faithfulness",
116+
"retrieval.chunk_recall_at_5",
117+
"retrieval.chunk_precision_at_5",
118+
"retrieval.chunk_mrr",
119+
"retrieval.doc_recall_at_5",
120+
"retrieval.doc_precision_at_5",
121+
"retrieval.doc_mrr",
122+
"retrieval.citation_grounding_accuracy",
123+
"retrieval.context_count",
124+
]
125+
depth = df[df["system"] == "depthapi"]
126+
base = df[df["system"] == "langchain_baseline"]
127+
128+
report = Path(output_dir) / "summary_comparison.md"
129+
with report.open("w") as f:
130+
f.write("# DepthAPI Evaluation Benchmark Report\n\n")
131+
f.write("## Overall Comparison\n\n| Metric | DepthAPI | Baseline |\n|---|---|---|\n")
132+
for m in metrics:
133+
if m in df.columns:
134+
f.write(f"| {m} | {_safe_mean(depth[m]):.4f} | {_safe_mean(base[m]):.4f} |\n")
135+
f.write("\n## Evaluator Success/Coverage\n\n")
136+
f.write("| Metric | DepthAPI Coverage | Baseline Coverage |\n|---|---|---|\n")
137+
for m in metrics:
138+
if m in df.columns:
139+
f.write(f"| {m} | {_coverage(depth[m]):.2%} | {_coverage(base[m]):.2%} |\n")
140+
141+
print(f"Reports generated in {output_dir}")

0 commit comments

Comments
 (0)