fix(worker): 知识写入 Milvus 必须探测字段名并补齐必填字段

背景:走 `POST /api/v1/knowledge/upload` 灌了 22 块场内基金知识,MySQL 全部写入成功、
向量事件也全部投递,却全部同步失败(重试 3 次进死信),客服检索不到新知识。
逐层定位出三个真问题,都在写入侧:

1) **字段名硬编码**。检索侧早已按 AGENTS.md 改用运行时探测
   (`app/core/knowledge_schema.py`:`doc_id`↔`knowledge_id`、`content`↔`snippet`),
   写侧却一直硬编码 `knowledge_id` / `snippet`。本机集合实际是
   `doc_id`/`content`/`chapter`/`visibility`/…(架构师那套 schema),于是
   `Attempt to insert an unexpected field knowledge_id` 整条失败。
   现在两侧共用 `resolve_schema` 的同一份映射表,调用方只用**逻辑**字段名;
   集合没有的字段(如另一套环境无 `intent`)跳过而不是报错。

2) **非 nullable 必填字段没给**。改完名字后报
   `Insert missed an field chapter to collection without set nullable==true`:
   集合里存在、调用方没提供的 VARCHAR 字段必须补值。VARCHAR 的判据用
   `params.max_length`,不必 import pymilvus 的枚举。

3) **补值不能一律空串**:`visibility` 留空会被检索侧
   `visibility == "public"` 的过滤整行排除。这条最隐蔽 —— `query` 查得到、`search`
   查不到,表现为「入库成功但客服永远答不出新知识」,比写入直接失败更难定位。
   现在按 `FIELD_DEFAULTS` 给有语义的字段默认值。

Worker 侧把 `fields` 的键改成逻辑名(`snippet` → `content`),上限表随之改名。

**测试**:原来的桩没有 `describe_collection`,所以这条路径从未覆盖到真机 schema
(这正是缺陷长期存在的原因)。现在桩提供两套真实 schema 并参数化,另加
「跳过集合没有的字段」「探测不出必要字段必须失败关闭」两个用例。

**验证**:22 块重新同步后 `fin_product_collection` 236 行、`visibility` 全为 `public`;
问「南方沪深300ETF 的起投金额是多少」命中新块 `score=0.7932`(≥ `HIGH_SCORE` 0.75),
客服从「转人工」变为直接作答,端到端 3.34 秒。

**另**:新增 `docs/43-场内基金产品手册(知识库入库版).md` —— 从 `docs/42` 抽出
**客户可见**的纯净内容(去掉全部内部决策备注)供入库;`docs/42` 保留为草稿与决策记录。
This commit is contained in:
2026-09-13 20:08:31 +08:00
parent 6f397ab513
commit fef4c826ff
5 changed files with 436 additions and 43 deletions
+8 -5
View File
@@ -105,9 +105,11 @@ Handler = Callable[[dict[str, Any]], Awaitable[None]]
#: 会超限并使整条 upsert 失败(实测:政策文档标题 346 字节 > 256,报
#: `length of varchar field title exceeds max length`)。FAQ 语料标题短,掩盖了这个缺陷;
#: 长标题的政策/产品文档一灌就炸。所以写入前必须逐个字段按字节截断。
#: 键是**逻辑字段名**(见 `MilvusKnowledgeWriter`):物理名由集合 schema 运行时探测决定
#: (两套环境分别叫 `content` 与 `snippet`),这里统一用 `content`。
VECTOR_FIELD_LIMITS: dict[str, int] = {
"title": 256,
"snippet": 4000,
"content": 4000,
"tags": 512,
"version": 16,
"intent": 32,
@@ -117,7 +119,7 @@ VECTOR_FIELD_LIMITS: dict[str, int] = {
def _fit_varchar(value: object, max_bytes: int) -> str:
"""把值转成字符串并按 **UTF-8 字节**截断到上限内(不切坏多字节字符)。
截断而非报错:`title`/`snippet` 只是检索辅助与展示字段,让整条向量同步因为字段过长
截断而非报错:`title`/`content` 只是检索辅助与展示字段,让整条向量同步因为字段过长
失败会阻塞知识入库;而正文的完整内容仍保存在 MySQL(权威源)。
"""
text = "" if value is None else str(value)
@@ -204,9 +206,10 @@ def build_knowledge_handlers(
raise RecoverableAgentError("嵌入维度与集合定义不一致")
fields: dict[str, Any] = {
"title": _fit_varchar(row.title, VECTOR_FIELD_LIMITS["title"]),
# snippet 是检索返回给模型的正文;必须是**本次**读到的 content_text,
# 否则正文更新后重投会留下旧答案(见模块 docstring 的幂等口径)。
"snippet": _fit_varchar(row.content_text, VECTOR_FIELD_LIMITS["snippet"]),
# `content` 是**逻辑**字段名,由 writer 按集合 schema 映射成 `content` 或 `snippet`。
# 它必须是**本次**读到的 content_text,否则正文更新后重投会留下旧答案
# (见模块 docstring 的幂等口径)。
"content": _fit_varchar(row.content_text, VECTOR_FIELD_LIMITS["content"]),
"tags": _fit_varchar(_tags_to_string(row.tags), VECTOR_FIELD_LIMITS["tags"]),
"version": _fit_varchar(
row.version or "", VECTOR_FIELD_LIMITS["version"]