Files
QMDSearch/tests/test_retriever.py
T
kplam 51dc8dc4f6 Initial commit: QMDSearch 分层信息检索服务
- FastAPI + Qdrant + Redis + Ollama 技术栈
- L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合)
- 文档三级总结与 2.5 级回退
- query 解析路由与分类
- /admin 管理页面
2026-07-29 21:24:40 +08:00

351 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""分层检索引擎与检索 API 的单元测试(全部 mock,不联网、不起 Docker"""
from typing import Any
import pytest
from fastapi.testclient import TestClient
from qdrant_client import models
from app.config import settings
from app.core.query_parser import ParsedQuery, RouteDecision
from app.core.retriever import Retriever
from app.core.sparse import SparseEncoder
from app.main import app
from app.models.search import SearchHit, SearchRequest, SearchResponse
from app.services.qdrant import COLLECTION_CHUNKS, COLLECTION_L1, COLLECTION_L2, COLLECTION_L3
def _point(
pid: str, doc_id: str = "", section_path: str | None = None, score: float = 1.0, **extra: Any
) -> models.ScoredPoint:
"""构造测试用 ScoredPointpayload 模拟 chunk/节点结构"""
payload: dict[str, Any] = {"doc_id": doc_id, "text": f"text-{pid}", "title": f"title-{doc_id}", **extra}
if section_path is not None:
payload["section_path"] = section_path
return models.ScoredPoint(id=pid, version=0, score=score, payload=payload, vector=None)
class FakeQdrant:
"""按集合 + 调用顺序返回预置结果的假 QdrantService,记录每次调用的参数"""
def __init__(self, results: dict[str, list[list[models.ScoredPoint]]]) -> None:
self._results = results
self.calls: list[dict[str, Any]] = []
def _next(self, collection: str) -> list[models.ScoredPoint]:
queue = self._results.get(collection, [])
return queue.pop(0) if queue else []
async def search_dense(
self, collection: str, vector: list[float], limit: int, query_filter: models.Filter | None = None
) -> list[models.ScoredPoint]:
self.calls.append({"collection": collection, "method": "dense", "limit": limit, "filter": query_filter})
return self._next(collection)
async def search_hybrid(
self,
collection: str,
dense_vector: list[float],
sparse: tuple[list[int], list[float]],
limit: int,
query_filter: models.Filter | None = None,
) -> list[models.ScoredPoint]:
self.calls.append({"collection": collection, "method": "hybrid", "limit": limit, "filter": query_filter})
return self._next(collection)
class FakeParser:
"""返回固定路由决策的假 QueryParser"""
def __init__(self, route: RouteDecision) -> None:
self.route = route
async def parse_and_route(self, query: str) -> RouteDecision:
return self.route
class FakeEmbedding:
"""返回固定向量的假 EmbeddingService"""
async def embed(self, texts: list[str]) -> list[list[float]]:
return [[0.1, 0.2, 0.3] for _ in texts]
def _route(
fallback: bool = False, categories: tuple[str, ...] = ("技术文档",), rewrite: str = "rewrite query"
) -> RouteDecision:
return RouteDecision(
fallback=fallback,
filter_categories=None if fallback else list(categories),
reason="low_confidence" if fallback else "routed",
parsed=ParsedQuery(raw_query="q", rewrite=rewrite),
)
def _make_retriever(qdrant: FakeQdrant, route: RouteDecision) -> Retriever:
return Retriever(
qdrant=qdrant, # type: ignore[arg-type]
query_parser=FakeParser(route), # type: ignore[arg-type]
embedding=FakeEmbedding(),
sparse_encoder=SparseEncoder(),
)
def _calls(qdrant: FakeQdrant, collection: str) -> list[dict[str, Any]]:
return [c for c in qdrant.calls if c["collection"] == collection]
def _must_match_any(flt: models.Filter | None, key: str) -> list[str] | None:
"""提取 must 中指定 key 的 MatchAny 值,不存在返回 None"""
if flt is None:
return None
for cond in flt.must or []:
if cond.key == key:
return list(cond.match.any)
return None
def _filter_categories(flt: models.Filter | None) -> list[str] | None:
"""提取 min_should 中 category 条件的 MatchAny 值,不存在返回 None"""
if flt is None or flt.min_should is None:
return None
for cond in flt.min_should.conditions:
if cond.key == "category":
return list(cond.match.any)
return None
class TestRetriever:
"""分层检索流程:各层 filter 参数与回退路径"""
async def test_full_pipeline(self):
"""正常三级逐层:L1 候选 → L2 收窄 → L3 两路(含 2.5 级文档 b 路)→ chunk"""
qdrant = FakeQdrant(
{
COLLECTION_L1: [[_point("l1a", "d1"), _point("l1b", "d2")]],
COLLECTION_L2: [[_point("l2a", "d1", "章节A")]],
COLLECTION_L3: [
[_point("l3a", "d1", "章节A")], # a 路:L2 命中文档
[_point("l3b", "d2", "")], # b 路:2.5 级文档(无 L2 节点)
],
COLLECTION_CHUNKS: [
[
_point("c1", "d1", "章节A", doc_summary="总结1"),
_point("c2", "d2", "", doc_summary="总结2"),
]
],
}
)
retriever = _make_retriever(qdrant, _route())
resp = await retriever.search(SearchRequest(query="测试查询"))
# L1categories 过滤、无 doc 限制,limit=l1_doc_top_n
l1_call = _calls(qdrant, COLLECTION_L1)[0]
assert l1_call["limit"] == settings.l1_doc_top_n
assert _filter_categories(l1_call["filter"]) == ["技术文档"]
assert _must_match_any(l1_call["filter"], "doc_id") is None
# L2doc_ids 为 L1 候选(保序),limit=l2_section_top_n*候选数
l2_call = _calls(qdrant, COLLECTION_L2)[0]
assert l2_call["method"] == "dense"
assert l2_call["limit"] == settings.l2_section_top_n * 2
assert _must_match_any(l2_call["filter"], "doc_id") == ["d1", "d2"]
assert _filter_categories(l2_call["filter"]) == ["技术文档"]
# L3 两路:a 路 d1 + section「章节A」;b 路 d22.5 级文档)仅 doc 过滤
l3_calls = _calls(qdrant, COLLECTION_L3)
assert len(l3_calls) == 2
assert all(c["limit"] == settings.l3_top_n for c in l3_calls)
assert _must_match_any(l3_calls[0]["filter"], "doc_id") == ["d1"]
assert _must_match_any(l3_calls[0]["filter"], "section_path") == ["章节A"]
assert _must_match_any(l3_calls[1]["filter"], "doc_id") == ["d2"]
assert _must_match_any(l3_calls[1]["filter"], "section_path") is None
# chunkdoc_ids 为 L3 命中文档,section_paths 仅非空值
chunk_call = _calls(qdrant, COLLECTION_CHUNKS)[0]
assert chunk_call["limit"] == settings.retrieval_top_k
assert sorted(_must_match_any(chunk_call["filter"], "doc_id") or []) == ["d1", "d2"]
assert _must_match_any(chunk_call["filter"], "section_path") == ["章节A"]
# 响应组装
assert resp.query == "测试查询"
assert resp.fallback is False
assert resp.routed_categories == ["技术文档"]
assert [h.doc_id for h in resp.hits] == ["d1", "d2"]
assert resp.hits[0].text == "text-c1"
assert resp.hits[0].title == "title-d1"
assert resp.hits[0].section_path == "章节A"
assert resp.hits[0].doc_summary == "总结1"
assert resp.hits[0].score > 0
async def test_l2_empty_all_docs_go_l3_b_path(self):
"""L2 整体无命中:全部候选文档回退为 L3 单路 doc 级查询"""
qdrant = FakeQdrant(
{
COLLECTION_L1: [[_point("l1a", "d1"), _point("l1b", "d2")]],
COLLECTION_L2: [[]],
COLLECTION_L3: [[_point("l3a", "d1", "章节A")]],
COLLECTION_CHUNKS: [[_point("c1", "d1", "章节A")]],
}
)
retriever = _make_retriever(qdrant, _route())
resp = await retriever.search(SearchRequest(query="测试查询"))
l3_calls = _calls(qdrant, COLLECTION_L3)
assert len(l3_calls) == 1
assert _must_match_any(l3_calls[0]["filter"], "doc_id") == ["d1", "d2"]
assert _must_match_any(l3_calls[0]["filter"], "section_path") is None
assert [h.doc_id for h in resp.hits] == ["d1"]
async def test_l3_empty_fallback_doc_level_chunks(self):
"""L3 两路均无命中:chunk 层回退为 L1 候选文档级检索(无 section 过滤)"""
qdrant = FakeQdrant(
{
COLLECTION_L1: [[_point("l1a", "d1"), _point("l1b", "d2")]],
COLLECTION_L2: [[_point("l2a", "d1", "章节A")]],
COLLECTION_L3: [[], []],
COLLECTION_CHUNKS: [[_point("c1", "d2", "")]],
}
)
retriever = _make_retriever(qdrant, _route())
resp = await retriever.search(SearchRequest(query="测试查询"))
chunk_call = _calls(qdrant, COLLECTION_CHUNKS)[0]
assert _must_match_any(chunk_call["filter"], "doc_id") == ["d1", "d2"]
assert _must_match_any(chunk_call["filter"], "section_path") is None
assert [h.doc_id for h in resp.hits] == ["d2"]
async def test_l1_empty_global_chunk_fallback(self):
"""L1 无候选文档:直接全库 chunk 兜底(无 filter),fallback=True"""
qdrant = FakeQdrant(
{
COLLECTION_L1: [[]],
COLLECTION_CHUNKS: [[_point("c1", "d9", "章节X", doc_summary="总结9")]],
}
)
retriever = _make_retriever(qdrant, _route())
resp = await retriever.search(SearchRequest(query="测试查询"))
# 不再触发 L2/L3 查询
assert _calls(qdrant, COLLECTION_L2) == []
assert _calls(qdrant, COLLECTION_L3) == []
# 全库 chunk 检索:无 filter
chunk_calls = _calls(qdrant, COLLECTION_CHUNKS)
assert len(chunk_calls) == 1
assert chunk_calls[0]["filter"] is None
assert chunk_calls[0]["limit"] == settings.retrieval_top_k
assert resp.fallback is True
assert [h.doc_id for h in resp.hits] == ["d9"]
async def test_route_fallback_no_category_filter(self):
"""路由兜底时 categories=None,各层均不做类目过滤"""
qdrant = FakeQdrant(
{
COLLECTION_L1: [[_point("l1a", "d1")]],
COLLECTION_L2: [[]],
COLLECTION_L3: [[_point("l3a", "d1", "")]],
COLLECTION_CHUNKS: [[_point("c1", "d1", "")]],
}
)
retriever = _make_retriever(qdrant, _route(fallback=True))
resp = await retriever.search(SearchRequest(query="测试查询"))
# L1categories 与 doc_ids 均空 → filter 为 None
assert _calls(qdrant, COLLECTION_L1)[0]["filter"] is None
# L2/L3/chunk:仅有 doc 级 must 条件,无类目 min_should
for collection in (COLLECTION_L2, COLLECTION_L3, COLLECTION_CHUNKS):
for call in _calls(qdrant, collection):
assert _filter_categories(call["filter"]) is None
assert resp.fallback is True
assert resp.routed_categories == []
async def test_top_k_override(self):
"""request.top_k 优先于 settings.retrieval_final_k"""
qdrant = FakeQdrant(
{
COLLECTION_L1: [[_point("l1a", "d1")]],
COLLECTION_L2: [[]],
COLLECTION_L3: [[_point("l3a", "d1", "")]],
COLLECTION_CHUNKS: [[_point(f"c{i}", "d1", "") for i in range(5)]],
}
)
retriever = _make_retriever(qdrant, _route())
resp = await retriever.search(SearchRequest(query="测试查询", top_k=2))
assert len(resp.hits) == 2
class _FakeRetriever:
"""API 测试用假 Retriever:返回固定响应或抛异常"""
def __init__(self, response: SearchResponse | None = None, exc: Exception | None = None) -> None:
self.response = response
self.exc = exc
async def search(self, request: SearchRequest) -> SearchResponse:
if self.exc is not None:
raise self.exc
assert self.response is not None
return self.response
class TestSearchApi:
"""检索 API 统一响应包装"""
def test_search_ok(self, monkeypatch: pytest.MonkeyPatch):
"""正常检索 → {"code": 0, "data": ..., "message": "ok"}"""
response = SearchResponse(
query="q",
hits=[SearchHit(text="t", doc_id="d1", title="标题", section_path="", score=0.5, doc_summary="s")],
routed_categories=["技术文档"],
fallback=False,
)
monkeypatch.setattr("app.api.v1.search._retriever", _FakeRetriever(response=response))
client = TestClient(app)
resp = client.post("/api/v1/search", json={"query": "q"})
assert resp.status_code == 200
body = resp.json()
assert body["code"] == 0
assert body["message"] == "ok"
assert body["data"]["query"] == "q"
assert body["data"]["hits"][0]["doc_id"] == "d1"
assert body["data"]["routed_categories"] == ["技术文档"]
assert body["data"]["fallback"] is False
def test_search_error(self, monkeypatch: pytest.MonkeyPatch):
"""检索内部异常 → code 2000"""
monkeypatch.setattr("app.api.v1.search._retriever", _FakeRetriever(exc=RuntimeError("boom")))
client = TestClient(app)
resp = client.post("/api/v1/search", json={"query": "q"})
assert resp.status_code == 200
body = resp.json()
assert body["code"] == 2000
assert body["data"] is None
assert "boom" in body["message"]
def test_validation_error(self):
"""缺少必填 query 字段 → code 1001"""
client = TestClient(app)
resp = client.post("/api/v1/search", json={})
assert resp.status_code == 200
body = resp.json()
assert body["code"] == 1001
assert body["data"] is None
def test_health_kept(self):
"""现有健康检查接口不受影响"""
client = TestClient(app)
resp = client.get("/api/v1/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}