174 lines
7.6 KiB
Python
174 lines
7.6 KiB
Python
"""Milvus 接入层(T21-2 · FLOW §2「milvus_tool」/ §3 知识库入库)。
|
||
|
||
Collection 设计(冻结):docs/项目框架设计/表设计/03-milvus-collections.md §2
|
||
`kb_product_rules`——产品手册/交易规则的语义检索,客户 + 代理人共用,
|
||
检索必带溯源字段(source_doc_id + source_version),且只返回已生效文档
|
||
(effective_date <= today,§2.3 合规约束)。
|
||
|
||
环境坑(2026-09-07 实测,含评审 P1 修正):pymilvus 3.x 在 **import 阶段**
|
||
load_dotenv() 并急切解析环境变量 MILVUS_URI,文件 URI(如 .env 的
|
||
./data/milvus.db)会让 ``import pymilvus`` 直接抛 Illegal uri。本模块的
|
||
防御顺序:先初始化 settings(干净读 .env),再把 os.environ 的 MILVUS_URI
|
||
归位为合法 http 占位(仅替换文件 URI),最后 import pymilvus——任何其他
|
||
顺序都会以 import 炸 / settings 遮蔽(真连接事故)两种方式踩坑。真实连接
|
||
恒走 MilvusClient(uri=...) 显式传参,不经过全局单例。
|
||
|
||
测试:单测用真 Milvus Lite(tempfile 临时 uri,module 级 fixture 共享),
|
||
本机实测可用(0904 交接 + 0907 复测);不做 mock 双轨。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
from app.config.settings import settings # noqa: E402
|
||
|
||
# ---- 环境防御(顺序敏感,评审 P1 修正):必须在 settings 初始化之后、
|
||
# from pymilvus import 之前。原因:pymilvus 3.x import 时 load_dotenv() +
|
||
# 急切解析 MILVUS_URI,.env 的文件 URI 会炸 import;但若先改 os.environ 再
|
||
# 初始化 settings,pydantic 会读到被污染的环境变量、把 .env 的 MILVUS_URI
|
||
# 也遮蔽掉(import 顺序敏感的真连接事故)。故口径:
|
||
# ① settings 先初始化(此时环境干净,milvus_uri 拿到 .env 正确值);
|
||
# ② 再把 os.environ 的 MILVUS_URI 归位为合法 http 占位(仅文件 URI 被换,
|
||
# 合法 http 值保留),pymilvus 全局单例不再炸 import;
|
||
# ③ 真实连接恒走 MilvusClient(uri=settings.milvus_uri) 显式传参。
|
||
_milvus_env_uri = os.environ.get("MILVUS_URI")
|
||
if _milvus_env_uri is None or not _milvus_env_uri.startswith(("http://", "https://")):
|
||
os.environ["MILVUS_URI"] = "http://localhost:19530"
|
||
|
||
from pymilvus import DataType, MilvusClient # noqa: E402
|
||
|
||
from app.config.settings import settings
|
||
|
||
COLLECTION_NAME = "kb_product_rules"
|
||
|
||
# effective_date 用 VARCHAR(10) 存 ISO "YYYY-MM-DD":Milvus 无原生 DATE 标量
|
||
# 类型;ISO 字符串序与日期序一致,filter 比较语义等价(口径登记,评审对照)
|
||
EFFECTIVE_DATE_LEN = 10
|
||
|
||
# 向量检索默认返回条数(TopK;对话 Tool 一期取 3,脚本/服务可覆盖)
|
||
DEFAULT_TOP_K = 3
|
||
|
||
|
||
def milvus_client() -> MilvusClient:
|
||
"""连接工厂(settings.milvus_uri;测试/脚本注入点)。
|
||
|
||
Milvus Lite:uri 为本地文件路径( MilvusClient 生成的是**目录**,
|
||
交接文档 §八 既有坑);Standalone:http://host:19530。
|
||
"""
|
||
return MilvusClient(uri=settings.milvus_uri)
|
||
|
||
|
||
def _kb_schema(dim: int):
|
||
"""kb_product_rules Collection schema(字段与 03-milvus-collections.md §2.1 一致)。"""
|
||
schema = MilvusClient.create_schema(auto_id=False, enable_dynamic_field=False)
|
||
schema.add_field("id", DataType.VARCHAR, is_primary=True, max_length=64) # {product_id}_{chunk_no}
|
||
schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=dim)
|
||
schema.add_field("product_id", DataType.VARCHAR, max_length=64)
|
||
schema.add_field("product_name", DataType.VARCHAR, max_length=256)
|
||
schema.add_field("doc_type", DataType.VARCHAR, max_length=32) # prospectus/fee/rule/risk
|
||
schema.add_field("risk_level", DataType.VARCHAR, max_length=2) # R1~R5
|
||
schema.add_field("source_doc_id", DataType.VARCHAR, max_length=64) # 溯源必填
|
||
schema.add_field("source_version", DataType.VARCHAR, max_length=32) # 溯源必填
|
||
schema.add_field("effective_date", DataType.VARCHAR, max_length=EFFECTIVE_DATE_LEN)
|
||
schema.add_field("chunk_text", DataType.VARCHAR, max_length=8192)
|
||
schema.add_field("chunk_no", DataType.INT64)
|
||
return schema
|
||
|
||
|
||
def ensure_collection(client: MilvusClient, dim: int | None = None) -> None:
|
||
"""幂等建集合 + 确保加载(exists → load;不存在 → 建 schema + 索引 + load)。
|
||
|
||
索引 AUTOINDEX + COSINE:Milvus Lite 支持 AUTOINDEX(自动选型),
|
||
COSINE 语义直观且 bge-m3 未归一化时也正确。dim 缺省取 settings.embed_dim,
|
||
与 embedding 服务同源——两边配置天然一致,不必各配一份。
|
||
|
||
load 口径(2026-09-07 实测坑):Milvus Lite 文件库**跨进程重开**后
|
||
collection 处于 released 状态,直接 search 报 MilvusException(code=101);
|
||
单测同进程覆盖不到,须每次 ensure 时显式 load_collection。
|
||
"""
|
||
dim = dim or settings.embed_dim
|
||
if client.has_collection(COLLECTION_NAME):
|
||
client.load_collection(COLLECTION_NAME)
|
||
return
|
||
index = client.prepare_index_params()
|
||
index.add_index(field_name="embedding", index_type="AUTOINDEX", metric_type="COSINE")
|
||
client.create_collection(
|
||
COLLECTION_NAME, schema=_kb_schema(dim), index_params=index
|
||
)
|
||
client.load_collection(COLLECTION_NAME)
|
||
|
||
|
||
def insert_chunks(client: MilvusClient, rows: list[dict]) -> int:
|
||
"""批量写入 chunk(id={product_id}_{chunk_no} 主键 → 重复导入自动覆盖)。
|
||
|
||
用 upsert 而非 insert:入库脚本重跑(文档改版重建向量)时按主键覆盖
|
||
旧块,不产生重复 id 报错。返回写入条数(rows 长度,Milvus 侧不二次校验)。
|
||
"""
|
||
if not rows:
|
||
return 0
|
||
client.upsert(COLLECTION_NAME, data=rows)
|
||
return len(rows)
|
||
|
||
|
||
def _escape(value: str) -> str:
|
||
"""filter 表达式字符串转义(单引号;防 product_id 等值破坏表达式)。"""
|
||
return value.replace("'", "\\'")
|
||
|
||
|
||
def search_kb(
|
||
client: MilvusClient,
|
||
query_vector: list[float],
|
||
*,
|
||
top_k: int = DEFAULT_TOP_K,
|
||
product_id: str | None = None,
|
||
doc_type: str | None = None,
|
||
effective_on: str | None = None,
|
||
) -> list[dict]:
|
||
"""向量检索 kb_product_rules(§2.3 合规约束内建在本函数)。
|
||
|
||
合规口径:只返回已生效文档——effective_date <= effective_on(缺省当天,
|
||
ISO 字符串比较)。product_id/doc_type 为可选标量过滤(检索某产品的
|
||
手册块 / 某类文档)。返回按相似度降序的扁平 list,每项含溯源字段
|
||
(source_doc_id/source_version)+ 原文 chunk_text + score(COSINE 距离,
|
||
越大越相似)。
|
||
"""
|
||
conditions = [f"effective_date <= '{_escape(effective_on or _today_iso())}'"]
|
||
if product_id:
|
||
conditions.append(f"product_id == '{_escape(product_id)}'")
|
||
if doc_type:
|
||
conditions.append(f"doc_type == '{_escape(doc_type)}'")
|
||
results = client.search(
|
||
COLLECTION_NAME,
|
||
data=[query_vector],
|
||
limit=top_k,
|
||
filter=" and ".join(conditions),
|
||
output_fields=[
|
||
"product_id",
|
||
"product_name",
|
||
"doc_type",
|
||
"risk_level",
|
||
"source_doc_id",
|
||
"source_version",
|
||
"effective_date",
|
||
"chunk_text",
|
||
"chunk_no",
|
||
],
|
||
)
|
||
hits = results[0] if results else []
|
||
return [
|
||
{
|
||
"id": hit["id"],
|
||
"score": float(hit["distance"]),
|
||
**hit.get("entity", {}),
|
||
}
|
||
for hit in hits
|
||
]
|
||
|
||
|
||
def _today_iso() -> str:
|
||
"""当天 ISO 日期(集中改点;时区收敛统一走 B8 挂账,与 core_tools._now_naive 同口径)。"""
|
||
import datetime as _dt
|
||
|
||
return _dt.date.today().isoformat()
|