51dc8dc4f6
- FastAPI + Qdrant + Redis + Ollama 技术栈 - L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合) - 文档三级总结与 2.5 级回退 - query 解析路由与分类 - /admin 管理页面
351 lines
14 KiB
Python
351 lines
14 KiB
Python
"""分层检索引擎与检索 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:
|
||
"""构造测试用 ScoredPoint,payload 模拟 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="测试查询"))
|
||
|
||
# L1:categories 过滤、无 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
|
||
|
||
# L2:doc_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 路 d2(2.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
|
||
|
||
# chunk:doc_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="测试查询"))
|
||
|
||
# L1:categories 与 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"}
|