新增 客服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。
44 KiB
客服 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 | 索引类型 | 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 的实现决定):
DatabaseModelEndpointResolver.resolve按capabilities含embedding筛出全部匹配端点;ModelDispatchService只按顺序尝试前max(1, max_attempts)(默认 2)个;- 该模块自己的注释写明:「能不能选到支持该任务的端点取决于端点在表里的顺序」「端点一多就会耗尽尝试次数直接失败」。
⇒ 一旦有人重跑该脚本,就会出现两个 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 既定)。