feat: 接入 BGE-M3 本地嵌入默认配置 + 新增 Qwen3-Reranker 重排
- .env.example 嵌入默认改为本地 BGE-M3(EMBEDDING_PROVIDER=local, dim=1024),NAS 全新部署即用 - 新增 app/services/reranker.py:基于 Ollama /api/rerank 的 cross-encoder 重排服务 - retriever 在 chunk 候选阶段接入语义精排,调用失败优雅降级为 RRF 顺序 - docker-compose 拉取 qwen3-reranker:0.6b,OLLAMA_MAX_LOADED_MODELS 提至 3 - .gitignore 排除 .workbuddy/ 与 .trae/(防止误提交项目记忆与 IDE spec) - 新增 reranker 单元与集成测试,全量测试 529 passed
This commit is contained in:
+64
-1
@@ -82,15 +82,29 @@ def _route(
|
||||
)
|
||||
|
||||
|
||||
def _make_retriever(qdrant: FakeQdrant, route: RouteDecision) -> Retriever:
|
||||
def _make_retriever(qdrant: FakeQdrant, route: RouteDecision, reranker: Any = None) -> Retriever:
|
||||
return Retriever(
|
||||
qdrant=qdrant, # type: ignore[arg-type]
|
||||
query_parser=FakeParser(route), # type: ignore[arg-type]
|
||||
embedding=FakeEmbedding(),
|
||||
sparse_encoder=SparseEncoder(),
|
||||
reranker=reranker,
|
||||
)
|
||||
|
||||
|
||||
class FakeReranker:
|
||||
"""假 RerankerService:返回预设分数或抛出预设异常"""
|
||||
|
||||
def __init__(self, scores: list[float] | None = None, exc: Exception | None = None) -> None:
|
||||
self.scores = scores if scores is not None else []
|
||||
self.exc = exc
|
||||
|
||||
async def rerank(self, query: str, documents: list[str]) -> list[float]:
|
||||
if self.exc is not None:
|
||||
raise self.exc
|
||||
return self.scores
|
||||
|
||||
|
||||
def _calls(qdrant: FakeQdrant, collection: str) -> list[dict[str, Any]]:
|
||||
return [c for c in qdrant.calls if c["collection"] == collection]
|
||||
|
||||
@@ -348,3 +362,52 @@ class TestSearchApi:
|
||||
resp = client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
|
||||
class TestReranker:
|
||||
"""重排启用时对 chunk 候选精排;关闭或失败则退化为 RRF 顺序"""
|
||||
|
||||
@staticmethod
|
||||
def _full_qdrant() -> FakeQdrant:
|
||||
return FakeQdrant(
|
||||
{
|
||||
COLLECTION_L1: [[_point("l1a", "d1")]],
|
||||
COLLECTION_L2: [[_point("l2a", "d1", "章节A")]],
|
||||
COLLECTION_L3: [[_point("l3a", "d1", "章节A")]],
|
||||
COLLECTION_CHUNKS: [
|
||||
[
|
||||
_point("c1", "d1", "章节A"),
|
||||
_point("c2", "d1", "章节A"),
|
||||
_point("c3", "d1", "章节A"),
|
||||
]
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
async def test_enabled_reorders_by_relevance(self):
|
||||
"""重排按相关性分数降序重排,并将 score 写回相关性分数"""
|
||||
reranker = FakeReranker(scores=[0.1, 0.9, 0.5]) # c1<c3<c2
|
||||
retriever = _make_retriever(self._full_qdrant(), _route(), reranker=reranker)
|
||||
|
||||
resp = await retriever.search(SearchRequest(query="测试查询"))
|
||||
|
||||
# 重排后顺序为 c2, c3, c1
|
||||
assert [h.text for h in resp.hits] == ["text-c2", "text-c3", "text-c1"]
|
||||
assert resp.hits[0].score == 0.9
|
||||
|
||||
async def test_disabled_keeps_rrf_order(self):
|
||||
"""重排未注入(关闭)时保持 RRF 融合原始顺序"""
|
||||
retriever = _make_retriever(self._full_qdrant(), _route(), reranker=None)
|
||||
|
||||
resp = await retriever.search(SearchRequest(query="测试查询"))
|
||||
|
||||
assert [h.text for h in resp.hits] == ["text-c1", "text-c2", "text-c3"]
|
||||
|
||||
async def test_failure_falls_back_to_rrf(self):
|
||||
"""重排调用异常时退化为 RRF 顺序,检索不中断"""
|
||||
reranker = FakeReranker(exc=RuntimeError("ollama 不可用"))
|
||||
retriever = _make_retriever(self._full_qdrant(), _route(), reranker=reranker)
|
||||
|
||||
resp = await retriever.search(SearchRequest(query="测试查询"))
|
||||
|
||||
assert [h.text for h in resp.hits] == ["text-c1", "text-c2", "text-c3"]
|
||||
|
||||
Reference in New Issue
Block a user