Files
ailine/backend/app/rag/retriever.py

353 lines
12 KiB
Python
Raw Normal View History

2026-04-21 11:02:16 +08:00
"""
Qdrant 混合检索器模块完全异步
2026-04-21 11:02:16 +08:00
提供基于 Qdrant 的混合检索Dense + Sparse功能包括
- 纯混合检索无子父文档
- 父子文档混合检索先检索子文档再返回父文档
2026-04-21 11:02:16 +08:00
核心原理
- 使用 Qdrant Universal Query API (query_points)
- 使用 Prefetch 并行检索多个源
- 使用 RRF 分数融合
2026-04-21 11:02:16 +08:00
"""
from typing import Dict, Any, Optional, List
from qdrant_client import AsyncQdrantClient, models
2026-04-21 11:02:16 +08:00
from qdrant_client.http.exceptions import UnexpectedResponse
from langchain_core.documents import Document
2026-04-21 11:02:16 +08:00
from langchain_core.embeddings import Embeddings
from langchain_core.retrievers import BaseRetriever
from pydantic import Field, PrivateAttr
2026-04-21 11:02:16 +08:00
2026-05-05 23:17:00 +08:00
from backend.rag_core import QdrantHybridStore, get_sparse_embedder, create_docstore
from backend.rag_core.client import create_async_qdrant_client
from ..model_services import get_embedding_service
2026-05-06 01:15:52 +08:00
from backend.app.logger import info, warning, debug
2026-04-21 11:02:16 +08:00
# 模块级常量
DEFAULT_SEARCH_K = 20
DEFAULT_PARENT_SEARCH_K = 5
class HybridRetriever(BaseRetriever):
"""
混合检索器稠密向量 + BM25 稀疏向量 RRF 分数融合异步
使用 Qdrant Universal Query API (query_points)
"""
collection_name: str = Field(description="Qdrant 集合名称")
search_k: int = Field(default=DEFAULT_SEARCH_K, description="检索返回结果数")
_vector_store: Any = PrivateAttr()
_client: Any = PrivateAttr()
_sparse_embedder: Any = PrivateAttr()
def __init__(
self,
collection_name: str,
vector_store: QdrantHybridStore,
search_k: int = DEFAULT_SEARCH_K,
):
"""
Args:
collection_name: Qdrant 集合名称
vector_store: QdrantHybridStore 实例
search_k: 检索返回结果数
"""
super().__init__(
collection_name=collection_name,
search_k=search_k
)
self._vector_store = vector_store
self._client = vector_store.get_async_qdrant_client()
self._sparse_embedder = get_sparse_embedder()
def _get_relevant_documents(
self, query: str, *, run_manager: Any = None
) -> List[Document]:
"""
同步检索不推荐使用仅供兼容性
注意在异步环境中请使用 _aget_relevant_documents ainvoke
"""
import asyncio
try:
loop = asyncio.get_running_loop()
# 已有事件循环,使用 create_task
task = loop.create_task(self._aget_relevant_documents(query))
return loop.run_until_complete(task)
except RuntimeError:
# 没有事件循环,创建新的
return asyncio.run(self._aget_relevant_documents(query))
async def _aget_relevant_documents(
self, query: str, *, run_manager: Any = None
) -> List[Document]:
"""
异步混合检索相关文档
"""
# 1. 生成查询向量
dense_query = await self._vector_store.aembed_query(query)
sparse_query = self._sparse_embedder.embed_query(query)
sparse_vec = models.SparseVector(
indices=sparse_query["indices"],
values=sparse_query["values"]
)
# 2. 使用 Qdrant 的 query_points API
response = await self._client.query_points(
collection_name=self.collection_name,
prefetch=[
models.Prefetch(
query=dense_query,
using="dense",
limit=self.search_k
),
models.Prefetch(
query=sparse_vec,
using="sparse",
limit=self.search_k
)
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=self.search_k,
with_payload=True
)
# 3. 转换结果
results = []
for point in response.points:
doc = Document(
page_content=point.payload.pop("page_content", point.payload.pop("text", "")),
metadata=point.payload
)
results.append(doc)
debug(f"混合检索返回 {len(results)} 个文档")
return results
class ParentHybridRetriever(BaseRetriever):
"""
父子文档混合检索器异步
2026-05-05 23:17:00 +08:00
1. 先用混合检索找到相关子文档
2. 根据子文档的 parent_id 找到对应的父文档
3. 去重并返回父文档
"""
2026-05-05 23:17:00 +08:00
collection_name: str = Field(description="Qdrant 集合名称")
search_k: int = Field(default=DEFAULT_PARENT_SEARCH_K, description="检索返回结果数")
2026-05-05 23:17:00 +08:00
_vector_store: Any = PrivateAttr()
_client: Any = PrivateAttr()
_sparse_embedder: Any = PrivateAttr()
_docstore: Any = PrivateAttr()
2026-05-05 23:17:00 +08:00
def __init__(
self,
collection_name: str,
vector_store: QdrantHybridStore,
search_k: int = DEFAULT_PARENT_SEARCH_K,
docstore: Optional[Any] = None,
):
"""
Args:
collection_name: Qdrant 集合名称
vector_store: QdrantHybridStore 实例
search_k: 最终返回的父文档数量
docstore: 文档存储如果父文档在 PostgreSQL可选
"""
super().__init__(
collection_name=collection_name,
search_k=search_k
)
self._vector_store = vector_store
self._client = vector_store.get_async_qdrant_client()
self._sparse_embedder = get_sparse_embedder()
self._docstore = docstore
def _get_relevant_documents(
self, query: str, *, run_manager: Any = None
) -> List[Document]:
"""
同步检索不推荐使用仅供兼容性
注意在异步环境中请使用 _aget_relevant_documents ainvoke
"""
import asyncio
try:
loop = asyncio.get_running_loop()
task = loop.create_task(self._aget_relevant_documents(query))
return loop.run_until_complete(task)
except RuntimeError:
return asyncio.run(self._aget_relevant_documents(query))
async def _aget_relevant_documents(
self, query: str, *, run_manager: Any = None
) -> List[Document]:
"""
2026-05-05 23:17:00 +08:00
异步检索相关子文档
"""
# 1. 生成查询向量
dense_query = await self._vector_store.aembed_query(query)
sparse_query = self._sparse_embedder.embed_query(query)
sparse_vec = models.SparseVector(
indices=sparse_query["indices"],
values=sparse_query["values"]
)
2026-05-05 23:17:00 +08:00
# 2. 多取一些子文档,避免去重后数量不足
search_limit = self.search_k * 2
2026-05-05 23:17:00 +08:00
# 3. 使用 query_points API 进行混合检索
response = await self._client.query_points(
collection_name=self.collection_name,
prefetch=[
models.Prefetch(
query=dense_query,
using="dense",
limit=search_limit
),
models.Prefetch(
query=sparse_vec,
using="sparse",
limit=search_limit
)
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=search_limit,
with_payload=True
)
2026-05-05 23:17:00 +08:00
if not response.points:
debug("混合检索未找到任何文档")
return []
2026-05-05 23:17:00 +08:00
# 4. 构建子文档列表
child_docs = []
for point in response.points:
payload_copy = point.payload.copy()
2026-05-05 23:17:00 +08:00
doc = Document(
page_content=payload_copy.pop("page_content", payload_copy.pop("text", "")),
metadata={
**payload_copy,
"child_id": point.id,
"score": point.score
}
)
2026-05-05 23:17:00 +08:00
child_docs.append(doc)
debug(f"父子文档混合检索返回 {len(child_docs)} 个子文档")
return child_docs
2026-04-21 11:02:16 +08:00
def create_hybrid_retriever(
2026-04-21 11:02:16 +08:00
collection_name: str,
search_k: int = DEFAULT_SEARCH_K,
embeddings: Optional[Embeddings] = None,
2026-04-21 11:02:16 +08:00
) -> BaseRetriever:
"""
创建混合检索器稠密向量 + BM25 稀疏向量- 异步版本
这是默认推荐的检索方式效果最优
2026-04-21 11:02:16 +08:00
Args:
2026-04-29 10:52:01 +08:00
collection_name: Qdrant 集合名称
search_k: 检索返回结果数
embeddings: 可选的嵌入模型实例若未提供将自动获取统一嵌入服务
2026-04-21 11:02:16 +08:00
Returns:
HybridRetriever 实例
2026-04-21 11:02:16 +08:00
"""
2026-04-29 10:52:01 +08:00
if embeddings is None:
embeddings = get_embedding_service()
info("使用统一嵌入服务(本地 llama.cpp → 智谱 API 自动降级)")
vector_store = QdrantHybridStore(collection_name=collection_name)
2026-04-21 11:02:16 +08:00
try:
vector_store.get_client().get_collection(collection_name)
2026-04-21 11:02:16 +08:00
except UnexpectedResponse as e:
if e.status_code == 404:
2026-04-29 10:52:01 +08:00
warning(f"⚠️ Qdrant 集合 '{collection_name}' 不存在,请先创建并索引文档")
raise ValueError(f"Qdrant 集合 '{collection_name}' 不存在")
2026-04-21 11:02:16 +08:00
raise
info(f"✅ Qdrant 混合检索器初始化成功search_k={search_k}")
return HybridRetriever(
2026-04-21 11:02:16 +08:00
collection_name=collection_name,
vector_store=vector_store,
search_k=search_k
2026-04-21 11:02:16 +08:00
)
def create_parent_hybrid_retriever(
2026-04-21 11:02:16 +08:00
collection_name: str,
search_k: int = DEFAULT_PARENT_SEARCH_K,
embeddings: Optional[Embeddings] = None,
use_docstore: bool = True,
2026-04-21 11:02:16 +08:00
) -> BaseRetriever:
"""
创建父子文档混合检索器默认推荐- 异步版本
检索流程
1. 混合检索找到相关子文档
2. 根据 parent_id 找到对应的父文档
3. 去重并返回父文档
2026-04-21 11:02:16 +08:00
Args:
collection_name: Qdrant 集合名称
search_k: 最终返回的父文档数量
embeddings: 可选的嵌入模型实例
use_docstore: 是否使用 PostgreSQL docstore 存储父文档
2026-04-21 11:02:16 +08:00
Returns:
ParentHybridRetriever 实例
2026-04-21 11:02:16 +08:00
"""
if embeddings is None:
embeddings = get_embedding_service()
info("使用统一嵌入服务(本地 llama.cpp → 智谱 API 自动降级)")
vector_store = QdrantHybridStore(collection_name=collection_name)
try:
vector_store.get_client().get_collection(collection_name)
except UnexpectedResponse as e:
if e.status_code == 404:
warning(f"⚠️ Qdrant 集合 '{collection_name}' 不存在,请先创建并索引文档")
raise ValueError(f"Qdrant 集合 '{collection_name}' 不存在")
raise
docstore = None
if use_docstore:
try:
docstore, _ = create_docstore()
info("✅ 文档存储初始化成功PostgreSQL")
except Exception as e:
warning(f"⚠️ 文档存储初始化失败,将不使用 docstore: %s", e)
info(f"✅ Qdrant 父子文档混合检索器初始化成功search_k={search_k}")
return ParentHybridRetriever(
collection_name=collection_name,
vector_store=vector_store,
search_k=search_k,
docstore=docstore
)
def create_base_retriever(
collection_name: str,
search_k: int = DEFAULT_SEARCH_K,
embeddings: Optional[Embeddings] = None,
) -> BaseRetriever:
"""
创建基础检索器向后兼容- 实际上返回混合检索器
"""
return create_hybrid_retriever(collection_name, search_k, embeddings)
# 别名:默认就是父子文档混合检索
create_retriever = create_parent_hybrid_retriever