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
+94
View File
@@ -0,0 +1,94 @@
"""评测指标纯函数单元测试(不依赖 Qdrant / Ollama"""
import pytest
from scripts.eval.metrics import (
aggregate,
entity_recall,
precision_at_k,
pruning_loss,
recall_at_k,
routing_f1,
)
class TestEntityRecall:
def test_all_entities_kept(self) -> None:
source = "使用 Qdrant 存储 1536 维向量,模型 bge-m3,见《部署手册》第三条。"
summary = "基于 Qdrant 与 bge-m3 的 1536 维向量检索,详见《部署手册》第三条。"
assert entity_recall(source, summary) == pytest.approx(1.0)
def test_partial_entities_kept(self) -> None:
source = "支持 text-embedding-3-small 和 bge-m3 两个模型。"
summary = "支持 bge-m3 模型。"
score = entity_recall(source, summary)
assert 0.0 < score < 1.0
def test_no_entities_returns_full_score(self) -> None:
assert entity_recall("这是一段没有任何实体的中文普通句子。", "很短") == 1.0
def test_empty_summary_drops_all(self) -> None:
source = "版本 v1.2.0 修复了 3 个问题。"
assert entity_recall(source, "") == pytest.approx(0.0)
class TestRoutingF1:
def test_perfect_routing(self) -> None:
golden = [{"d1"}, {"d2"}]
routed = [{"d1"}, {"d2"}]
result = routing_f1(golden, routed)
assert result == {"precision": 1.0, "recall": 1.0, "f1": 1.0}
def test_micro_average(self) -> None:
# query1: tp=1 fp=1query2: tp=0 fn=1 → micro p=1/2 r=1/2 f1=1/2
golden = [{"d1"}, {"d2"}]
routed = [{"d1", "d3"}, set()]
result = routing_f1(golden, routed)
assert result["precision"] == pytest.approx(0.5)
assert result["recall"] == pytest.approx(0.5)
assert result["f1"] == pytest.approx(0.5)
def test_empty_routed(self) -> None:
result = routing_f1([{"d1"}], [set()])
assert result["precision"] == 0.0
assert result["recall"] == 0.0
class TestPruningLoss:
def test_mixed_cases(self) -> None:
assert pruning_loss([False, True, False, False]) == pytest.approx(0.25)
def test_empty(self) -> None:
assert pruning_loss([]) == 0.0
class TestPrecisionRecallAtK:
def test_precision_at_k(self) -> None:
hits = ["d1", "d2", "d3", "d4", "d5"]
assert precision_at_k(hits, {"d1", "d3"}, 5) == pytest.approx(0.4)
def test_precision_at_k_empty_hits(self) -> None:
assert precision_at_k([], {"d1"}, 5) == 0.0
def test_recall_at_k_dedup(self) -> None:
hits = ["d1", "d1", "d2"]
assert recall_at_k(hits, {"d1", "d2", "d3"}, 3) == pytest.approx(2 / 3)
def test_recall_at_k_empty_golden(self) -> None:
assert recall_at_k(["d1"], set(), 5) == 0.0
class TestAggregate:
def test_mean_per_key(self) -> None:
records = [{"a": 1.0, "b": 0.5}, {"a": 0.0, "b": 1.0}]
result = aggregate(records)
assert result["a"] == pytest.approx(0.5)
assert result["b"] == pytest.approx(0.75)
def test_partial_keys(self) -> None:
result = aggregate([{"a": 1.0}, {"a": 0.0, "b": 1.0}])
assert result["a"] == pytest.approx(0.5)
assert result["b"] == pytest.approx(1.0)
def test_empty(self) -> None:
assert aggregate([]) == {}