Initial commit: QMDSearch 分层信息检索服务

- FastAPI + Qdrant + Redis + Ollama 技术栈
- L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合)
- 文档三级总结与 2.5 级回退
- query 解析路由与分类
- /admin 管理页面
This commit is contained in:
2026-07-29 21:24:40 +08:00
commit 51dc8dc4f6
83 changed files with 10794 additions and 0 deletions
+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env python3
"""分层摘要 RAG 离线评测 harness
流程:回归集 → 独立 _eval 后缀集合入库(Ingester)→ 摘要质量指标(Entity Recall /
幻觉率 / 类目一致性)→ 检索效用指标(Routing F1 / Pruning Loss / Precision@5 /
Recall@10)→ 平铺 chunks baseline 对比 → Markdown 报告(stdout + report.md)。
用法:
uv run python scripts/eval/run_eval.py [--regression PATH] [--keep-data] [--no-judge]
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from pathlib import Path
# 脚本直运行时把项目根加入 sys.path,保证可以 import app 与 scripts.eval
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
import structlog # noqa: E402
from app.config import settings # noqa: E402
from app.core import ingestion as ingestion_mod # noqa: E402
from app.core import retriever as retriever_mod # noqa: E402
from app.core.ingestion import Ingester # noqa: E402
from app.core.retriever import Retriever # noqa: E402
from app.models.document import DocumentInput # noqa: E402
from app.models.search import SearchRequest # noqa: E402
from app.services import qdrant as qdrant_mod # noqa: E402
from app.services.ollama import OllamaClient # noqa: E402
from app.services.qdrant import QdrantService # noqa: E402
from scripts.eval.judge import hallucination_rate, taxonomy_consistency # noqa: E402
from scripts.eval.metrics import ( # noqa: E402
aggregate,
entity_recall,
precision_at_k,
pruning_loss,
recall_at_k,
routing_f1,
)
logger = structlog.get_logger()
# 评测集合后缀与默认路径
EVAL_SUFFIX = "_eval"
_SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_REGRESSION = _SCRIPT_DIR / "regression_set.json"
REPORT_PATH = _SCRIPT_DIR / "report.md"
# 门槛(Spec 规定):低于/高于门槛在报告中标红
THRESHOLD_L1_ER = 0.85 # L1 Entity Recall ≥ 0.85
THRESHOLD_L3_ER = 0.9 # L3 Entity Recall ≥ 0.9
THRESHOLD_HALLUCINATION = 0.02 # 幻觉率 < 2%
THRESHOLD_PRUNING_LOSS = 0.08 # Pruning Loss < 8%
def _switch_to_eval_collections() -> list[str]:
"""把 app 内模块级集合名常量整体切换为 _eval 后缀的评测集合
QdrantService / Retriever / Ingester 均在各自模块命名空间引用了集合名常量,
评测脚本统一改写这些模块属性实现集合隔离,不改动 app 源码。
返回评测集合名列表(用于评测结束后清理)。
"""
eval_names = {
"COLLECTION_L1": f"{qdrant_mod.COLLECTION_L1}{EVAL_SUFFIX}",
"COLLECTION_L2": f"{qdrant_mod.COLLECTION_L2}{EVAL_SUFFIX}",
"COLLECTION_L3": f"{qdrant_mod.COLLECTION_L3}{EVAL_SUFFIX}",
"COLLECTION_CHUNKS": f"{qdrant_mod.COLLECTION_CHUNKS}{EVAL_SUFFIX}",
}
for module in (qdrant_mod, retriever_mod, ingestion_mod):
for name, value in eval_names.items():
if hasattr(module, name):
setattr(module, name, value)
qdrant_mod.ALL_COLLECTIONS = tuple(eval_names.values())
sparse_eval = (eval_names["COLLECTION_L1"], eval_names["COLLECTION_CHUNKS"])
qdrant_mod.SPARSE_COLLECTIONS = sparse_eval
retriever_mod.SPARSE_COLLECTIONS = sparse_eval
# upsert_nodes 按集合名校验层级前缀,需同步替换
qdrant_mod._NODE_ID_PREFIX = {eval_names["COLLECTION_L2"]: "l2", eval_names["COLLECTION_L3"]: "l3"}
return list(eval_names.values())
async def _check_services(qdrant: QdrantService, ollama: OllamaClient) -> str | None:
"""检查 Qdrant / Ollama 连通性,返回错误消息(None 表示正常)"""
try:
await qdrant.client.get_collections()
except Exception as exc:
return f"无法连接 Qdrant{settings.qdrant_host}:{settings.qdrant_port}):{exc}"
if not await ollama.is_available():
return f"无法连接 Ollama{settings.ollama_base_url}),请确认服务已启动(入库与评测均依赖 Ollama)"
return None
async def _l1_candidate_doc_ids(retriever: Retriever, query: str) -> set[str]:
"""轻量复现 Retriever 的 L1 路由层:embed → L1 集合 top-N → 候选 doc_id 集合
用于计算 Pruning Loss 与 Routing F1;不套类目过滤,度量纯 L1 向量召回。
"""
dense = (await retriever.embedding.embed([query]))[0]
sparse = retriever.sparse_encoder.encode(query) if settings.sparse_enabled else None
# 复用 Retriever 内部检索方法(hybrid/dense 按集合能力自动选择)
hits = await retriever._search_collection(retriever_mod.COLLECTION_L1, dense, sparse, settings.l1_doc_top_n, None)
return {(p.payload or {}).get("doc_id", "") for p in hits} - {""}
async def _baseline_doc_ids(retriever: Retriever, query: str) -> list[str]:
"""平铺 baseline:直接对 chunks 集合做 hybrid top-k(无路由无剪枝),返回命中 doc_id 列表"""
dense = (await retriever.embedding.embed([query]))[0]
sparse = retriever.sparse_encoder.encode(query) if settings.sparse_enabled else None
points = await retriever._search_collection(
retriever_mod.COLLECTION_CHUNKS, dense, sparse, settings.retrieval_top_k, None
)
return [(p.payload or {}).get("doc_id", "") for p in points]
def _mark(ok: bool) -> str:
"""门槛判定标记"""
return "" if ok else ""
def _fmt(value: float | None, percent: bool = False) -> str:
"""数值格式化;None 表示未评测(judge 跳过)"""
if value is None:
return "N/A"
return f"{value:.1%}" if percent else f"{value:.4f}"
def _build_report(
regression_path: Path,
doc_records: list[dict],
query_summary: dict,
hier_metrics: dict[str, float],
baseline_metrics: dict[str, float],
routing: dict[str, float],
prune_loss: float,
judge_enabled: bool,
kept_data: bool,
) -> str:
"""组装 Markdown 评测报告"""
lines: list[str] = [
"# 分层摘要 RAG 评测报告",
"",
f"- 回归集:`{regression_path}`",
f"- 文档数:{len(doc_records)}query 数:{query_summary['total']}"
f"positive {query_summary['positive']} / negative {query_summary['negative']}",
f"- LLM judge{'开启' if judge_enabled else '跳过(--no-judge 或 Ollama 不可用)'}",
f"- 评测集合:`*{EVAL_SUFFIX}`{'保留' if kept_data else '已清理'}",
"",
"## 摘要质量(按文档)",
"",
"| 文档 | 标题 | golden 类目 | 实际类目 | L1 Entity Recall | L3 Entity Recall | 幻觉率 | 类目一致 |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
for record in doc_records:
consistency = record.get("taxonomy_consistency")
lines.append(
f"| {record['id']} | {record['title']} | {record['golden_category']} | {record['category']} "
f"| {_fmt(record['entity_recall_l1'])} | {_fmt(record['entity_recall_l3'])} "
f"| {_fmt(record.get('hallucination_rate'), percent=True)} "
f"| {('' if consistency else '') if consistency is not None else 'N/A'} |"
)
# 汇总与门槛判定
er_l1 = aggregate([{"v": r["entity_recall_l1"]} for r in doc_records]).get("v", 0.0)
er_l3 = aggregate([{"v": r["entity_recall_l3"]} for r in doc_records]).get("v", 0.0)
hall_records = [{"v": r["hallucination_rate"]} for r in doc_records if r.get("hallucination_rate") is not None]
hall = aggregate(hall_records).get("v") if hall_records else None
lines += [
"",
"## 指标汇总与门槛判定",
"",
"| 指标 | 数值 | 门槛 | 判定 |",
"| --- | --- | --- | --- |",
f"| L1 Entity Recall | {_fmt(er_l1)} | ≥ {THRESHOLD_L1_ER} | {_mark(er_l1 >= THRESHOLD_L1_ER)} |",
f"| L3 Entity Recall | {_fmt(er_l3)} | ≥ {THRESHOLD_L3_ER} | {_mark(er_l3 >= THRESHOLD_L3_ER)} |",
f"| Hallucination Rate | {_fmt(hall, percent=True)} | < {THRESHOLD_HALLUCINATION:.0%} "
f"| {_mark(hall < THRESHOLD_HALLUCINATION) if hall is not None else 'N/A'} |",
f"| Pruning Loss | {_fmt(prune_loss, percent=True)} | < {THRESHOLD_PRUNING_LOSS:.0%} "
f"| {_mark(prune_loss < THRESHOLD_PRUNING_LOSS)} |",
"",
"## 检索效用(hierarchical vs 平铺 baseline",
"",
"| 指标 | 分层检索 | 平铺 baseline |",
"| --- | --- | --- |",
f"| Precision@5 | {_fmt(hier_metrics.get('precision@5', 0.0))} "
f"| {_fmt(baseline_metrics.get('precision@5', 0.0))} |",
f"| Recall@10 | {_fmt(hier_metrics.get('recall@10', 0.0))} | {_fmt(baseline_metrics.get('recall@10', 0.0))} |",
"",
"### 路由层(L1",
"",
f"- Routing Precision{_fmt(routing['precision'])}",
f"- Routing Recall{_fmt(routing['recall'])}",
f"- Routing F1{_fmt(routing['f1'])}",
f"- Pruning Loss{_fmt(prune_loss, percent=True)}{_mark(prune_loss < THRESHOLD_PRUNING_LOSS)}",
"",
"### negative query",
"",
f"- 误中(返回了任意结果):{query_summary['negative_false_alarm']} / {query_summary['negative']}",
"",
]
return "\n".join(lines)
async def run(regression_path: Path, keep_data: bool, judge_enabled: bool) -> int:
"""评测主流程,返回进程退出码"""
data = json.loads(regression_path.read_text(encoding="utf-8"))
documents: list[dict] = data["documents"]
queries: list[dict] = data["queries"]
eval_collections = _switch_to_eval_collections()
qdrant = QdrantService()
ollama = OllamaClient()
# 连通性检查:失败给出中文提示并以退出码 2 结束
error = await _check_services(qdrant, ollama)
if error:
print(f"错误:{error}", file=sys.stderr)
return 2
await qdrant.ensure_collections()
logger.info("评测集合就绪", collections=eval_collections)
ingester = Ingester(qdrant=qdrant)
retriever = Retriever(qdrant=qdrant)
id_map: dict[str, str] = {} # 回归集 doc id -> 实际入库 doc_id
doc_records: list[dict] = []
hier_records: list[dict[str, float]] = []
baseline_records: list[dict[str, float]] = []
pruned_cases: list[bool] = []
golden_sets: list[set[str]] = []
routed_sets: list[set[str]] = []
negative_total = 0
negative_false_alarm = 0
try:
# 1. 逐篇入库并计算摘要质量指标
for doc in documents:
result = await ingester.ingest(DocumentInput(title=doc["title"], text=doc["text"]))
id_map[doc["id"]] = result.document_id
logger.info("文档入库完成", id=doc["id"], doc_id=result.document_id, category=result.category)
record: dict = {
"id": doc["id"],
"title": doc["title"],
"golden_category": doc["golden_category"],
"category": result.category,
"entity_recall_l1": entity_recall(doc["text"], result.summary.l1_summary),
"entity_recall_l3": entity_recall(doc["text"], result.summary.l3_content_outline),
"hallucination_rate": None,
"taxonomy_consistency": None,
}
if judge_enabled:
record["hallucination_rate"] = await hallucination_rate(result.summary.l1_summary, doc["text"], ollama)
record["taxonomy_consistency"] = await taxonomy_consistency(
result.summary.l1_summary, result.category, ollama
)
doc_records.append(record)
# 2. 逐 query 计算检索效用指标(分层检索 + 轻量 L1 路由层 + 平铺 baseline
for query in queries:
query_text = query["query"]
golden_ids = {id_map[query["golden_doc_id"]]} if query.get("golden_doc_id") else set()
response = await retriever.search(SearchRequest(query=query_text))
hit_doc_ids = [hit.doc_id for hit in response.hits]
l1_candidates = await _l1_candidate_doc_ids(retriever, query_text)
if query["type"] == "positive" and golden_ids:
pruned_cases.append(not golden_ids & l1_candidates)
golden_sets.append(golden_ids)
routed_sets.append(l1_candidates)
hier_records.append(
{
"precision@5": precision_at_k(hit_doc_ids, golden_ids, 5),
"recall@10": recall_at_k(hit_doc_ids, golden_ids, 10),
}
)
baseline_ids = await _baseline_doc_ids(retriever, query_text)
baseline_records.append(
{
"precision@5": precision_at_k(baseline_ids, golden_ids, 5),
"recall@10": recall_at_k(baseline_ids, golden_ids, 10),
}
)
else:
# negative query:期望无结果,任何返回均计为误中
negative_total += 1
if hit_doc_ids:
negative_false_alarm += 1
finally:
# 评测集合清理(--keep-data 时保留)
if keep_data:
logger.info("--keep-data 生效,保留评测集合", collections=eval_collections)
else:
for collection in eval_collections:
try:
await qdrant.client.delete_collection(collection)
except Exception as exc:
logger.warning("评测集合清理失败", collection=collection, error=str(exc))
logger.info("评测集合已清理", collections=eval_collections)
# 3. 汇总并输出报告
prune_loss = pruning_loss(pruned_cases)
routing = routing_f1(golden_sets, routed_sets)
hier_metrics = aggregate(hier_records)
baseline_metrics = aggregate(baseline_records)
query_summary = {
"total": len(queries),
"positive": len(queries) - negative_total,
"negative": negative_total,
"negative_false_alarm": negative_false_alarm,
}
report = _build_report(
regression_path,
doc_records,
query_summary,
hier_metrics,
baseline_metrics,
routing,
prune_loss,
judge_enabled,
keep_data,
)
print(report)
REPORT_PATH.write_text(report + "\n", encoding="utf-8")
logger.info("评测报告已写入", path=str(REPORT_PATH))
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description="分层摘要 RAG 离线评测:回归集入库 → 摘要质量/检索效用指标 → 平铺 baseline 对比 → Markdown 报告",
)
parser.add_argument(
"--regression",
type=Path,
default=DEFAULT_REGRESSION,
help=f"回归集 JSON 路径(默认 {DEFAULT_REGRESSION}",
)
parser.add_argument("--keep-data", action="store_true", help="评测结束后保留 _eval 集合(默认删除)")
parser.add_argument("--no-judge", action="store_true", help="跳过 LLM-as-judge 指标(幻觉率 / 类目一致性)")
args = parser.parse_args()
return asyncio.run(run(args.regression.resolve(), args.keep_data, not args.no_judge))
if __name__ == "__main__":
sys.exit(main())