2026-09-20 14:33:30 +08:00
|
|
|
|
"""幂等创建知识检索用的四个 Milvus 集合(Task 6)。
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
|
|
|
|
|
用法::
|
|
|
|
|
|
|
|
|
|
|
|
.\\.venv\\Scripts\\python.exe tools\\setup_milvus_knowledge_collections.py
|
|
|
|
|
|
|
|
|
|
|
|
安全口径(共享 Milvus 实例,实例里还有别的项目在用的集合):
|
|
|
|
|
|
|
|
|
|
|
|
1. **幂等**:集合已存在则**直接跳过**,不重建、不覆盖、不清数据;
|
|
|
|
|
|
2. **不覆盖不同结构**:同名集合已存在但字段/维度与本脚本定义不同时,**停下来报告**
|
|
|
|
|
|
并以非零退出码结束 —— 宁可人工确认,也不动别人的数据;
|
2026-09-20 14:33:30 +08:00
|
|
|
|
3. 只处理 `ALLOWED_COLLECTIONS` 里的四个集合,集合名**不接受外部参数**。
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
2026-09-20 14:33:30 +08:00
|
|
|
|
schema 四个集合逐字相同,且**本模块是唯一权威定义**(`H-05` ④「两套建表脚本收敛为一套」):
|
|
|
|
|
|
|
|
|
|
|
|
| 字段 | 类型 / 长度 | 角色 |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `doc_id` | VARCHAR(64) | **主键** |
|
|
|
|
|
|
| `title` / `content` | VARCHAR(1024) / VARCHAR(16384) | 检索与输出 |
|
|
|
|
|
|
| `chapter` / `section` / `tags` | VARCHAR(512) | 定位与分组 |
|
|
|
|
|
|
| `doc_no` / `version` / `effective_date` / `expire_date` | VARCHAR(64/32/32/32) | 溯源与时效 |
|
|
|
|
|
|
| `source_url` / `reviewer` / `source_file` | VARCHAR(512/64/128) | 溯源与合规留痕 |
|
|
|
|
|
|
| `family_id` / `param_class` / `intent` | VARCHAR(64/16/32) | 同族 / 参数类型 / 意图(v1.4) |
|
|
|
|
|
|
| `visibility` | VARCHAR(16) | **分区键(档位隔离)** |
|
|
|
|
|
|
| `embedding` | FLOAT_VECTOR(dim=1024) | 向量 |
|
|
|
|
|
|
|
|
|
|
|
|
索引 `AUTOINDEX` + `metric_type="COSINE"`。**为什么收敛到这一套**:`app/core/knowledge_schema.py`
|
|
|
|
|
|
的 `FIELD_CANDIDATES` 优先取 `doc_id` / `content`(注释即写明"那是灌库脚本的正式设计名"),
|
|
|
|
|
|
`app/infrastructure/milvus_knowledge_writer.py` 的逻辑主键/正文名也是 `doc_id` / `content`,
|
|
|
|
|
|
`knowledge/_chunks.jsonl` 的产物字段同样是这一套 —— 旧脚本里的 `knowledge_id` / `snippet` /
|
|
|
|
|
|
`intent` 属**另一套环境的历史命名**,保留它等于让"同名集合两套字段"继续活着。
|
2026-09-11 14:37:20 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import sys
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.config import get_settings # noqa: E402
|
|
|
|
|
|
from app.core.knowledge_contracts import ALLOWED_COLLECTIONS, VECTOR_DIM # noqa: E402
|
|
|
|
|
|
|
2026-09-20 14:33:30 +08:00
|
|
|
|
PRIMARY_FIELD = "doc_id"
|
2026-09-11 14:37:20 +08:00
|
|
|
|
VECTOR_FIELD = "embedding"
|
|
|
|
|
|
INDEX_NAME = "knowledge_autoindex"
|
|
|
|
|
|
|
2026-09-20 14:33:30 +08:00
|
|
|
|
#: 档位字段 = **分区键**。集合内按档位物理分桶,检索按该字段过滤时由引擎做分区裁剪,
|
|
|
|
|
|
#: 不可见档位不进候选集 —— 这是"检索层硬隔离",不依赖上层自觉。
|
|
|
|
|
|
#:
|
|
|
|
|
|
#: ⚠️ 两条实测结论(2026-09-18,Milvus v2.5.3):
|
|
|
|
|
|
#: 1. 分区键模式下 **Milvus 禁止手工 `create_partition`**(报
|
|
|
|
|
|
#: `disable create partition if partition key mode is used`)⇒ 新增档位值
|
|
|
|
|
|
#: **不需要任何运维动作**,由引擎按哈希自动路由。设计文档里"档位值变更 = 建分区"
|
|
|
|
|
|
#: 的表述据此修正为"档位值变更 = 无需动作,引擎自动路由"。
|
|
|
|
|
|
#: 2. `upsert` / `insert` / 按分区键过滤的 `search` 在该模式下**均正常**
|
|
|
|
|
|
#: (已用抛废集合实测),所以写入侧(走 `upsert`)不受影响。
|
|
|
|
|
|
PARTITION_KEY_FIELD = "visibility"
|
|
|
|
|
|
|
|
|
|
|
|
#: 分区桶数。**创建后不可改**,所以宁可一次给足:档位枚举预计会增长,16 桶可保证
|
|
|
|
|
|
#: 不同档位值大概率落在不同桶、裁剪真正生效;600+ 行规模下多桶开销可忽略。
|
|
|
|
|
|
NUM_PARTITIONS = 16
|
|
|
|
|
|
|
2026-09-11 14:37:20 +08:00
|
|
|
|
#: (字段名, VARCHAR 最大长度);顺序与写入侧口径一致。
|
2026-09-20 14:33:30 +08:00
|
|
|
|
#:
|
|
|
|
|
|
#: 这份字段表是**唯一权威**:灌库脚本(`tools/load_knowledge_milvus.py`)从本模块
|
|
|
|
|
|
#: import `VARCHAR_FIELDS` / `FIELD_LIMITS` 做截断,**不再自带第二份定义**(`H-05` ④)。
|
|
|
|
|
|
#: 长度按**实际数据最坏情况**定:`content` 取 16384(实测最长块 2828 字符,留足余量),
|
|
|
|
|
|
#: `title` 取 1024(含完整章节路径)。
|
2026-09-11 14:37:20 +08:00
|
|
|
|
VARCHAR_FIELDS: tuple[tuple[str, int], ...] = (
|
|
|
|
|
|
(PRIMARY_FIELD, 64),
|
2026-09-20 14:33:30 +08:00
|
|
|
|
("title", 1024),
|
|
|
|
|
|
("content", 16384),
|
|
|
|
|
|
("chapter", 512),
|
|
|
|
|
|
("section", 512),
|
2026-09-11 14:37:20 +08:00
|
|
|
|
("tags", 512),
|
2026-09-20 14:33:30 +08:00
|
|
|
|
("doc_no", 64),
|
|
|
|
|
|
("version", 32),
|
|
|
|
|
|
("effective_date", 32),
|
|
|
|
|
|
("expire_date", 32),
|
|
|
|
|
|
("source_url", 512),
|
|
|
|
|
|
("reviewer", 64),
|
|
|
|
|
|
("source_file", 128),
|
|
|
|
|
|
# v1.4(2026-09-18):`D2.4` 附录F 的「同族合并 / 计算型参数位 / 意图标签」三条能力
|
|
|
|
|
|
# 需要它们。`family_id` 64 足够(父块编号最长 `POL-AST-012` 这类);`param_class`
|
|
|
|
|
|
# 是固定枚举 `none/rate/threshold/scale/count`;`intent` 是五类业务意图。
|
|
|
|
|
|
("family_id", 64),
|
|
|
|
|
|
("param_class", 16),
|
2026-09-11 14:37:20 +08:00
|
|
|
|
("intent", 32),
|
2026-09-20 14:33:30 +08:00
|
|
|
|
(PARTITION_KEY_FIELD, 16),
|
2026-09-11 14:37:20 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-20 14:33:30 +08:00
|
|
|
|
#: 字段名 → 最大长度。写入侧据此截断,**与集合定义同源**,避免"脚本截到 500、
|
|
|
|
|
|
#: 集合只给 256"这类只在写入时才暴露的错配。
|
|
|
|
|
|
FIELD_LIMITS: dict[str, int] = dict(VARCHAR_FIELDS)
|
|
|
|
|
|
|
|
|
|
|
|
#: 允许缺省的字段。**空集 = 全部必填**:VARCHAR 由写入侧补空串
|
|
|
|
|
|
#: (`MilvusKnowledgeWriter` 的既有行为),分区键字段则要求**显式声明**——
|
|
|
|
|
|
#: `visibility` 缺省不是"写空串"而是"不计入任何档位",属档位越权风险,
|
|
|
|
|
|
#: 必须在入库门禁(`B-01`)就挡住,不能靠集合默认值兜底。
|
|
|
|
|
|
NULLABLE_FIELDS: frozenset[str] = frozenset()
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def expected_fields() -> dict[str, dict[str, Any]]:
|
2026-09-20 14:33:30 +08:00
|
|
|
|
"""本脚本期望的字段结构:`{字段名: {type, is_primary, is_partition_key, max_length?, dim?}}`。"""
|
2026-09-11 14:37:20 +08:00
|
|
|
|
fields: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
for name, max_length in VARCHAR_FIELDS:
|
|
|
|
|
|
fields[name] = {
|
|
|
|
|
|
"type": "VARCHAR",
|
|
|
|
|
|
"max_length": max_length,
|
|
|
|
|
|
"is_primary": name == PRIMARY_FIELD,
|
2026-09-20 14:33:30 +08:00
|
|
|
|
"is_partition_key": name == PARTITION_KEY_FIELD,
|
2026-09-11 14:37:20 +08:00
|
|
|
|
}
|
2026-09-20 14:33:30 +08:00
|
|
|
|
fields[VECTOR_FIELD] = {
|
|
|
|
|
|
"type": "FLOAT_VECTOR",
|
|
|
|
|
|
"dim": VECTOR_DIM,
|
|
|
|
|
|
"is_primary": False,
|
|
|
|
|
|
"is_partition_key": False,
|
|
|
|
|
|
}
|
2026-09-11 14:37:20 +08:00
|
|
|
|
return fields
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def describe_mismatch(described: Any) -> list[str]:
|
|
|
|
|
|
"""比较 Milvus `describe_collection` 结果与期望 schema,返回差异说明(一致时为空)。"""
|
|
|
|
|
|
if not isinstance(described, dict): # pragma: no cover - 防御:返回结构异常
|
|
|
|
|
|
return [f"无法解析集合描述:{type(described).__name__}"]
|
|
|
|
|
|
raw_fields = described.get("fields")
|
|
|
|
|
|
if not isinstance(raw_fields, list): # pragma: no cover - 防御:返回结构异常
|
|
|
|
|
|
return ["集合描述缺少 fields"]
|
|
|
|
|
|
|
|
|
|
|
|
actual: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
for raw in raw_fields:
|
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
name = str(raw.get("name", ""))
|
|
|
|
|
|
params = raw.get("params") if isinstance(raw.get("params"), dict) else {}
|
|
|
|
|
|
actual[name] = {
|
|
|
|
|
|
"type": raw.get("type"),
|
|
|
|
|
|
"params": params,
|
|
|
|
|
|
"is_primary": bool(raw.get("is_primary")),
|
2026-09-20 14:33:30 +08:00
|
|
|
|
"is_partition_key": bool(raw.get("is_partition_key")),
|
2026-09-11 14:37:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
problems: list[str] = []
|
2026-09-20 14:33:30 +08:00
|
|
|
|
# 分区桶数也是结构的一部分,而且**创建后不可改** —— 不一致必须报出来,
|
|
|
|
|
|
# 否则"看起来建好了"的集合其实没有按预期分桶、分区裁剪名存实亡。
|
|
|
|
|
|
actual_partitions = described.get("num_partitions")
|
|
|
|
|
|
if isinstance(actual_partitions, int) and actual_partitions != NUM_PARTITIONS:
|
|
|
|
|
|
problems.append(
|
|
|
|
|
|
f"num_partitions 不一致(实际 {actual_partitions},期望 {NUM_PARTITIONS})"
|
|
|
|
|
|
)
|
2026-09-11 14:37:20 +08:00
|
|
|
|
for name, spec in expected_fields().items():
|
|
|
|
|
|
found = actual.get(name)
|
|
|
|
|
|
if found is None:
|
|
|
|
|
|
problems.append(f"缺少字段 {name}")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if found["is_primary"] != spec["is_primary"]:
|
|
|
|
|
|
problems.append(f"字段 {name} 主键标记不一致(实际 {found['is_primary']})")
|
2026-09-20 14:33:30 +08:00
|
|
|
|
if found["is_partition_key"] != spec["is_partition_key"]:
|
|
|
|
|
|
problems.append(
|
|
|
|
|
|
f"字段 {name} 分区键标记不一致"
|
|
|
|
|
|
f"(实际 {found['is_partition_key']},期望 {spec['is_partition_key']})"
|
|
|
|
|
|
)
|
2026-09-11 14:37:20 +08:00
|
|
|
|
if name == VECTOR_FIELD:
|
|
|
|
|
|
dim = found["params"].get("dim")
|
|
|
|
|
|
if not isinstance(dim, int) or dim != VECTOR_DIM:
|
|
|
|
|
|
problems.append(f"字段 {name} 维度不一致(实际 {dim},期望 {VECTOR_DIM})")
|
|
|
|
|
|
continue
|
|
|
|
|
|
length = found["params"].get("max_length")
|
|
|
|
|
|
if length != spec["max_length"]:
|
|
|
|
|
|
problems.append(f"字段 {name} 长度不一致(实际 {length},期望 {spec['max_length']})")
|
|
|
|
|
|
return problems
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-20 14:33:30 +08:00
|
|
|
|
def build_schema() -> Any:
|
|
|
|
|
|
"""构造四个集合的统一 schema。**公开**:灌库脚本直接 import 本函数,
|
|
|
|
|
|
以保证"建表的字段定义"与"写库的字段填充"是同一份声明(`H-05` ④)。
|
|
|
|
|
|
"""
|
2026-09-11 14:37:20 +08:00
|
|
|
|
from pymilvus import DataType, MilvusClient # type: ignore[import-untyped]
|
|
|
|
|
|
|
|
|
|
|
|
schema = MilvusClient.create_schema(auto_id=False, enable_dynamic_field=False)
|
|
|
|
|
|
for name, max_length in VARCHAR_FIELDS:
|
|
|
|
|
|
schema.add_field(
|
|
|
|
|
|
field_name=name,
|
|
|
|
|
|
datatype=DataType.VARCHAR,
|
|
|
|
|
|
max_length=max_length,
|
|
|
|
|
|
is_primary=(name == PRIMARY_FIELD),
|
2026-09-20 14:33:30 +08:00
|
|
|
|
# 档位字段是**分区键**(每集合只能有一个,且不可空)。
|
|
|
|
|
|
is_partition_key=(name == PARTITION_KEY_FIELD),
|
|
|
|
|
|
# 只有被显式列为可缺省的字段允许为空;分区键字段永远不可空。
|
2026-09-11 14:37:20 +08:00
|
|
|
|
nullable=name in NULLABLE_FIELDS,
|
|
|
|
|
|
)
|
|
|
|
|
|
schema.add_field(field_name=VECTOR_FIELD, datatype=DataType.FLOAT_VECTOR, dim=VECTOR_DIM)
|
|
|
|
|
|
return schema
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-20 14:33:30 +08:00
|
|
|
|
def build_index_params() -> Any:
|
|
|
|
|
|
"""构造向量索引参数(`AUTOINDEX` + `COSINE`)。**公开**,理由同 `build_schema`。"""
|
2026-09-11 14:37:20 +08:00
|
|
|
|
from pymilvus import MilvusClient # type: ignore[import-untyped]
|
|
|
|
|
|
|
|
|
|
|
|
index_params = MilvusClient.prepare_index_params()
|
|
|
|
|
|
index_params.add_index(
|
|
|
|
|
|
field_name=VECTOR_FIELD,
|
|
|
|
|
|
index_name=INDEX_NAME,
|
|
|
|
|
|
index_type="AUTOINDEX",
|
|
|
|
|
|
metric_type="COSINE",
|
|
|
|
|
|
)
|
|
|
|
|
|
return index_params
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def ensure_collections(uri: str, token: str = "") -> tuple[list[str], list[str], list[str]]:
|
2026-09-20 14:33:30 +08:00
|
|
|
|
"""确保四个集合存在。返回 `(创建, 已存在, 结构冲突)`;结构冲突时不覆盖。"""
|
2026-09-11 14:37:20 +08:00
|
|
|
|
from pymilvus import AsyncMilvusClient # type: ignore[import-untyped]
|
|
|
|
|
|
|
|
|
|
|
|
names = sorted(ALLOWED_COLLECTIONS)
|
|
|
|
|
|
created: list[str] = []
|
|
|
|
|
|
existed: list[str] = []
|
|
|
|
|
|
conflicting: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
client = AsyncMilvusClient(uri=uri, token=token or None)
|
|
|
|
|
|
try:
|
|
|
|
|
|
for name in names:
|
|
|
|
|
|
if await client.has_collection(name):
|
|
|
|
|
|
described = await client.describe_collection(name)
|
|
|
|
|
|
problems = describe_mismatch(described)
|
|
|
|
|
|
if problems:
|
|
|
|
|
|
conflicting.append(f"{name}: {';'.join(problems)}")
|
|
|
|
|
|
print(f"[冲突] {name} 已存在但结构不同,未覆盖 -> {';'.join(problems)}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
existed.append(name)
|
|
|
|
|
|
print(f"[跳过] {name} 已存在且结构一致")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
await client.create_collection(
|
|
|
|
|
|
collection_name=name,
|
2026-09-20 14:33:30 +08:00
|
|
|
|
schema=build_schema(),
|
|
|
|
|
|
index_params=build_index_params(),
|
|
|
|
|
|
num_partitions=NUM_PARTITIONS,
|
2026-09-11 14:37:20 +08:00
|
|
|
|
)
|
|
|
|
|
|
await client.load_collection(name)
|
|
|
|
|
|
created.append(name)
|
|
|
|
|
|
print(f"[创建] {name} 已创建并加载")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await client.close()
|
|
|
|
|
|
return created, existed, conflicting
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
created, existed, conflicting = asyncio.run(
|
|
|
|
|
|
ensure_collections(settings.milvus_uri, settings.milvus_token)
|
|
|
|
|
|
)
|
|
|
|
|
|
print(f"创建 {len(created)} 个:{created}")
|
|
|
|
|
|
print(f"跳过 {len(existed)} 个:{existed}")
|
|
|
|
|
|
if conflicting:
|
|
|
|
|
|
print("存在同名不同结构的集合,已停止且未做任何覆盖,请人工确认:")
|
|
|
|
|
|
for line in conflicting:
|
|
|
|
|
|
print(f" - {line}")
|
|
|
|
|
|
return 2
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover - CLI 入口
|
|
|
|
|
|
raise SystemExit(main())
|