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
+106
View File
@@ -0,0 +1,106 @@
"""离线评测指标:纯函数实现,不依赖 Qdrant / Ollama,可独立单测
- entity_recall:实体保留率(摘要质量)
- routing_f1L1 路由层 micro P/R/F1
- pruning_loss:上层剪枝损失率
- precision_at_k / recall_at_k:检索效用
- aggregate:按 key 汇总均值
"""
import re
# 数字串(含小数/版本号/百分数),如 1536、3.12、95%
_NUMBER_RE = re.compile(r"\d+(?:\.\d+)*%?")
# 型号/版本号模式,如 bge-m3、v1.2.0、FD-07
_MODEL_RE = re.compile(r"[A-Za-z]+[-_]\w*\d\w*|[A-Za-z]+\d+(?:\.\d+)*")
# 条款号,如 第三条、第 5 章、第十条
_CLAUSE_RE = re.compile(r"\s*[一二三四五六七八九十百千万零0-9]+\s*[条款章节项]")
# 英文/大小写混合词(含连字符/点号连接),如 Qdrant、OpenAI、text-embedding-3-small
_EN_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:[-_.][A-Za-z0-9]+)*")
# 中文书名号内词,如 《部署手册》
_BOOK_TITLE_RE = re.compile(r"《([^》]+)》")
def _extract_entities(text: str) -> set[str]:
"""从文本抽取关键实体 token(去重集合)"""
entities: set[str] = set()
entities.update(_NUMBER_RE.findall(text))
entities.update(_MODEL_RE.findall(text))
entities.update(m.replace(" ", "") for m in _CLAUSE_RE.findall(text))
entities.update(_BOOK_TITLE_RE.findall(text))
# 英文词至少 2 个字符,避免单字母噪声
entities.update(w for w in _EN_WORD_RE.findall(text) if len(w) >= 2)
return entities
def entity_recall(source_text: str, summary: str) -> float:
"""实体保留率:原文抽取的关键实体在摘要中保留的比例
原文无可抽取实体时返回 1.0(无实体可丢,视为满分)。
"""
entities = _extract_entities(source_text)
if not entities:
return 1.0
kept = sum(1 for entity in entities if entity in summary)
return kept / len(entities)
def routing_f1(golden_doc_ids_per_query: list[set[str]], routed_doc_ids_per_query: list[set[str]]) -> dict[str, float]:
"""L1 路由层 micro precision / recall / f1
逐 query 累加 tp/fp/fn 后统一计算(micro 平均);
两条列表必须等长,通常只统计 positive querynegative query 无 golden doc)。
"""
tp = fp = fn = 0
for golden, routed in zip(golden_doc_ids_per_query, routed_doc_ids_per_query, strict=True):
tp += len(golden & routed)
fp += len(routed - golden)
fn += len(golden - routed)
precision = tp / (tp + fp) if tp + fp else 0.0
recall = tp / (tp + fn) if tp + fn else 0.0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
return {"precision": precision, "recall": recall, "f1": f1}
def pruning_loss(cases: list[bool]) -> float:
"""剪枝损失率:原文含答案但 L1 未命中 golden doc 的 positive query 占比
cases 中每个元素表示一条 positive query 是否被上层剪掉(True = 被剪)。
"""
if not cases:
return 0.0
return sum(cases) / len(cases)
def precision_at_k(hit_doc_ids: list[str], golden: set[str], k: int) -> float:
"""Precision@k:前 k 条命中中 doc_id 属于 golden 的比例(按命中条数计,不去重)"""
if k <= 0:
return 0.0
top = hit_doc_ids[:k]
if not top:
return 0.0
hits = sum(1 for doc_id in top if doc_id in golden)
return hits / k
def recall_at_k(hit_doc_ids: list[str], golden: set[str], k: int) -> float:
"""Recall@k:前 k 条命中覆盖的 golden doc 比例(按 doc 去重);golden 为空返回 0.0"""
if not golden:
return 0.0
found = {doc_id for doc_id in hit_doc_ids[:k] if doc_id in golden}
return len(found) / len(golden)
def aggregate(records: list[dict[str, float]]) -> dict[str, float]:
"""按 key 汇总均值:对记录列表中每个指标求算术平均
某 key 只在部分记录中出现时,按出现的记录取均值;空列表返回 {}
"""
if not records:
return {}
keys = list(dict.fromkeys(key for record in records for key in record))
result: dict[str, float] = {}
for key in keys:
values = [record[key] for record in records if key in record]
result[key] = sum(values) / len(values) if values else 0.0
return result