Files
group_fqcd_jr/app/infrastructure/milvus_adapter.py
T

128 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""知识检索**读路径**的 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()