Files
group_fqcd_jr/app/infrastructure/milvus_adapter.py
T

128 lines
5.3 KiB
Python
Raw Normal View History

"""知识检索**读路径**的 Milvus 适配器(Task 6)。
与写路径的物理隔离(`app/infrastructure/milvus_knowledge_writer.py`):
- 本模块**只读**(`search` + `describe_collection`),绝不 import 写路径模块 ——
检索进程不持有写客户端,向量库故障不会从写侧传染到问答主链路,反之亦然;
- 检索侧只接受 `ALLOWED_COLLECTIONS` 内的集合名,校验由调用方
(`KnowledgeRetrievalService._assert_collections_allowed`)在**任何网络调用之前**完成,
本适配器只做"第二道闸"式的兜底拒绝。
连接是**惰性**的:`__init__` 不连 Milvus,首次检索才 `import pymilvus` 并建
`AsyncMilvusClient`。任何连接/查询失败统一转成 `RecoverableAgentError`,
由 `KnowledgeRetrievalService` 捕获后降级 MySQL LIKE —— 本层不吞异常、不返回空结果,
避免把"向量库挂了"伪装成"知识库没有内容"。
"""
from __future__ import annotations
from typing import Any
from app.core.errors import ForbiddenAgentError, RecoverableAgentError
from app.core.knowledge_contracts import ALLOWED_COLLECTIONS
#: 返回给上层的标量字段(不含 `embedding`:向量不回传,省带宽也避免误用)。
DEFAULT_OUTPUT_FIELDS: tuple[str, ...] = (
"knowledge_id",
"title",
"snippet",
"tags",
"version",
"intent",
)
#: 一次检索最多返回的候选数下限/上限,防调用方传入异常 top_k。
MIN_TOP_K = 1
MAX_TOP_K = 50
class MilvusKnowledgeClient:
"""知识向量读边界:`search` 失败一律 `RecoverableAgentError`,由上层降级。"""
def __init__(self, uri: str, token: str = "") -> None:
self._uri = uri
self._token = token
self._client: Any = None
async def _ensure(self) -> Any:
if self._client is None:
try:
from pymilvus import AsyncMilvusClient # type: ignore[import-untyped]
except ImportError as exc: # pragma: no cover - 依赖已声明,缺装是环境问题
raise RecoverableAgentError("pymilvus 未安装,无法检索知识向量") from exc
try:
self._client = AsyncMilvusClient(uri=self._uri, token=self._token or None)
except Exception as exc:
raise RecoverableAgentError("Milvus 读客户端初始化失败") from exc
return self._client
async def search(
self,
*,
collection: str,
vector: list[float],
top_k: int,
output_fields: tuple[str, ...] | list[str] = DEFAULT_OUTPUT_FIELDS,
) -> list[dict[str, Any]]:
"""向量检索,返回**扁平化**的命中行(每行含标量字段与 `score`)。
pymilvus 返回的是 `[[{id, distance, entity}]]`(每查询一组),这里折叠为单层列表;
距离字段名可能是 `distance` 或 `score`,两种都吸收(COSINE 越大越相似)。
"""
if collection not in ALLOWED_COLLECTIONS:
raise ForbiddenAgentError(f"知识集合不在白名单内:{collection}")
if not vector:
raise RecoverableAgentError("检索向量不能为空")
client = await self._ensure()
try:
raw = await client.search(
collection_name=collection,
data=[list(vector)],
limit=max(MIN_TOP_K, min(int(top_k), MAX_TOP_K)),
output_fields=list(output_fields),
search_params={"metric_type": "COSINE"},
)
except Exception as exc:
raise RecoverableAgentError(f"知识向量检索失败:{collection}") from exc
return self.parse_hits(raw)
@staticmethod
def parse_hits(raw: Any) -> list[dict[str, Any]]:
"""把 pymilvus 的嵌套命中结构折叠为 `list[dict]`(纯函数,不抛异常)。"""
rows: list[dict[str, Any]] = []
for group in raw if isinstance(raw, list | tuple) else [raw]:
items = group if isinstance(group, list | tuple) else [group]
for item in items:
row = MilvusKnowledgeClient._as_row(item)
if row is not None:
rows.append(row)
return rows
@staticmethod
def _as_row(item: Any) -> dict[str, Any] | None:
if isinstance(item, dict):
fields = dict(item)
elif hasattr(item, "entity") or hasattr(item, "id"):
fields = {}
for name in (*DEFAULT_OUTPUT_FIELDS, "id", "distance", "score", "entity"):
if hasattr(item, name):
fields[name] = getattr(item, name)
else:
return None
entity = fields.pop("entity", None)
if isinstance(entity, dict):
merged = dict(entity)
merged.update({key: value for key, value in fields.items() if value is not None})
fields = merged
knowledge_id = fields.get("knowledge_id") or fields.get("id")
if knowledge_id is None or not str(knowledge_id).strip():
# 没有主键的行无法回表校验,丢弃而不是猜造标识(避免假引用)。
return None
fields["knowledge_id"] = str(knowledge_id).strip()
return fields
async def close(self) -> None:
if self._client is not None:
client, self._client = self._client, None
await client.close()