Files
RAG/backend/app/tools/evaluate_demo.py
YoVinchen ecdb10c37a
Some checks failed
verify / verify (push) Has been cancelled
Make the governed RAG evidence path executable end to end
Separate local parsing from model indexing, bind review decisions to immutable manifests, persist vectors behind active profiles, and expose retrieval, chat, evaluation, and document workflows through the React workbench.

Constraint: Live Bailian authentication currently fails for all three configured capabilities

Rejected: Direct upload-to-embedding flow | bypasses local review and manifest binding

Confidence: high

Scope-risk: broad

Directive: Keep private-data deployment blocked until authentication, RBAC, and separate database roles land

Tested: make verify; fresh and replay Docker document smoke; worker recovery smoke; frozen synthetic evaluation; migration 0003-0004 roundtrip

Not-tested: Successful live Bailian calls, OCR, real multi-user authorization
2026-07-13 05:58:11 +08:00

214 lines
7.1 KiB
Python

"""Run a reproducible retrieval evaluation on the public synthetic corpus."""
from __future__ import annotations
import asyncio
import hashlib
import json
import sys
import uuid
from dataclasses import asdict
from pathlib import Path
from typing import Any, Protocol
from app.adapters.fake import FakeEmbeddingProvider, FakeReranker
from app.core.config import Settings
from app.core.demo_identity import ACCESS_SCOPE_ID, KNOWLEDGE_BASE_ID
from app.persistence.retrieval import PostgresRetrievalRepository
from app.services.evaluation import (
RankingMetrics,
bootstrap_mean_confidence_interval,
evaluate_ranking,
freeze_run_config,
)
from app.services.retrieval import RetrievalActor, RetrievalGrant, RetrievalResult, RetrievalService
from app.tools.seed_demo import (
DEFAULT_SAMPLE_ROOT,
DemoDocument,
DemoQuery,
load_documents,
load_queries,
)
def _sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _source_id(source_name: str) -> str:
return Path(source_name).stem
def _mean(values: list[float]) -> float:
return sum(values) / len(values) if values else 0.0
class DemoRetrievalService(Protocol):
async def search(
self,
*,
actor: RetrievalActor,
knowledge_base_id: uuid.UUID,
query: str,
vector_top_k: int,
rerank_top_n: int,
) -> RetrievalResult: ...
async def evaluate_demo_queries(
*,
service: DemoRetrievalService,
actor: RetrievalActor,
documents: list[DemoDocument],
queries: list[DemoQuery],
vector_top_k: int = 20,
rerank_top_n: int = 10,
metric_cutoff: int = 3,
) -> dict[str, Any]:
"""Evaluate answerable queries with a fully judged synthetic corpus pool."""
corpus_ids = frozenset(document.source_id for document in documents)
if len(corpus_ids) != len(documents):
raise ValueError("synthetic corpus document IDs must be unique")
cases: list[dict[str, Any]] = []
scored: list[RankingMetrics] = []
active_profile_hash: str | None = None
for query in queries:
result = await service.search(
actor=actor,
knowledge_base_id=KNOWLEDGE_BASE_ID,
query=query.query,
vector_top_k=vector_top_k,
rerank_top_n=rerank_top_n,
)
if active_profile_hash is None:
active_profile_hash = result.profile.profile_hash
elif active_profile_hash != result.profile.profile_hash:
raise ValueError("active profile changed during evaluation")
ranked_ids = [_source_id(hit.source_name) for hit in result.results]
case: dict[str, Any] = {
"qid": query.qid,
"answerable": query.answerable,
"ranked_document_ids": ranked_ids,
"retrieval_status": result.status,
"rerank_status": result.rerank_status,
}
if query.answerable:
relevance = {document_id: 0.0 for document_id in corpus_ids}
for expected_id in query.expected_doc_ids:
if expected_id not in corpus_ids:
raise ValueError("expected document is outside the corpus manifest")
relevance[expected_id] = 1.0
groups = tuple(frozenset({expected_id}) for expected_id in query.expected_doc_ids)
metrics = evaluate_ranking(
ranked_ids,
relevance=relevance,
judged_document_ids=corpus_ids,
evidence_groups=groups,
k=metric_cutoff,
)
scored.append(metrics)
case["metrics"] = asdict(metrics)
else:
case["metrics"] = None
cases.append(case)
if active_profile_hash is None:
raise ValueError("evaluation query set is empty")
hit_values = [metric.hit_at_k for metric in scored]
hit_ci = bootstrap_mean_confidence_interval(
hit_values,
seed=20260713,
iterations=2_000,
)
return {
"status": "ok",
"dataset": "synthetic-demo",
"case_count": len(queries),
"answerable_case_count": len(scored),
"active_embedding_profile_hash": active_profile_hash,
"metrics": {
f"hit_at_{metric_cutoff}": _mean(hit_values),
"mrr": _mean([metric.reciprocal_rank for metric in scored]),
f"ndcg_at_{metric_cutoff}": _mean([metric.ndcg_at_k for metric in scored]),
f"complete_hit_at_{metric_cutoff}": _mean(
[metric.complete_hit_at_k for metric in scored]
),
f"evidence_group_recall_at_{metric_cutoff}": _mean(
[metric.evidence_group_recall_at_k for metric in scored]
),
f"hit_at_{metric_cutoff}_confidence_interval": asdict(hit_ci),
},
"cases": cases,
}
async def async_main() -> int:
document_path = Path(
sys.argv[1] if len(sys.argv) > 1 else DEFAULT_SAMPLE_ROOT / "demo_documents.jsonl"
)
query_path = Path(
sys.argv[2] if len(sys.argv) > 2 else DEFAULT_SAMPLE_ROOT / "demo_queries.jsonl"
)
try:
settings = Settings()
documents = load_documents(document_path)
queries = load_queries(query_path)
service = RetrievalService(
repository=PostgresRetrievalRepository(settings),
embedding_provider=FakeEmbeddingProvider(settings.embedding_dimension),
reranker=FakeReranker(),
synthetic_embedding_provider=FakeEmbeddingProvider(settings.embedding_dimension),
synthetic_reranker=FakeReranker(),
)
actor = RetrievalActor(
subject="synthetic-evaluation-runner",
grants=(
RetrievalGrant(
knowledge_base_id=KNOWLEDGE_BASE_ID,
access_scope_ids=(ACCESS_SCOPE_ID,),
),
),
)
artifact = await evaluate_demo_queries(
service=service,
actor=actor,
documents=documents,
queries=queries,
)
config, config_hash = freeze_run_config(
{
"corpus_sha256": _sha256_file(document_path),
"query_set_sha256": _sha256_file(query_path),
"embedding_profile_hash": artifact["active_embedding_profile_hash"],
"vector_top_k": 20,
"rerank_top_n": 10,
"metric_cutoff": 3,
"bootstrap_seed": 20260713,
}
)
artifact["frozen_config"] = json.loads(config)
artifact["frozen_config_sha256"] = config_hash
sys.stdout.write(json.dumps(artifact, ensure_ascii=False, sort_keys=True) + "\n")
return 0
except Exception:
# CLI output remains fixed and does not echo paths, document text, DSNs, or errors.
sys.stdout.write(
json.dumps(
{"status": "failed", "error_kind": "evaluation_failed"},
sort_keys=True,
)
+ "\n"
)
return 1
def main() -> None:
raise SystemExit(asyncio.run(async_main()))
if __name__ == "__main__":
main()