Files
group_xinghuo_jinrong/app/service/milvus_service.py
T

174 lines
7.6 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 接入层(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()