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:
@@ -0,0 +1,128 @@
|
||||
"""RerankerService 单元测试(mock httpx,不发起真实网络请求)"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.services.reranker import OllamaRerankerService, create_reranker_service
|
||||
|
||||
|
||||
def _make_response(status_code: int, data: dict | None = None) -> httpx.Response:
|
||||
"""构造带 request 上下文的 httpx.Response(raise_for_status 依赖 request)"""
|
||||
request = httpx.Request("POST", "http://localhost:11434/api/rerank")
|
||||
if data is None:
|
||||
return httpx.Response(status_code, request=request)
|
||||
return httpx.Response(status_code, json=data, request=request)
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
"""httpx.AsyncClient 替代品:记录请求参数,返回预设响应或抛出预设异常"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
captured: dict,
|
||||
response: httpx.Response | None = None,
|
||||
error: Exception | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._captured = captured
|
||||
self._response = response
|
||||
self._error = error
|
||||
captured["timeout"] = kwargs.get("timeout")
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> bool:
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: dict | None = None) -> httpx.Response:
|
||||
self._captured["url"] = url
|
||||
self._captured["json"] = json
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
assert self._response is not None
|
||||
return self._response
|
||||
|
||||
|
||||
def _client(monkeypatch: pytest.MonkeyPatch, captured: dict, **kw: Any) -> None:
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: _FakeAsyncClient(captured, **k, **kw))
|
||||
|
||||
|
||||
class TestRerank:
|
||||
async def test_returns_scores_aligned_to_documents(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
_client(
|
||||
monkeypatch,
|
||||
captured,
|
||||
response=_make_response(
|
||||
200,
|
||||
{
|
||||
"results": [
|
||||
{"index": 0, "relevance_score": 0.2},
|
||||
{"index": 1, "relevance_score": 0.9},
|
||||
{"index": 2, "relevance_score": 0.5},
|
||||
]
|
||||
},
|
||||
),
|
||||
)
|
||||
service = OllamaRerankerService(base_url="http://localhost:11434/", model="qwen3-reranker:0.6b")
|
||||
|
||||
scores = await service.rerank("查询", ["doc-a", "doc-b", "doc-c"])
|
||||
|
||||
assert scores == [0.2, 0.9, 0.5]
|
||||
assert captured["url"] == "http://localhost:11434/api/rerank"
|
||||
assert captured["json"] == {
|
||||
"model": "qwen3-reranker:0.6b",
|
||||
"query": "查询",
|
||||
"documents": ["doc-a", "doc-b", "doc-c"],
|
||||
}
|
||||
|
||||
async def test_empty_documents_returns_empty(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
_client(monkeypatch, captured, response=_make_response(200, {"results": []}))
|
||||
service = OllamaRerankerService(base_url="http://localhost:11434", model="qwen3-reranker:0.6b")
|
||||
|
||||
scores = await service.rerank("查询", [])
|
||||
|
||||
assert scores == []
|
||||
# 空文档不发起请求
|
||||
assert "url" not in captured
|
||||
|
||||
async def test_partial_results_zero_fill_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
# 仅返回 index 1,index 0/2 缺失 → 补 0
|
||||
_client(
|
||||
monkeypatch,
|
||||
captured,
|
||||
response=_make_response(200, {"results": [{"index": 1, "relevance_score": 0.7}]}),
|
||||
)
|
||||
service = OllamaRerankerService(base_url="http://localhost:11434", model="qwen3-reranker:0.6b")
|
||||
|
||||
scores = await service.rerank("查询", ["a", "b", "c"])
|
||||
|
||||
assert scores == [0.0, 0.7, 0.0]
|
||||
|
||||
async def test_http_error_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_client(monkeypatch, {}, response=_make_response(500))
|
||||
service = OllamaRerankerService(base_url="http://localhost:11434", model="qwen3-reranker:0.6b")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await service.rerank("查询", ["a", "b"])
|
||||
|
||||
|
||||
class TestCreateRerankerService:
|
||||
def test_disabled_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "reranker_enabled", False)
|
||||
assert create_reranker_service() is None
|
||||
|
||||
def test_enabled_returns_service(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "reranker_enabled", True)
|
||||
monkeypatch.setattr(settings, "reranker_model", "qwen3-reranker:0.6b")
|
||||
monkeypatch.setattr(settings, "ollama_base_url", "http://ollama:11434")
|
||||
monkeypatch.setattr(settings, "reranker_timeout", 30.0)
|
||||
service = create_reranker_service()
|
||||
assert isinstance(service, OllamaRerankerService)
|
||||
assert service.model == "qwen3-reranker:0.6b"
|
||||
Reference in New Issue
Block a user