Files
group_fqcd_jr/客服agent/D2.8-客服Agent知识库RAG全链路与选型说明-2026-09-20.md
T
张胜宇 921de2cfc3 docs(W17): 知识库 RAG 全链路与选型说明成文(D2.8)—— 按八阶段讲清解析/切片/检索增强 + 4 张流程图
新增 客服agent\D2.8-客服Agent知识库RAG全链路与选型说明-2026-09-20.md(域 2,D2.x 续号,683 行)。

- 按流程逐步:语料与解析 → 切片 → 向量化 → 存储 → 入库七步 → 在线八步 → 检索增强 7 个动作 → 阈值与五出口。
- 4 张 Mermaid 图:端到端全景 / 切片与派生字段 / 检索增强 / 出口决策树。
- 切片写透:叶子标题策略(对照三种真实情况)、表格逐行拆自解释小块(父子块)、表头 bug 与自检守卫。
- 检索增强 7 动作:向量召回 / 字面召回(专有名词,条件触发)/ 父块带回 ×0.9 / doc_id 去重 / 兄弟子块归并 / 整节块保底席位 / 证据包三种形态。
- 阈值取证:0.75/0.55/0.07/0.40/0.5,MIN_GAP 实测标定区间 (0.0649, 0.0759)。
- 选型原因:8 项决策 26 备选,逐项含否决理由与演进触发条件。
- 实测:切片件 675 块,Milvus 四集合 count(*) 150/191/288/46 与切片件逐集合一致,索引全 Finished。
- 新登记 3 项不一致(只登记不修复):① 文档写 HNSW/IVF_FLAT 而实库是 AUTOINDEX;② tools\configure_embedding_endpoint.py 会建出第二个 embedding 端点(重跑可能造成索引与查询不同模型的无声质量崩塌);③ agent_faq_synonym 表检索链路不读(术语归一化未落地)。
- 计数同步:D1.1 v1.6→v1.7,59→60 份(58→59 份编号),客服agent\ 7→8 份;§4.0 总表与 §4.1 明细各新增 D2.8 行;注入校验 55→56 份;新增 §26 轮次段。
- D2.1 标题 v6.31→v6.32;D1.6 新增 §4.45(含一处自我更正:误读旧 JSON 导致"实库缺 family_id/param_class/intent"的错误结论)。
- 本轮不改代码、未跑金标评测;门禁:check_authoritative_docs.py 通过 + _consistency.py GATE PASS。
2026-09-20 16:58:42 +08:00

44 KiB
Raw Blame History

客服 Agent 知识库 RAG 全链路解析与选型说明(2026-09-20)

体系编号:D2.8 · 域:二、对外交付 · 编号体系见 D1.1 §4.0 读者:答辩评委 + 需要接手知识库的人 + 三个月后的自己。 性质:把「解析 → 切片 → 向量化 → 入库 → 检索 → 增强 → 判定」这条链路按流程逐步讲清,每一步回答三件事:做了什么 / 为什么这么做 / 不这么做会怎样。 口径:所有结论均为逐行读码 + 实库实测(2026-09-20)。凡「设计文档写了但代码没做」的,一律在 §12 单列,不混进流程里当好话讲。 配套:D2.4(知识库设计方案 · 权威)、D3.2(完整版 · 含 26 个备选否决理由)、D3.5(检索升级备选池)、D3.7(金标与判分)、D2.6(答辩主文档)、D2.7(记忆与画像联动)。


0. 一句话总览

这不是「把文档丢进向量库然后问它」,而是一条有 4 道门禁、3 路召回、3 道去重、5 个出口的受控流水线。

一句话概括每一段:

阶段 一句话
解析 按文档形态分两族:Markdown 走标题层级解析,FAQ 走制表符两列解析;不是 PDF/OCR 那套通用解析
切片 按叶子标题切(其后没有更深标题的标题),表格逐行再拆自解释小块 —— 让「起投多少」不再命中整节说明书
向量化 text-embedding-v3 · 1024 维 · 端点由发布配置决定,维度不符直接失败关闭
入库 七步流水线,第 7 步抽样校验不可省(档位分布 / 越权抽检 / 召回抽检 / 剔除核对)
检索 在线八步;visibility 是分区键,档位在 Milvus 引擎侧裁剪,不是应用层过滤
增强 三路召回(向量 / 字面专有名词 / 父块带回)+ 三道去重 + 证据包构建,共 7 个动作
判定 阈值 0.75 / 0.55 / gap 0.07 → 五出口 E1—E5;答不了可以,答错不行

1. 端到端全景流程图

flowchart TD
    subgraph S1["① 语料层 knowledge/"]
        S1A["Markdown 源 7 份<br/>basic ×2 / company ×1<br/>policy ×2 / product ×2"]
        S1B["FAQ 纯文本 1 份<br/>制表符两列 65 组"]
        S1C["显式剔除<br/>反洗钱手册(内部机密)<br/>高净值规范 三章以后(内部管理)"]
    end

    subgraph S2["② 解析"]
        S2A["chunk_markdown<br/>保留标题层级 H1—H6"]
        S2B["chunk_qa<br/>问 / 答 两列"]
    end

    subgraph S3["③ 切片"]
        S3A["叶子标题切分<br/>其后无更深标题者 = 切分点"]
        S3B["标题路径 stack<br/>完整上级链 · 用 ' · ' 连接"]
        S3C["表格逐行拆自解释小块<br/>产品名:标签 值<br/>父块保留"]
        S3D["守卫:正文完全相同的块必须为 0<br/>否则中止,不写 jsonl"]
    end

    subgraph S4["④ 派生字段"]
        S4A["family_id<br/>去掉 -NN 两位子块后缀"]
        S4B["param_class<br/>rate/threshold/scale/count"]
        S4C["intent<br/>按集合映射五类"]
    end

    subgraph S5["⑤ 向量化"]
        S5A["text-embedding-v3"]
        S5B["dim = 1024<br/>不符 → 失败关闭"]
    end

    subgraph S6["⑥ Milvus 存储"]
        S6A["4 个集合<br/>faq / product / policy / basic"]
        S6B["18 字段全 NOT NULL"]
        S6C["visibility = 分区键<br/>num_partitions = 16"]
        S6D["AUTOINDEX + COSINE"]
    end

    subgraph S7["⑦ 在线检索"]
        S7A["tiers 必填无默认值<br/>缺失即 TypeError"]
        S7B["visibility in [...]<br/>检索层硬隔离"]
        S7C["缺 visibility 字段的集合<br/>受限档位下一律排除 fail-closed"]
    end

    subgraph S8["⑧ 检索增强(7 个动作)"]
        S8A["A1 向量召回<br/>三主集合 top_k"]
        S8B["A2 字面召回<br/>专有名词 · 条件触发"]
        S8C["A3 父块带回<br/>粒度补偿 ×0.9"]
        S8D["A4 doc_id 去重"]
        S8E["A5 兄弟子块归并<br/>只留最高分"]
        S8F["A6 整节块保底席位"]
        S8G["A7 证据包构建<br/>同章节组 / 跨章节近分 / 组内外合并"]
    end

    subgraph S9["⑨ 判定与出口"]
        S9A["E3 原文直返<br/>零模型调用"]
        S9B["E4 证据约束生成<br/>三道输出校验"]
        S9C["E1 澄清 / E2 计算型"]
        S9D["E5b 部分答 + 引导<br/>E5c 显式转人工"]
    end

    S1A --> S2A
    S1B --> S2B
    S1C -.->|不入库| S2A
    S2A --> S3A
    S2B --> S3A
    S3A --> S3B --> S3C --> S3D
    S3D --> S4A --> S4B --> S4C
    S4C --> S5A --> S5B
    S5B --> S6A
    S6A --> S6B --> S6C --> S6D
    S6D --> S7A --> S7B --> S7C
    S7C --> S8A
    S8A --> S8B --> S8C --> S8D --> S8E --> S8F --> S8G
    S8G --> S9A
    S8G --> S9B
    S8G --> S9C
    S8G --> S9D

读图要点:安全在前、增强在后。档位裁剪(S7C)发生在一切召回之前 —— 这是 D2.4 全文最重要的一条主线:「把权限边界从提示词层移到检索层」。后面 7 个增强动作一个都不碰权限。


2. 阶段一 · 语料与解析

2.1 语料构成(实测)

客服agent\knowledge\
├─ basic\   基金基础知识.md            6.1 KB
│           基金交易与时限常识.md      6.4 KB
├─ company\ 企业信息.md               12.7 KB
├─ faq\     高频问答对.txt            25.5 KB   ← 制表符两列,65 组
├─ policy\  个人投资者适当性管理指南.md 22.5 KB
│           理财产品销售管理办法.md    28.8 KB
└─ product\ 个人理财产品手册.md        22.8 KB
            高净值客户服务规范.md      24.4 KB

2.2 为什么是「两族解析器」而不是一套通用解析器

文档形态 解析器 依据
Markdown(7 份) chunk_markdown —— 保留标题层级 这些文档的价值全在层级上(「第一章 → 第九条 → 9.1」),丢了层级就丢了归属
FAQ 纯文本(1 份) chunk_qa —— 按 \t 切「问 / 答」 它本来就是一行一条的问答对,没有层级可保;强行当 Markdown 解析会全部挤成一块

🔑 本项目没有 PDF / OCR / 版面还原这一层 —— 语料是已整理的 Markdown / TXT。答辩时不要把「解析」讲成"我们做了复杂版面分析",那是不实的。这里的解析难点不在格式识别,而在层级归属(见 §3)。

2.3 显式剔除(不解析,直接不入库)

被剔除者 剔除理由 落点
反洗钱合规操作手册.md 标注内部机密;第十六条禁止向客户透露可疑交易信息;本业务只做公募基金、不涉及资金划划付 ⇒ 混入面向客户的知识库存在制度性冲突 tools\build_knowledge_chunks.py 的 SOURCES 注释
高净值客户服务规范.md 第三章及以后 家族信托 / 资产配置流程 / 客户经理考核 / 隐私应急预案属内部管理内容,客户咨询用不到 allow_chapters: ["一、", "二、"] 章节白名单

这是"解析"这一步真正的业务决策:解析不是"能读就读",而是先决定哪些内容不该进客户侧知识库。


3. 阶段二 · 切片(本项目最"有讲究"的一步)

3.1 主策略:叶子标题切分

定义:一个标题若其后没有更深的标题,它就是一个切分点。

为什么不用「按固定级别切」(比如一律按 ###) —— 三种真实情况会同时崩:

真实情况 例子 固定级别切的后果 叶子策略
条款带子条款 反洗钱第九条 → 9.1—9.4 按 ### 切 → 子条款被拆散 按子条款切,粒度更细
条款不带子条款 反洗钱第十一条 按 ### 切 → 被并进上一条 自己就是叶子,单独成块
章没有小节 企业信息「一、公司基本信息」 按 ### 切 → 整章内容丢失 章本身就是叶子

实现:tools\build_knowledge_chunks.py::leaf_split_points(判据 = 下一个标题的层级是否更深)。

3.2 标题路径:块的「身份证」

每块都带一条完整上级链,用 · 连接:

个人投资者适当性管理指南 · 第一章 总则 · 第一条 目的
企业信息 · 一、公司基本信息 · 注册资本
  • 归属补全:若切分点本身不是「第X条」(例如反洗钱第十三条下的 ### 第一类:资金流转异常),会把最近的条款名补进路径 —— 否则块会失去条款归属。
  • 章 ≠ 最外层文档标题:取「最近的上级标题」,而不是排序后首个(排序首个会拿到 H1 文档名)。
  • 纯「目录」块直接丢弃。

📌 这条路径不只是好看:Agent 侧靠它做同章节合并(_chapter_group_of 要求 · 分段 ≥ 3 才认第二段是章节),也是给客户看的来源引用。

3.3 关键设计:表格逐行拆成自解释小块(父子块)

问题:叶子标题粒度 = 「一个叶子标题一块」→ 产品手册里整个产品小节(表格 + 说明)成一块。于是客户问「起投多少」和问「风险高吗」命中同一块、拿到完全相同的整节内容 —— 客户会觉得客服没听懂问题,只是把说明书重贴一遍。

根因还有一层:整节几百字的向量是整节的混合语义,与「起投多少」这种具体小问题的相似度天然偏低(实测该问句向量 top1 只有 0.6291,够不到 0.75 门槛)。

做法:Markdown 表格每一行拆成一个自解释小块,doc_id 挂父块(PROD-007-01),父块照旧保留。

父块  PROD-007        「南方季季盈90天」整节(表格 + 说明)
子块  PROD-007-01     「南方季季盈90天:起投金额 1万元」
子块  PROD-007-02     「南方季季盈90天:产品期限 90天封闭期」
  • 必须自解释:只回「1万元」客户不知道说的是哪个产品 ⇒ 带上产品名 + 行标签。
  • 父块保留:客户问「介绍一下」时仍要能拿到完整一节。
  • 子块编号稳定:父块编号不受新增子块影响 ⇒ 反复重跑脚本得到的 doc_id 一致(这是重灌可复现的前提)。

3.4 一个真实 bug 与它的守卫(值得当答辩素材)

bug:原实现用「第一个非分隔行」当表头,而 header 从不重置 ⇒ 同一节里出现第二张表格时,它的表头行被当成数据行。

后果:《个人投资者适当性管理指南》第九条下有 16 张问卷表格 → 产出 15 条正文逐字相同的零信息碎片(形如「第九条 问卷内容及评分标准:选项 分值」)。它们必然互相打平 —— 实测把「风险评估问卷怎么评分」的 top1/次优差压到 0.002,而客服的判据是「中置信必须领先次优 ≥ 0.07」⇒ 判并列 → 转人工。真正有内容的块被挤到第 5 名。

修法:Markdown 表格的表头只可能是紧邻分隔行 |---|---| 之前的那一行 ⇒ 按分隔行认表头,用 prev 延迟一行判断。修后块数 636 → 617,正文完全相同的组从 1 组 15 块降到 0。

守卫(因为这个脚本是一次性灌库脚本、没有单测覆盖 —— 这正是 bug 活下来的原因):

assert_no_duplicate_contents(records)
# 正文完全相同的块必须为 0,否则 SystemExit,不写 jsonl

🔑 为什么守卫是"内容逐字重复"而不是"块长度下限":短块本身是设计的一部分(「评审标准:管理人资质 15%」只有 13 字,但它是真实的数据行、是有效答案)。内容逐字重复才是缺陷特征,长度不是。

3.5 切片结果的三个派生字段(v1.4 新增)

字段 派生规则 用途
family_id 去掉 -NN(两位数字)子块后缀;父块自身即族号 同族合并:同族并列时应合并作答,而不是当"两个互不相干的候选"去算 top1/次优差(那会把真实答案判成"并列"→转人工)
param_class 块里最主要的数值型产品要素:rate / threshold / scale / count / none。判据 = 「关键字在正文中最早出现的那一类」 计算型出口定位参数位(E2 问答不上,靠它找参数)
intent 按集合映射:basic_explain / faq / product_inquiry / policy_explain 意图标签

param_class 为什么刻意不用固定优先级:否则「费率表里顺带写了一句起投金额」会被整块判成 threshold。用"最早出现" = 块的开口主语。

⚠️ family_id 后缀只认两位数字:FAQ-0026 / COMP-001 是父块本身(四位/三位),不能被误削成 FAQ- / COMP-。


4. 阶段三 · 向量化

项 值 依据
模型 text-embedding-v3 实库 active 端点 knowledge-embedding-qwen-v3(model_endpoint_config.id=1)
维度 1024 app\core\knowledge_contracts.py:51 VECTOR_DIM = 1024
端点解析 按 task_type="embedding" 从已发布配置里筛(DatabaseModelEndpointResolver) app\service\model_gateway.py:208
密钥 env:QWEN_EMBEDDING_API_KEY(与 QWEN_API_KEY / DASHSCOPE_API_KEY 同一把千问 key) .env §137-145
批量 batch_size = 64 D2.4 §6.1 第 5 步
维度不符 失败关闭 —— 既不静默写入、也不返回空结果,抛异常 app\worker\knowledge_vector_worker.py:13

为什么维度必须失败关闭:运行期才发现维度不匹配,可能已经是静默的错误答案(返回了"看起来正常"的结果)。所以维度校验前移到写入与检索的启动期。


5. 阶段四 · 存储(Milvus 集合与索引)

5.1 四个集合

集合 承载 intent 默认检索面
fin_faq_collection FAQ 问答对 65 + 企业信息 85 faq ✅
fin_product_collection 产品手册 + 高净值服务规范 product_inquiry ✅
fin_policy_collection 适当性指南 + 销售管理办法 policy_explain ✅
fin_basic_collection 金融行业基础信息(行业通用常识) basic_explain ❌ 不在默认检索面(见 §12.3)

5.2 schema(实库实测 18 个字段,全部 NOT NULL)

doc_id(64, 主键) / title(1024) / content(16384)
chapter(512) / section(512) / tags(512)
doc_no(64) / version(32) / effective_date(32) / expire_date(32)
source_url(512) / reviewer(64) / source_file(128)
family_id(64) / param_class(16) / intent(32)
visibility(16, ★ 分区键, num_partitions = 16)
embedding(FLOAT_VECTOR(1024))
  • 长度按实际数据最坏情况定:content 取 16384(实测最长块 2828 字符,留足余量);title 取 1024(含完整章节路径)。
  • 唯一定义源:tools\setup_milvus_knowledge_collections.py 的 VARCHAR_FIELDS —— 灌库脚本从它 import 做截断,不再自带第二份定义(H-05 ④「两套建表脚本收敛为一套」)。

5.3 索引(实库实测)

项 实库值
索引名 knowledge_autoindex
类型 AUTOINDEX
度量 COSINE
状态 Finished,pending_index_rows = 0

⚠️ 设计文档写的是 FAQ→HNSW / 长文档→IVF_FLAT,实库是 AUTOINDEX —— 这处不一致已在 §12.1 单列,不要照文档讲。

5.4 visibility 作为分区键(本项目安全设计的核心)

结论 说明
隔离语义 从「表达式正确」升级为「结构不可达」—— 即使过滤表达式写错、字段缺失或索引重建,越权数据仍在另一个分区
取消 over-fetch ×3 over-fetch 存在的唯一目的是修补「TopK 被不可见条目占满」;分区裁剪后该场景不再存在,顺带消除了 over-fetch 自身带入的噪音(召回精度的净提升,不是等价替换)
写入侧 fail-closed visibility 是分区键 ⇒ 取值不允许为空或 null ⇒「忘标注位」在入库时报错,而不是在检索时静默按 public 放行
运维动作 = 不需要 ⚠️ 2026-09-18 实测(Milvus v2.5.3):分区键模式下 Milvus 禁止手工 create_partition(报 disable create partition if partition key mode is used)⇒ 新增档位值由引擎按哈希自动路由。设计文档里"档位值变更 = 建分区"的表述据此作废

6. 阶段五 · 入库链路(七步 · D2.4 §6.1)

① 提交        选类型(FAQ / 产品 / 政策);类型不支持 → 直接拒绝
② 解析        保留标题层级;中断则不产生半成品
③ 分块        空块丢弃并计数(计数留痕)
④ 档位标注    判定档位、丢弃 internal、产出标注报告;未命中记入报告待复核
⑤ 向量化      batch_size = 64;批次失败即重试,连续失败中断
⑥ 写入        按批次提交;失败可整批回滚
⑦ 登记元数据 + 抽样校验

第 7 步抽样校验(不可省略,四项)

# 校验 判据
① 档位分布 FAQ 集合 registered 占比 < 20%;政策集合全部 public
② 越权抽检 以访客身份检索 10 条已知 registered 关键词,全部返回空
③ 召回抽检 10 条已知问题,公开问题召回数量不下降
④ 剔除核对 dropped 列表与预期剔除章节一致

🔑 ②是"必须为空" —— 这是入库这一步唯一能证明"档位真的生效"的验收手段。

6.1 写入侧的幂等语义(覆盖,不是跳过)

现库同一批 aggregate_id 上堆了 408 条 knowledge.vector_sync_requested(重跑导入会线性增长),且投递侧无唯一约束 ⇒ 重复投递是常态。因此消费侧必须是覆盖语义:每次投递都重新读正文 → 重新嵌入 → upsert 覆盖主键。

为什么不能"看到已存在就跳过":正文被 UPDATE 之后重投,必须让 Milvus 里的正文跟着变 —— 跳过会让检索继续命中旧正文。


7. 阶段六 · 检索(在线八步 · D2.4 §6.2)

① Query 准备     多轮指代消解(由会话模块完成)
② 主体判定       禁止读取客户端可控参数
③ 档位映射       未知主体 → {public}
④ 主检索         服务端按档位分区裁剪
⑤ 阈值判定       FAQ 0.75 / 其他 0.70
⑥ 跨集合回退     阈值 0.65,复用同一 expr,≤ 2 次尝试
⑦ 兜底           固定话术 + 转人工建议,不降低阈值强行作答
⑧ 结果输出       来源格式化 + 记入工具调用;回退命中标注来源集合

7.1 代码实现与设计文档的对应

设计步骤 代码落点 实现要点
③ 档位映射 app\service\agent\base.py + app\core\knowledge_tier.py::tiers_for_roles 业务代码不手写档位字面量;档位由鉴权结果决定,不由查询内容决定
④ 主检索 app\service\knowledge_search_service.py::search tiers 是必填参数、无默认值 —— 遗漏即 TypeError,不会静默放行(fail-closed 第一道防线)
④ 过滤 visibility_expression(tiers) → Milvus filter= 检索层硬隔离,不依赖提示词约束
④ 缺字段 同上 请求受限档位且集合没有 visibility 字段 ⇒ 该集合直接排除(barred),不放行 —— 这是 K-07「缺字段即放行」的根治点
④ 多 schema app\core\knowledge_schema.py::detect_schema 同一批集合名在不同环境可能是两套 schema(doc_id/content vs knowledge_id/snippet);硬编码任一套都会把另一套打挂(Milvus 对不存在字段直接报错 → 三集合全失败 → degraded=True → 客户一律"引导人工")

7.2 失败语义(全链路一致)

任何一步失败都不抛异常给主链路,而是返回 degraded=True 的空结果,由客服 Agent 走「引导客户致电人工客服」的兜底路径。

🔑 一句话:金融场景下 「答不了」是可接受的结果,「答错」不是。所以检索故障不许表现成客户可见的错误,也不许静默当成"知识库里没有"。


8. 阶段七 · 检索增强(7 个动作 · 这是"RAG 到底做了什么"的正面回答)

A1 向量召回(基础路)

三主集合各查一次,limit = max(1, min(top_k, 20)),输出字段由 运行时探测决定(不硬编码 schema)。

A2 字面召回(专有名词兜底 · 条件触发)

为什么需要:专有名词在 embedding 空间里不占优势。实测客户问「季季盈90天的起投金额是多少」,向量 top1 = PROD-007 仅 0.6291(够不到 0.75 硬门槛);而同一次查询用 title like "%季季盈%" 是唯一命中 PROD-007。既然客户已经明确说出了产品名,就不该再让相似度去赌。

三条硬边界(都是实测逼出来的):

边界 值 理由
命中分 KEYWORD_MATCH_SCORE = 1.0 字面命中的确定分,压过任何相似度分
最小重合 MIN_KEYWORD_OVERLAP = 6 字 不是 4:客户问「基金赎回几天到账」与章节标题「5.2 基金赎回流程」正好 4 字连续重合,但「基金赎回」是业务动作词、不是专有名词。产品名通常更长,取 6 字能保住产品名、挡住动作词
触发条件 仅当 top1 < VECTOR_CONFIDENT_SCORE (0.75) 无条件字面匹配会把向量已答对的题顶掉:客户问「基金赎回几天到账」,向量已给正确答案(FAQ-0016 得 0.8060),但「五、申购赎回操作指南」也有 6 字重合 ⇒ 会用操作步骤替换掉客户真正问的到账时间
作用范围 只对产品集合 客户问「季季盈90天的起投金额」与通用 FAQ 标题「基金起投金额是多少?」最长公共子串有 7 字;若把 FAQ 也纳入,它会和真正的产品块一起拿到满分、差距归零 ⇒ 又退化成"转人工"

匹配算法 = 最长公共子串(不是分词):知识库标题是「…公募基金与专户产品手册 · 1.2 南方稳健增利债券 A」这种没有词边界的长串,任何分词器都得先养一份自定义词典,而词典会和手册一起过期。子串匹配不需要词典,手册改版也不会失效。

A3 父块带回(粒度补偿)

命中行级子块时,把父块(整节)一并带回,分数按子块 × 0.9。

  • 为什么:子块让「起投多少」拿到聚焦答案,但客户问「介绍一下」时会被某一行抢答(实测返回了"产品期限 90天封闭期",而客户要的是整个产品的介绍)。检索层不猜客户想听多细 —— 两种粒度都给,由调用方按问句选。
  • 为什么 ×0.9:既排在子块之后(不抢聚焦答案),又不把差距压到转人工门槛之下(0.869 折算成 0.782,与子块差 0.087,仍在 0.07 之上)。
  • 只取最高分子块的父块:把命中到的子块的父块全带上只会挤占 top_k 名额(实测带上多个后,父块反而被截断在门外、整节拿不到)。

A4 doc_id 去重

同一内容可能同时存在于产品手册与问答对里 ⇒ 按 doc_id 去重保留最高分。

A5 兄弟子块归并("客服答不出来"的主要来源之一)

问题:行级子块的粒度设计是对的,但有副作用 —— 同一节的子块彼此分数贴得极近,而它们全都是"同一个答案"的不同细节,不是并列的多个答案。doc_id 去重接不住这种情形(POL-AST-009-07 与 POL-AST-009-12 是两个不同 doc_id,却都来自同一节)。

实测:《适当性指南》第九条被切成 94 块(16 张问卷表格),问「风险评估问卷怎么评分」时 top5 全是这一节的子块,top1 与次优差 0.002 ⇒ 客服判"并列" ⇒ 转人工。

做法:同一父块的行级子块只保留最高分的那一条。

归并后实测(同一批向量离线复算):该问句 gap 0.002 → 0.093(转人工 → 可答);10 个问句回归 0 个变坏;靠子块精确命中的问题 top1 与答案完全不变(gap 反而从 0.113 升到 0.205);库外问句「今天天气怎么样」仍正确转人工。

为什么不会丢信息:被丢掉的兄弟子块不是答案本身,只是同一节的其它细节;本节内容由 A3 带回来的**父块(整节)**兜底。

A6 整节块保底席位

"整节块"的定义是「有行级子块挂在它下面」的块 —— FAQ / 政策 / 公司信息的块没有子块,它们本身就是细粒度答案,不能和产品手册的整节块混为一谈。

⚠️ 第一版用「doc_id 不含两位数字后缀」判断,把 FAQ 块全当成整节块排到最后,直接害得「基金赎回几天到账」转人工(正确答案被挤出了 top1)。

做法:整节块保底占最后一个名额(selected = [*plain[:top_k-1], sections[0]])。

  • 为什么保底:它按分数容易被 top_k 截掉,而「介绍一下」这类概括问句只能靠它拿到整节(实测被截后客户只收到一行"产品期限 90天封闭期")。
  • 为什么放末尾:不让它参与 top1/top2 判定 —— 实测它挤到第 2 位时 gap 会从 0.090 掉到 0.076,几乎跌破 0.07 的转人工门槛。

A7 证据包构建(E4 该不该接管、包里放哪些块)

先决条件:gap < MIN_GAP(分数接近)。领先明显时仍走 E3 原文直返 —— 原文直返没有幻觉面,能不用模型就不用。

满足先决后,命中的形态决定证据包怎么取(三种形态,都是实测补齐的):

形态 取法 为什么
同章节多块 取该章节的成员组 为什么按章节而不是 family_id:family_id 的粒度是父块/行级子块,同一次检索里同一 family_id 的多块只会是「父块 + 它的子块」——父块本就包含子块内容,合并没有增量。真正需要合并的是同一章节的不同小节(实测 C-01 的"四档权益"分属 HNW-004~007,四个不同 family_id、同一章节)
跨章节近分(W2 补) 取 TopK 里 ≥ E4_MIN_SCORE 的块 H-06 首跑 46 条里有 14 条落在澄清 E1,其中 9 条 top1 已 ≥0.55 ⇒ 澄清成了新的兜底;而设计原话是「TopK 内同族多块且分数接近 → 合并为一个答案(E4),不澄清」
组内 + 组外合并(W6 补) 先保章节组,再用组外高分块补足 实测 A-04(「风险等级越高的产品是不是收益越高?」)答案在 FAQ-0018(rank 3),而「成员最多的章节组」是三条适当性条款(不含风险↔收益表述)⇒ 模型只能判"证据不足"退澄清。二选一必然丢一边

安全边界一条没松:包仍先过档位裁剪;生成结果仍要过 _answer_from_evidence 的三道校验(引用越界 / 数字无出处 / 合规命中);模型判"缺主语 / 过泛 / 证据不足"时按契约回 None,仍退澄清。


9. 阶段八 · 判定与出口(检索结果 → 答复)

9.1 阈值(app\service\agent\implementations\customer_service.py:291-293, 389, 518)

常量 值 含义
HIGH_SCORE 0.75 ≥ 直接答(高置信不再要求间隙:分数已足够说明问题)
MID_SCORE 0.55 需与 MIN_GAP 同时满足才答,答时附"信息可能不完整"提示
MIN_GAP 0.07 top1 领先次优的最小间隙;领先不足说明有并列候选,不硬答
CLARIFY_SCORE 0.40 澄清的门槛
E4_MIN_SCORE 0.5 进证据包的最低分(其下的噪声不进包)
TOP_K 10 候选池

9.2 MIN_GAP = 0.07 是实测标定出来的,不是拍的

取证:group_fqcd_jr\docs\evidence\20260919-t9-threshold-calibration.json(46 条金标)

分支 实测 值
直返 E3(15 条,零模型调用) gap 最小 0.0759(B-05)
合并 E4(15 条,调 1 次模型) gap 最大 0.0649(B-03)
⇒ MIN_GAP 可选取值区间 (0.0649, 0.0759),分界中点 0.0704 ⇒ 取 0.07

⚠️ 踩坑登记:判据必须建立在真实查询上。按原始问句独立检索取谷是错的 —— E-02 原始问句 gap 0.0054、真实查询 0.1784(同一题差 32 倍)。本轮为此返工两次。

9.3 出口决策

flowchart TD
    Q["检索命中 hits"] --> D0{"degraded ?"}
    D0 -->|是| F["E5b 部分答 + 引导<br/>(基础设施故障不暴露给客户)"]
    D0 -->|否| D1{"命中为空 ?"}
    D1 -->|是| F
    D1 -->|否| SUB{"F-3 主体相关性闸门<br/>问句点名主题但无一块提到它 ?"}
    SUB -->|是| SM["E5b 引导登录 / 改写建议<br/>模型零调用"]
    SUB -->|否| S["score = top1 · gap = top1 - top2"]
    S --> G{"gap < MIN_GAP ?"}
    G -->|是| EV["构造证据包 → E4 证据约束生成"]
    G -->|否| H{"score >= 0.75 ?"}
    H -->|是| E3["E3 原文直返(零模型调用)"]
    H -->|否| M{"score >= 0.55 且 gap >= 0.07 ?"}
    M -->|是| E3M["E3 直返 + '信息可能不完整'"]
    M -->|否| E1["E1 澄清(问回去)"]

F-3 主体相关性闸门(实测新增):问句点名了某个主题(如「投顾服务」),但一块命中都没提到它 ⇒ 这是"检索拿相近概念凑数",直返原文或合并生成都会答非所问。它放在 E4 与置信判定之前 —— 这类答复不是"置信度不够",而是"证据与问题无关"。


10. 选型原因(8 项决策 · 26 个备选)

来源:D2.4 §7(决策总览)+ §7.3(逐项理由)。贯穿判据:优先保证「权限边界不可被绕过」,其次「召回不因安全措施受损」,最后才考虑性能与优雅度。

# 决策点 采纳 备选数 风险
1 向量库选型 Milvus 4 低
2 可见性实现方式 独立标量字段(审计)+ 服务端强制拼装 + 集合内分区(隔离) 5 高(选错即全盘失效)
3 集合划分粒度 按知识类型分三集合 3 中
4 分块策略 按形态差异化 4 中
5 嵌入模型 沿用底座既有模型(维度锁定) 3 高(不一致)
6 索引类型 FAQ→HNSW / 长文档→IVF_FLAT(实库为 AUTOINDEX,见 §12.1) 3 低
7 检索模式 纯向量检索(混合列为演进项) 2 中
8 重排策略 不做独立重排(阈值控制替代) 3 低

10.1 为什么是 Milvus(决策 1)

备选 否决理由
FAISS 不支持标量过滤 ⇒ 可见性只能落应用层 ⇒ 直接违反 P1(权限边界不可绕过) ⇒ 直接否决
pgvector 引入新中间件
Chroma 过滤能力不足以支撑索引级过滤

⇒ Milvus 本身是既定技术栈,且是唯一同时满足"向量 + 标量/分区过滤"的选项。

10.2 为什么可见性要「标量字段 + 服务端拼装 + 分区」(决策 2 · 唯一"选错就全盘失效"的一项)

备选 否决理由
物理双集合(_public / _registered) 集合数 3→6;双份向量化与存储;跨档须两次查询再融合;知识更新需双写,而双写不一致比越权更难排查
metadata 打标 + 应用层过滤 TopK 语义被破坏(全 registered 时过滤后为空 → 误判无答案 → 直接兜底);应用层天然拿不到"多取候选"的机会;过滤逻辑分散,漏一处即泄露
提示词约束 「不是权限控制,是请求」—— 可被忽略、注入绕过、遗忘
集合内分区(v1.3 补强,已采纳) 上面三条否决理由一条都不适用:集合数不变(仍是 3 个);一个块只有一个档位、只进一个分区(没有副本、没有双写);分区裁剪一次查询即可覆盖多档

分区 ≠ 标量字段的替代,而是双轨:字段保留(供词法回退与审计),隔离职责移交分区。

🔑 为什么分区比标量过滤更强:签名约束的是调用方(防"忘记过滤");分区约束的是存储与检索引擎本身。即使过滤表达式写错、字段缺失或索引重建,越权数据仍不可达 —— 而 K-07 实测的恰恰是「字段缺失」这一类签名挡不住的失效。

10.3 其余五项(决策 3—5、7、8)

# 采纳与理由 否决与理由
3 按知识类型分三集合:不同形态需不同索引与阈值(FAQ 0.75 / 长文档 0.70 难以兼顾) 单一大集合(阈值无法兼顾、噪音大);按档位分集合(把「权限」这一高频变化维度做成物理集合 ⇒ 3→9 个集合)。划分维度应选变化频率低的那个 —— 注意与分区区分:分集合 3→9,分区只需 3 集合各建分区
4 按形态差异化:三条分支本质是三个 if,成本极低 统一 512/64(FAQ 被拆散、表格被拦腰截断 —— 两处均为语义级破坏);语义分块与父子块列为演进项
5 沿用底座既有模型:Embedding 是「底座已决定的既成事实」;变更成本不对称 更换更强模型(必须重建全部集合并重新灌库,与底座不一致会造成「同库不同维」的致命问题);本地部署模型(需算力与运维)
7 纯向量检索:规模小(百条级)、查询以自然语言问句为主 向量 + 关键词混合:需引入全文索引、融合权重需标注集调参、带来「两组结果如何统一过滤」的额外复杂度。演进触发条件:出现术语类查询召回明显不足的实测证据;届时全文索引侧同样必须能过滤档位,否则新路径会成为越权后门
8 不做独立重排:TopK 仅 3—5 条,重排收益空间有限;用阈值控制替代 Cross-Encoder(额外模型与推理开销);LLM 重排(延迟高、成本高、结果不稳定)。演进触发条件:TopK 提升到 10 条以上,或出现「正确块进入候选但未进前 3」的实测案例

📌 决策 7 的"演进触发条件"已经在实现里被触发了 —— A2 字面召回就是这一条的部分落地(只在向量不确信时介入),而不是完整的混合检索 + RRF 融合。


11. 实测数据(2026-09-20)

11.1 切片件 knowledge\_chunks.jsonl

指标 值
总块数 675
最长正文 2828 字符
最短正文 11 字符
平均正文 102.0 字符
子块数(-NN 后缀) 459
family_id 覆盖 675 / 675(100%)
param_class 非 none 186 块

11.2 按集合与来源

集合 块数 来源文件
fin_policy_collection 288 适当性指南 152 + 销售管理办法 136
fin_product_collection 191 产品手册 176 + 高净值规范 15
fin_faq_collection 150 FAQ 65 + 企业信息 85
fin_basic_collection 46 基金基础知识 22 + 交易时限常识 24
合计 675

11.3 档位分布

档位 块数 构成
public 650 ——
registered 25 高净值规范 15 + FAQ 10(Q17/Q20/Q21/Q27/Q28/Q29/Q30/Q33/Q47/Q53)

11.4 param_class 分布

类型 块数
none 489
rate(费率) 72
threshold(门槛/起投) 66
scale(规模) 42
count(家数/网点) 6

11.5 Milvus 实库

集合 count(*) 索引状态
fin_faq_collection 150 Finished / pending = 0
fin_product_collection 191 Finished / pending = 0
fin_policy_collection 288 Finished / pending = 0
fin_basic_collection 46 Finished / pending = 0

✅ 切片件 675 = 实库 675,四集合全部索引 Finished、无 pending —— 这是"库和切片件一致"的证据。


12. 已知不一致与风险(如实登记,不要回避)

12.1 🔴 索引类型:文档写 HNSW / IVF_FLAT,实库是 AUTOINDEX

位置 写法
D2.4 §651—653、§994、§1061、§1563 「FAQ→HNSW / 长文档→IVF_FLAT」,参数 {"M":16,"efConstruction":200} / {"nlist":128}
实库实测 knowledge_autoindex · AUTOINDEX · COSINE
D2.4 §1466 已登记实库为 AUTOINDEX / COSINE ⇒ 文档内部自相矛盾

点评:AUTOINDEX 在这里是更合理的选择(集合规模小、免调参、Milvus 会按数据量自动选型),而且 D2.4 §1466 已经按实库登记过。问题不在选型,在于同一个 §7 里两处说法不一致 —— 答辩追问「到底用的什么索引」时,以实库为准答 AUTOINDEX + COSINE。

建议:把 D2.4 §7 决策 6 的正文改成「实库采用 AUTOINDEX(HNSW/IVF_FLAT 为设计初稿,未落地)」并保留历史说明。

12.2 🟠 tools\configure_embedding_endpoint.py 会建出第二个 embedding 端点

项 现役(实库 active) 该脚本写的
endpoint_code knowledge-embedding-qwen-v3 qwen-embedding
model_name text-embedding-v3 qwen3.7-text-embedding-flash

风险链条(app\service\model_gateway.py:208-246 的实现决定):

  1. DatabaseModelEndpointResolver.resolve 按 capabilities 含 embedding 筛出全部匹配端点;
  2. ModelDispatchService 只按顺序尝试前 max(1, max_attempts)(默认 2)个;
  3. 该模块自己的注释写明:「能不能选到支持该任务的端点取决于端点在表里的顺序」「端点一多就会耗尽尝试次数直接失败」。

⇒ 一旦有人重跑该脚本,就会出现两个 embedding 端点,后果二选一:嵌入失败(耗尽尝试次数),或索引与查询用了不同的嵌入模型 ⇒ 两个向量不在同一空间,cosine 相似度失去意义 ⇒ 检索质量无声崩塌(不会报错,只是答得越来越不准)。

建议:① 该脚本标注「已废弃,勿重跑」(现役端点的配置脚本应指向 knowledge-embedding-qwen-v3 / text-embedding-v3);② 增加一条守卫:active 的 embedding 能力端点必须恰好 1 个,多于 1 个即告警(这是纯配置检查,成本为零)。

⚠️ 注意:tools\load_knowledge_milvus.py 里硬编码 EMBED_MODEL = "text-embedding-v3"(直接打 DashScope,不走端点表)。它与实库 active 端点当前是一致的,所以现在没有问题 —— 但这个"一致"是靠人工维护的,两侧各自独立。真正的隐患是将来改一侧忘改另一侧。

12.3 🟡 第 4 集合 fin_basic_collection 已建已灌,但不在默认检索面

实测结论(2026-09-19):把它加进默认检索面后复跑 46 条金标,M-1 出口准确率 100% → 91.3%(A-03/A-07/B-06/E-04 由 E3/E5b 掉成 E4)。

根因不是"答错了",而是通用常识文本天生与很多问题都像:它把次优分抬到 0.69—0.79,而 MIN_GAP=0.07 的"top1 领先不足"判据据此把本该直答的题推给证据约束生成。它是"抢答",不是"补位"。

⇒ 落法:集合建好、灌好、契约放行,但不进默认检索面。要真正启用,需先做跨集合分数归一 / 重排(否则每个集合的分数量纲不同,混在一起比大小本身就不成立),启用前必须先复跑评测、M-1 必须仍是 100%。

12.4 🟡 同义词表建了,但检索链路没读它

agent_faq_synonym 表存在、由 tools\import_knowledge_seed.py 写入,但客服 Agent 的检索链路(knowledge_search_service → Milvus)完全不查这张表。D3.5 §3-B 建议的「术语归一化 / 多查询扩展」未落地。

(注意区分:customer_service.py:1750 的"归一化"是数字归一化,用于输出侧 M-9 数字一致性校验,不是 query 归一化。)

⇒ 这是已知未做项,不是既有能力。

12.5 🟡 D3.5 的前提风险已部分闭环,但未全部

风险 现状
K-01 集合无 visibility ✅ 已闭环 —— 实库 18 字段含 visibility 且为分区键
K-02 集合级 fail-open ✅ 已闭环 —— search() 对缺字段集合在受限档位下 barred(排除)
K-06 FAQ 阈值 0.75 偏高 ⚠️ 未闭环 —— 仍是 0.75
K-07 重修须 drop 而非 upsert ✅ 已闭环 —— 已按 drop → create → 重灌 执行,切片件与实库块数一致(675)

13. 复现命令(都在仓库根目录执行)

$py = ".\.venv\Scripts\python.exe"

# ① 切片(产出 knowledge\_chunks.jsonl;含"正文重复块必须为 0"守卫)
& $py tools\build_knowledge_chunks.py

# ② 建集合(幂等;已存在则跳过,结构不同则停下报告)
& $py tools\setup_milvus_knowledge_collections.py

# ③ 灌库(写入 Milvus)
& $py tools\load_knowledge_milvus.py

# ④ 只读体检:集合 schema / 行数 / 是否含 visibility
& $py tools\probe_knowledge_collections.py

# ⑤ 一致性对账(知识行 ↔ 向量)
& $py tools\reconcile_knowledge_vectors.py

⚠️ 跑 ④ 时必须把工作目录切到仓库根:它把产物写到相对路径 docs\evidence\knowledge-collections.json,在别处跑会写到别处,而你看到的仍是仓库里的旧文件 —— 这一步我自己就踩过一次(§14)。


14. 诚实声明

已做 未做 / 已知边界
逐行读码:build_knowledge_chunks.py / knowledge_search_service.py / knowledge_tool.py / knowledge_schema.py / knowledge_contracts.py / knowledge_tier.py / setup_milvus_knowledge_collections.py / knowledge_vector_worker.py / customer_service.py(检索与判定段) ❌ 本文未改任何代码
实库实测:4 个集合 describe_collection / count(*) / describe_index / 分区键标志 ❌ 未跑金标评测(§11 的数字是语料与库的实测,不是效果指标;效果指标见 D2.6 / D3.7)
切片件 _chunks.jsonl 全量统计(675 块 / 档位 / 派生字段 / 长度分布) ❌ 未做 §12.1 / §12.2 / §12.4 三项的修正(只登记,不执行)
与 D2.4 / D3.2 / D3.5 的选型理由对读 ❌ 未做 术语归一化 / 多查询扩展 / RRF 融合 / 重排(均为设计里列为"演进项"的部分)

🔁 一处自我更正(本轮如实登记):我第一版读到的「实库只有 15 个字段、缺 family_id/param_class/intent」是错的 —— 原因是把 tools\probe_knowledge_collections.py 跑在了非仓库根目录,于是脚本把新产物写到了别处,而我读的是仓库里尚未更新的旧 JSON。改为直接查 Milvus 后确认:18 个字段齐全、visibility 是真分区键。结论以 §5.2 / §11.5 的直接查询为准。


维护责任:本文件为活文档。切片策略、阈值、集合 schema、索引类型任一变更后,须回填 §11 实测数据与 §12 风险表。 同步方向:权威副本(D:\桌面\金融\客服agent\)→ 仓库(group_fqcd_jr\客服agent\)单向覆盖(D1.6 §4.38 既定)。