256 lines
9.0 KiB
Python
256 lines
9.0 KiB
Python
"""双索引检索引擎 — embedding/keywords/blend 三模式。
|
||
|
||
- embedding: pgvector 语义检索(LlamaIndex PGVectorStore.aquery)
|
||
- keywords: PG 全文检索(jieba 分词 + tsquery_rank)
|
||
- blend: 并行执行两种检索,按分数归一化后合并去重排序
|
||
|
||
参考 LlamaIndex hybrid retriever 模式(VectorStoreRetriever + 全文检索)。
|
||
ACL 过滤由调用方在传入 `kb_ids` 前完成(见 acl.filter_kb_by_user_acl)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from typing import TYPE_CHECKING
|
||
|
||
from sqlalchemy import text
|
||
|
||
from agentkit.rag_platform.fulltext import KB_CHUNKS_TABLE, build_tsquery
|
||
from agentkit.rag_platform.models import QueryMode, QueryResult
|
||
|
||
if TYPE_CHECKING:
|
||
from llama_index.core.embeddings import BaseEmbedding
|
||
from llama_index.core.vector_stores.types import VectorStoreQuery
|
||
from llama_index.vector_stores.postgres import PGVectorStore
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ponytail: 默认归一化策略 — min-max,将每种检索的分数映射到 [0,1]
|
||
# 升级路径:若需更精细的融合权重,可引入 RRF (Reciprocal Rank Fusion) 或学习权重
|
||
_BLEND_WEIGHT_EMBEDDING = 0.6
|
||
_BLEND_WEIGHT_KEYWORDS = 0.4
|
||
|
||
|
||
def _normalize_scores(scores: list[float]) -> list[float]:
|
||
"""min-max 归一化分数到 [0, 1]。
|
||
|
||
空列表返回空。常数列表(含单元素)返回全 1.0 — 所有结果等价于最高相关度。
|
||
"""
|
||
if not scores:
|
||
return []
|
||
lo, hi = min(scores), max(scores)
|
||
if hi - lo < 1e-9:
|
||
return [1.0 for _ in scores]
|
||
return [(s - lo) / (hi - lo) for s in scores]
|
||
|
||
|
||
class RetrievalEngine:
|
||
"""双索引检索引擎 — embedding/keywords/blend 三模式。
|
||
|
||
Args:
|
||
vector_store: LlamaIndex PGVectorStore 实例(embedding 模式使用)
|
||
session_factory: SQLAlchemy async session factory(keywords 模式使用)
|
||
embed_model: LlamaIndex BaseEmbedding(embedding 模式使用)
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
vector_store: "PGVectorStore",
|
||
session_factory,
|
||
embed_model: "BaseEmbedding | None" = None,
|
||
) -> None:
|
||
self._vector_store = vector_store
|
||
self._sf = session_factory
|
||
self._embed_model = embed_model
|
||
|
||
async def retrieve(
|
||
self,
|
||
query: str,
|
||
kb_ids: list[str],
|
||
mode: QueryMode,
|
||
top_k: int = 5,
|
||
) -> list[QueryResult]:
|
||
"""检索入口 — 根据 mode 分发到具体实现。
|
||
|
||
Args:
|
||
query: 查询文本
|
||
kb_ids: 限定检索的知识库 ID 列表(已通过 ACL 过滤)
|
||
mode: 检索模式
|
||
top_k: 返回结果数
|
||
|
||
Returns:
|
||
QueryResult 列表(按分数降序)。无结果时返回空列表。
|
||
"""
|
||
if not kb_ids:
|
||
return []
|
||
|
||
if mode == QueryMode.embedding:
|
||
return await self._retrieve_embedding(query, kb_ids, top_k)
|
||
elif mode == QueryMode.keywords:
|
||
return await self._retrieve_keywords(query, kb_ids, top_k)
|
||
elif mode == QueryMode.blend:
|
||
return await self._retrieve_blend(query, kb_ids, top_k)
|
||
else: # pragma: no cover — 枚举已穷尽
|
||
raise ValueError(f"Unsupported query mode: {mode}")
|
||
|
||
async def _retrieve_embedding(
|
||
self,
|
||
query: str,
|
||
kb_ids: list[str],
|
||
top_k: int,
|
||
) -> list[QueryResult]:
|
||
"""pgvector 语义检索。
|
||
|
||
使用 LlamaIndex vector_store.aquery 执行向量检索,结果按 metadata.kb_id
|
||
过滤到 kb_ids 子集。
|
||
"""
|
||
if self._embed_model is None:
|
||
raise ValueError("embed_model is required for embedding retrieval mode")
|
||
|
||
from llama_index.core.vector_stores.types import VectorStoreQuery
|
||
|
||
query_embedding = await self._embed_model.aget_text_embedding(query)
|
||
vs_query: VectorStoreQuery = VectorStoreQuery(
|
||
query_embedding=query_embedding,
|
||
similarity_top_k=top_k,
|
||
)
|
||
result = await self._vector_store.aquery(vs_query)
|
||
|
||
kb_set = set(kb_ids)
|
||
out: list[QueryResult] = []
|
||
for node, score in zip(result.nodes, result.similarities):
|
||
meta = node.metadata or {}
|
||
node_kb = meta.get("kb_id", "")
|
||
if node_kb not in kb_set:
|
||
continue
|
||
out.append(
|
||
QueryResult(
|
||
chunk_id=node.node_id,
|
||
content=node.get_content(),
|
||
score=float(score) if score is not None else 0.0,
|
||
metadata=meta,
|
||
document_id=meta.get("document_id", ""),
|
||
kb_id=node_kb,
|
||
)
|
||
)
|
||
if len(out) >= top_k:
|
||
break
|
||
return out
|
||
|
||
async def _retrieve_keywords(
|
||
self,
|
||
query: str,
|
||
kb_ids: list[str],
|
||
top_k: int,
|
||
) -> list[QueryResult]:
|
||
"""PG 全文检索(jieba 分词 + tsquery)。
|
||
|
||
使用 `ts_rank(search_vector, tsquery)` 排序,按 kb_id 过滤。
|
||
kb_id 存储在 metadata_ JSON 列中(LlamaIndex PGVectorStore schema),
|
||
用 `metadata_->>'kb_id'` 提取。
|
||
"""
|
||
tsquery = build_tsquery(query)
|
||
if not tsquery:
|
||
return []
|
||
|
||
# ponytail: 用 ANY(%s) 传 kb_ids 列表,避免字符串拼接
|
||
# 升级路径:若 kb_ids 数量超过 PG 参数限制(32k),需分批查询
|
||
# 列名参考 LlamaIndex PGVectorStore 默认 schema:
|
||
# id / embedding / text / metadata_ (JSON) / document_id / search_vector
|
||
sql = text(
|
||
f"""
|
||
SELECT id, text, metadata_, document_id,
|
||
ts_rank(search_vector, to_tsquery('simple', :tsquery)) AS score
|
||
FROM {KB_CHUNKS_TABLE}
|
||
WHERE search_vector @@ to_tsquery('simple', :tsquery)
|
||
AND metadata_->>'kb_id' = ANY(:kb_ids)
|
||
ORDER BY score DESC
|
||
LIMIT :top_k
|
||
""" # noqa: S608 — 表名为常量
|
||
)
|
||
|
||
async with self._sf() as db:
|
||
result = await db.execute(
|
||
sql,
|
||
{
|
||
"tsquery": tsquery,
|
||
"kb_ids": list(kb_ids),
|
||
"top_k": top_k,
|
||
},
|
||
)
|
||
rows = result.all()
|
||
|
||
out: list[QueryResult] = []
|
||
for row in rows:
|
||
chunk_id, content, metadata, document_id, score = row
|
||
meta: dict[str, object] = metadata if isinstance(metadata, dict) else {}
|
||
kb_id = meta.get("kb_id", "")
|
||
out.append(
|
||
QueryResult(
|
||
chunk_id=str(chunk_id),
|
||
content=content or "",
|
||
score=float(score) if score is not None else 0.0,
|
||
metadata=meta,
|
||
document_id=str(document_id) if document_id is not None else "",
|
||
kb_id=str(kb_id) if kb_id is not None else "",
|
||
)
|
||
)
|
||
return out
|
||
|
||
async def _retrieve_blend(
|
||
self,
|
||
query: str,
|
||
kb_ids: list[str],
|
||
top_k: int,
|
||
) -> list[QueryResult]:
|
||
"""双索引合并 — 语义 + 全文结果去重排序。
|
||
|
||
并行执行两种检索(各取 top_k),按 chunk_id 去重,分数归一化后加权融合。
|
||
若任一检索失败(如 embed_model 未配置),降级为另一种。
|
||
"""
|
||
# 并行执行两种检索
|
||
embed_task = self._safe_retrieve_embedding(query, kb_ids, top_k)
|
||
kw_task = self._retrieve_keywords(query, kb_ids, top_k)
|
||
embed_results, kw_results = await asyncio.gather(
|
||
embed_task, kw_task, return_exceptions=False
|
||
)
|
||
|
||
# 归一化分数
|
||
embed_scores = _normalize_scores([r.score for r in embed_results])
|
||
for r, s in zip(embed_results, embed_scores):
|
||
r.score = s * _BLEND_WEIGHT_EMBEDDING
|
||
|
||
kw_scores = _normalize_scores([r.score for r in kw_results])
|
||
for r, s in zip(kw_results, kw_scores):
|
||
r.score = s * _BLEND_WEIGHT_KEYWORDS
|
||
|
||
# 合并去重 — 同 chunk_id 取最高分
|
||
merged: dict[str, QueryResult] = {}
|
||
for r in (*embed_results, *kw_results):
|
||
existing = merged.get(r.chunk_id)
|
||
if existing is None or r.score > existing.score:
|
||
merged[r.chunk_id] = r
|
||
|
||
# 按分数降序,取 top_k
|
||
results = sorted(merged.values(), key=lambda x: x.score, reverse=True)
|
||
return results[:top_k]
|
||
|
||
async def _safe_retrieve_embedding(
|
||
self,
|
||
query: str,
|
||
kb_ids: list[str],
|
||
top_k: int,
|
||
) -> list[QueryResult]:
|
||
"""embedding 检索的容错包装 — 失败时返回空列表(降级为纯关键词)。"""
|
||
if self._embed_model is None:
|
||
return []
|
||
try:
|
||
return await self._retrieve_embedding(query, kb_ids, top_k)
|
||
except Exception as e:
|
||
logger.warning("Embedding retrieval failed, falling back to keywords only: %s", e)
|
||
return []
|
||
|
||
|
||
__all__ = ["RetrievalEngine"]
|