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:
2026-08-01 00:08:38 +08:00
parent 6c6f690788
commit 6ae91679e2
8 changed files with 320 additions and 9 deletions
+6
View File
@@ -21,6 +21,12 @@ class Settings(BaseSettings):
ollama_model: str = "qwen2.5:1.5b" # 备选: qwen2.5:3b
ollama_embedding_model: str = "bge-m3" # embedding_provider=local 时使用的嵌入模型
# 重排模型(cross-encoder,经 Ollama /api/rerank,对 chunk 候选做最终精排)
# 关闭时检索链路退化为纯 RRF 融合结果,行为不变
reranker_enabled: bool = False
reranker_model: str = "qwen3-reranker:0.6b"
reranker_timeout: float = 30.0
# Qdrant
qdrant_host: str = "localhost"
qdrant_port: int = 6333
+26 -3
View File
@@ -16,7 +16,7 @@ from qdrant_client import models
from app.config import settings
from app.core.embeddings import EmbeddingService, create_embedding_service
from app.core.query_parser import QueryParser, RouteDecision
from app.core.ranker import finalize, rrf_fuse
from app.core.ranker import rrf_fuse
from app.core.result_summarizer import ResultSummarizer
from app.core.sparse import SparseEncoder
from app.models.knowledge import load_taxonomy
@@ -30,6 +30,7 @@ from app.services.qdrant import (
SPARSE_COLLECTIONS,
QdrantService,
)
from app.services.reranker import RerankerService, create_reranker_service
logger = structlog.get_logger()
@@ -49,6 +50,7 @@ class Retriever:
embedding: EmbeddingService | None = None,
sparse_encoder: SparseEncoder | None = None,
result_summarizer: ResultSummarizer | None = None,
reranker: RerankerService | None = None,
) -> None:
self.qdrant = qdrant or QdrantService()
self.query_parser = query_parser or QueryParser(
@@ -58,6 +60,7 @@ class Retriever:
self.embedding = embedding or create_embedding_service()
self.sparse_encoder = sparse_encoder or SparseEncoder()
self.result_summarizer = result_summarizer or ResultSummarizer()
self.reranker = reranker if reranker is not None else create_reranker_service()
async def search(self, request: SearchRequest) -> SearchResponse:
"""分层检索主流程"""
@@ -77,7 +80,7 @@ class Retriever:
# L1 无候选文档 → 全库 chunk 兜底
chunk_hits = await self._search_collection(COLLECTION_CHUNKS, dense, sparse, settings.retrieval_top_k, None)
logger.info("L1 无命中,全库 chunk 兜底", hits=len(chunk_hits))
hits = self._to_hits(finalize(chunk_hits, self._final_k(request)))
hits = self._to_hits(await self._rerank(query_text, chunk_hits, self._final_k(request)))
return await self._build_response(request, route, hits, fallback=True)
doc_ids = _unique((p.payload or {}).get("doc_id") for p in l1_hits)
@@ -123,7 +126,7 @@ class Retriever:
)
logger.info("chunk 检索完成", hits=len(chunk_hits))
final_points = finalize(rrf_fuse([chunk_hits]), self._final_k(request))
final_points = await self._rerank(query_text, chunk_hits, self._final_k(request))
hits = self._to_hits(final_points)
return await self._build_response(request, route, hits, fallback=route.fallback)
@@ -145,6 +148,26 @@ class Retriever:
"""最终返回数:请求指定优先,否则用配置默认值"""
return request.top_k or settings.retrieval_final_k
async def _rerank(
self, query_text: str, points: list[models.ScoredPoint], final_k: int
) -> list[models.ScoredPoint]:
"""对 chunk 候选池做语义精排(cross-encoder
重排未启用、候选为空或调用异常时,退化为 RRF 融合的原始顺序(取前 final_k),
保证检索链路在任何情况下都不因重排失败而中断。
"""
if self.reranker is None or not points:
return points[:final_k]
documents = [(p.payload or {}).get("text", "") for p in points]
try:
scores = await self.reranker.rerank(query_text, documents)
except Exception:
logger.warning("重排调用失败,退化为 RRF 融合顺序", exc_info=True)
return points[:final_k]
# 按相关性分数降序重排,写回 score 字段为相关性分数
ordered = sorted(range(len(points)), key=lambda i: scores[i], reverse=True)
return [points[i].model_copy(update={"score": scores[i]}) for i in ordered[:final_k]]
@staticmethod
def _to_hits(points: list[models.ScoredPoint]) -> list[SearchHit]:
"""chunk 点组装为 SearchHit,字段取自 chunk payloaddoc_summary 仅上下文标注)"""
+79
View File
@@ -0,0 +1,79 @@
"""语义重排服务(cross-encoder reranker
调用 Ollama 的 `/api/rerank` 端点(Qwen3-Reranker 等重排模型),对检索召回的
chunk 候选池做精排,提升最终 Top-K 的相关性。相比纯向量/RRF 融合,cross-encoder
以 (query, doc) 联合编码,能捕捉字词不匹配的语义关联,典型带来约 10% 的精度提升。
工厂 `create_reranker_service()` 按 `settings.reranker_enabled` 返回实例或 None
关闭时上层检索链路退化为原有的 RRF 融合结果,行为完全不变。
"""
from typing import Protocol, runtime_checkable
import httpx
import structlog
from app.config import settings
logger = structlog.get_logger()
@runtime_checkable
class RerankerService(Protocol):
"""重排服务统一接口"""
async def rerank(self, query: str, documents: list[str]) -> list[float]:
"""对候选文档按与 query 的相关性打分
Args:
query: 查询文本
documents: 候选文档文本列表(按输入顺序对齐)
Returns:
与 documents 等长的相关性分数列表(分数越高越相关)
"""
...
class OllamaRerankerService:
"""基于 Ollama /api/rerank 的重排服务"""
def __init__(self, base_url: str, model: str, timeout: float = 30.0) -> None:
self.base_url = base_url.rstrip("/")
self.model = model
self.timeout = timeout
async def rerank(self, query: str, documents: list[str]) -> list[float]:
if not documents:
return []
url = f"{self.base_url}/api/rerank"
payload = {"model": self.model, "query": query, "documents": documents}
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
data = resp.json()
results = data.get("results", [])
# 按 Ollama 返回的 index 映射 relevance_score;缺失项补 0,保证输出与输入等长
scores: list[float] = [0.0] * len(documents)
for item in results:
idx = item.get("index")
score = float(item.get("relevance_score", 0.0))
if idx is not None and 0 <= idx < len(documents):
scores[idx] = score
logger.debug("重排完成", model=self.model, candidates=len(documents))
return scores
def create_reranker_service() -> RerankerService | None:
"""按 settings.reranker_enabled 创建重排服务实例
关闭时返回 None,上层据此跳过精排、退化为 RRF 融合结果。
"""
if not settings.reranker_enabled:
return None
return OllamaRerankerService(
base_url=settings.ollama_base_url,
model=settings.reranker_model,
timeout=settings.reranker_timeout,
)