Files
group_fqcd_jr/tools/build_knowledge_chunks.py
张胜宇 94ce44a851 feat(W24): docs/43 场内基金手册入库 + B-02 展示候选集收口
补历史欠账:D3.4 B-02(决 12)与 D4.1 §B-02 早已承诺把 docs/43 纳入 SOURCES,
从未执行;W21 首次把它暴露成实测缺口(702 块里「科创债」0 处)。

语料与切片
- docs/43-场内基金产品手册(知识库入库版).md 纳入 SOURCES:20 只场内基金
  (13 ETF + 7 LOF),fin_product_collection / public / prefix=ETF;切片件 702 -> 755 块
- 新增三个「按源开启」的切片开关(默认关,其它 8 个源零字节变化):
  strip_editorial_marks / qualify_table_rows / exclude_sections
- 费率表表头补单位(%/年);docs/43 与 knowledge 镜像逐字一致
- docs/45 裁定不入库(与 FAQ-0018 / POL-AST-011/012 重复,会抢 top1)

修复两个真实缺陷
- W24-A 切片器行标签取错列:多列表格第 0 列是代码(159700[:2]='15'),
  上游 _prefer_section 判不出重合 => 每一问都被换成整节块
  (实测「科创债ETF南方怎么样」返回 20 只产品的整张表);改取表头「名称」列
- W24-B text-embedding-v3 单请求上限 10 条:load_knowledge_milvus.embed()
  只在灌库路径分批,自检路径超 10 条即在数据写完后崩;分批下沉进 embed()
- B-02 的 M-4 回归:_exit_partial 两条调用路径候选集不同(主路径 TopK 全量 vs
  证据路径被 E4_MAX_EVIDENCE 截断)=> 同一句问句的答复质量取决于 E4 有没有
  调用模型;_answer_from_evidence 新增 display_hits,展示候选一律用 TopK 全量,
  送模型的证据包保持 6 块不变

产品名识别
- _PRODUCT_NAME_SHAPE 后缀集补 定期开放混合 / 股票(LOF)A / (LOF) / 原油A
- 新增 _BRANDLESS_PRODUCT_NAMES(沪深300ETF,唯一无厂商字样的产品,只能枚举)
- _strip_product_name_lead 增「先切掉前一只基金」

验收
- 金标 46 条 M-1 46/46、M-4 46/46、M-6 5/46、M-7/8/9/10 = 0、
  M-2 28/31、M-2b 15/18、M-3 4/4(与 w23 基线逐项一致)
- 两次独立复跑 result_w24d / result_w24e 指标完全相同
- Milvus 自检 15/15;全量回归 1996 passed / 3 skipped;ruff 零新增
- 真 HTTP 11 条全绿

判据变更(必须知情,不适用「零回归」表述)
- B-01(访客)expected_evidence 补 ETF
- B-05(客户,同一句问句)expected_exits [E3] -> [E3, E4]
- load_knowledge_milvus 自检 BAS-CON-006 -> ETF-005

文档
- 客服agent/D2.1 升 v6.39;D2.4 升 v1.8(语料 755 块、手册切片实测 53);
  D2.8 新增 §3.6 与 §11 复测;D2.9 升 v1.3
- 开发文档/D1.1 升 v1.15(新增 §31);D1.6 升 v1.2(新增 §11、§10.4 销账);
  D4.8 升 v1.2(新增 §10)

诚实留痕
- _exit_partial 仍按分数挑块(不接收问句),本轮只统一候选集口径
- E5b 展示层净化仍不覆盖「收益 + 数字%」形态,继续挂账
- 语料里 2 个零容忍地雷块(POL-SPM-016 / POL-SPM-022-01)是禁令条款,故意保留
2026-09-21 12:26:42 +08:00

574 lines
32 KiB
Python
Raw Permalink 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.
"""把 knowledge/ 下的文档切成可检索知识块,输出 JSONL(临时脚本,跑完即删)。
切片粒度决定检索质量。这里采用**叶子标题**策略:一个标题若其后没有更深的标题,
它就是一个切分点。相比"按固定级别切",这个策略同时满足了三种真实情况:
- 有的条款带 `#### X.Y` 子条款(如反洗钱第九条 9.1–9.4)→ 按子条款切,粒度更细;
- 有的条款没有子条款(如反洗钱第十一条)→ 自己就是叶子,单独成块,不会被并进上一块;
- 有的章没有小节(如企业信息「一、公司基本信息」)→ 章本身就是叶子,内容不会丢。
标题路径保留完整上级链;若切分点本身不是「第X条」(例如反洗钱第十三条下的
`### 第一类:资金流转异常`),会把最近的条款名补进路径,避免块失去归属。
纯「目录」块直接丢弃。
"""
import json
import re
import sys
from pathlib import Path
# 门禁抽到了 `tools/knowledge_corpus_gate.py`(那边可被单测 import,本脚本一 import
# 就会重写 jsonl,测试没法安全加载)。以脚本方式运行时仓库根不在 sys.path 上,这里补。
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from tools.knowledge_corpus_gate import assert_corpus_gate # noqa: E402
HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
CLAUSE = re.compile(r"^第[一二三四五六七八九十百]+条")
# 每个文件的入库配置:集合、编号前缀、可见性、版本与生效日期(取自文档头部)
SOURCES: dict[str, dict[str, str]] = {
"policy/个人投资者适当性管理指南.md": {
"collection": "fin_policy_collection", "prefix": "POL-AST", "visibility": "public",
"version": "V3.2", "effective_date": "2024-01-15", "doc_no": "JR-AST-2024-001",
"tags": "适当性,C1-C5,R1-R5,双录,冷静期",
},
# 反洗钱合规操作手册**不入客服知识库**:本业务只做公募基金,不涉及银行转账与资金划付,
# 反洗钱属后续风控模块职责;且该手册标注「内部机密」、第十六条禁止向客户透露可疑交易
# 信息,混入面向客户的知识库存在制度性冲突。
"policy/理财产品销售管理办法.md": {
"collection": "fin_policy_collection", "prefix": "POL-SPM", "visibility": "public",
"version": "V3.0", "effective_date": "2024-02-01", "doc_no": "JR-SPM-2024-003",
"tags": "理财产品销售,双录,冷静期,费率,投诉",
},
"product/个人理财产品手册.md": {
"collection": "fin_product_collection", "prefix": "PROD", "visibility": "public",
"version": "V2.8", "effective_date": "", "doc_no": "",
"tags": "基金,银行理财,保险,费率,申赎",
},
# 只取「客户分层标准」与「各层级专属权益」两章:家族信托、资产配置流程、客户经理
# 考核指标、隐私应急预案属内部管理内容,客户咨询用不到,不入库。
"product/高净值客户服务规范.md": {
# 档位 = `registered`(**不是** public):依据是 `D2.4` §4.4「高净值客户服务规范 ·
# 分层权益与增值服务 → registered」——**依据是「权益明细」,不是「门槛」**。
# `D2.4` v1.7 已更正(`D-1` 选乙):**分层体系与门槛属公开宣传口径** ——
# `public` 的 `FAQ-0014` 已完整给出五档门槛、`FAQ-0050` 含「600 万元以上钻石客户」,
# 故门槛**不构成** `registered` 的理由(附录B `v1.3` 裁定的「理由②」已作废)。
# ⇒ 故 `HNW-004`—`HNW-007` 对访客不可见,访客问「高净值客户有什么权益」走
# 「引导登录」。改档位前先读 `D2.4` §4.4 与附录B —— `visibility` 是**分区键**,
# 改档位须**重建集合**,不是改一个字段那么简单。
"collection": "fin_product_collection", "prefix": "HNW", "visibility": "registered",
"version": "V2.1", "effective_date": "", "doc_no": "",
"tags": "高净值,VIP分级,层级权益,费率优惠",
"allow_chapters": ["一、", "二、"],
},
# `乙-7` / `DEC-08`:第 4 集合「金融行业基础信息」。**行业通用常识**,
# 不含任何产品参数、费率、门槛或推荐(源文件头部也写了这条边界)。
"basic/基金基础知识.md": {
"collection": "fin_basic_collection", "prefix": "BAS-CON", "visibility": "public",
"version": "V1.0", "effective_date": "2026-06-30", "doc_no": "JR-BAS-2026-001",
"tags": "基金基础,概念,分类,净值,分红",
},
"basic/基金交易与时限常识.md": {
"collection": "fin_basic_collection", "prefix": "BAS-TRD", "visibility": "public",
"version": "V1.0", "effective_date": "2026-06-30", "doc_no": "JR-BAS-2026-002",
"tags": "认购,申购,赎回,T+N,费用,定投,风险等级",
},
"company/企业信息.md": {
"collection": "fin_faq_collection", "prefix": "COMP", "visibility": "public",
"version": "", "effective_date": "", "doc_no": "",
"tags": "公司信息,金融牌照,资质",
},
# `W23`:`docs/43-场内基金产品手册(知识库入库版).md`(20 只场内基金:13 ETF + 7 LOF)。
#
# **这是一笔历史欠账,不是新增需求**:`D2.4` §4.4 的档位映射表早已把它登记为
# `fin_product_collection` / `public`("表格整表,约 30—50 块"),`D3.4` 的 `B-02`(决 12)
# 与 `D4.1` §B-02 也都写了"新增 `docs/43` 入 `SOURCES`"——**实际从未执行**。
# 后果(`W21` 实测):`_chunks.jsonl` 里「科创债」0 处、`ETF南方` 0 处,客户问
# 「科创债ETF南方怎么样」的 top1 是 `南方稳健增利债券 A` 的产品卡(0.6696)——问 A 答 B。
#
# 两个**按源开启**的开关(默认关,其它源一个字节都不变):
# · `strip_editorial_marks`:本文件在「二、产品清单」里有一段 `>` 引用的**内部编辑说明**
# ("草稿版 `docs/42` 用的是 0.2332…两处不一致是已知的、有意的")——它是写给
# **入库人**的,不是写给客户的;而 `E3`/`E5b` 会把块正文直接呈现给客户,
# 等于把内部文档名与两个互相矛盾的净值摆到客户面前。表内那个 `⚠️` 就是这段
# 说明的锚点,引用拿掉后它只剩「0.2661 ⚠️」这种没有解释的符号,一并清掉。
# **镜像文件与 `docs/43` 逐字一致**,只在切片时丢弃这些标记。
# · `qualify_table_rows`:本文件的两张表是 **7 列**(产品清单)与 **5 列**(费率一览)。
# 行级子块默认只取前两列,会产出「二、产品清单:159700 科创债ETF南方」这种**丢掉风险
# 等级/净值/费率**的碎片——恰恰是客户最常问的字段。开启后按表头逐列串成
# 「代码 159700 / 名称 科创债ETF南方 / 交易所 深交所 / …」。**不开全局**:现有 8 个源
# 的表结构与检索表现都已稳定,约 300 行的子块正文变化无法只靠 46 条金标兜住。
# · `exclude_sections`:**导航型小节不入库**。「3.2 其他产品的查询」的问行是
# 「某只产品的风险等级和费率是多少?怎么查?」,答案却只是"见上方清单 / 到产品列表页
# 去查"。这种块对**任何**参数类问句都有很高的字面相似度,却答不出任何参数 ——
# 实测它抢走了金标 `B-06`(「举个例子说明费率怎么查」)的 top1(0.7001,把
# `PROD-018` 的「6.3 费用计算示例」挤到第二)。**「怎么查」不是知识**:
# 客户问参数时要的是参数,不是"去哪里看"。
"product/场内基金产品手册(知识库入库版).md": {
"collection": "fin_product_collection", "prefix": "ETF", "visibility": "public",
"version": "V1.0", "effective_date": "2026-09-11", "doc_no": "JR-ETF-2026-001",
"tags": "场内基金,ETF,LOF,交易规则,费率,风险等级,起投",
"strip_editorial_marks": True,
"qualify_table_rows": True,
"exclude_sections": ["3.2 其他产品的查询"],
},
}
#: FAQ 中判为 `registered` 的**条目序号**(1-based,对应 `D6.1.3` 的行号)。
#:
#: 依据 `D6.1.2-南方基金-高频问答对.md` §附「四、知识库可见性档位标注」(2026-09-17 定案):
#: 唯一判据是「**答案中是否含具体数值型产品要素**」——费率 / 起投金额 / 收益率区间 /
#: 产品规模 / 门槛金额 / 具体合作家数。**含即为产品参数 → `registered`。**
#: 现行口径 **public 55 / registered 10 = 65**(`W6` 于 2026-09-19 追加 Q65),
#: 序号为 Q17、Q20、Q21、Q27、Q28、Q29、
#: Q30、Q33、Q47、Q53。**改这份名单前先读该节**:V1.0 与 V2.0 的成员并不相同
#: (V1.0 的 Q15 已降为 `public`,Q47 / Q53 是 V2.0 新补的 `registered`),
#: 不要按旧名单反推 `public`。
FAQ_REGISTERED_QIDS: frozenset[int] = frozenset({17, 20, 21, 27, 28, 29, 30, 33, 47, 53})
#: FAQ 源文件应有的问答对条数(`D6.1.3` 的 64 组 + `W6` 补的 1 组 = 65)。
#:
#: **为什么是 65 而不是 64**:`W5` 复跑实测 `A-07`(「T 日和 T+1 是什么意思?」)时发现,
#: 旧库里的专条「什么是T日、T+1?」在现行 628 块语料中**已不存在**,FAQ 家族只剩 4 条
#: 顺带提到 `T+1`,检索 top1 只有 0.481、FAQ 家族根本进不了 top10 ⇒ 该金标条目在当前
#: 语料下**不可达**(路由怎么改都救不回来)。这是**语料缺口**,补条目才是根因修复。
#: 新条目**必须追加在末尾**:`FAQ_REGISTERED_QIDS` 是 1-based 序号,中间插入会让其后
#: 所有条目的序号平移、档位整体错位(`H-05` 的档位隔离会被无声破坏)。
#: `W21`(2026-09-21)再补 4 组:`W20` 智能度体检(55 条真实口语问法)实测出的四类
#: **语料缺口** —— ① 按类型找产品(「你们有没有债基」)全库最高分只有 0.54,主集合里
#: 「债券基金」被「储蓄国债」抢答;② 「什么时候能卖」唯一的字面邻居是政策文件的
#: 「第二十七条 生效日期」;③ 「债基和货基哪个收益高」没有任何一条把两个品类的特征
#: 并排讲清楚;④ 「有没有不亏的产品」被答成「什么是净值型产品」。四组同样**追加在末尾**。
FAQ_EXPECTED_COUNT = 69
def leaf_split_points(marks: list[tuple[int, int, str]]) -> set[int]:
"""叶子标题的行号集合:其后没有更深标题的标题。"""
points: set[int] = set()
for position, (index, level, _title) in enumerate(marks):
following = marks[position + 1] if position + 1 < len(marks) else None
if following is not None and following[1] > level:
continue # 有子标题,不是叶子
points.add(index)
return points
def chunk_markdown(text: str) -> list[dict[str, str]]:
lines = text.splitlines()
marks: list[tuple[int, int, str]] = []
for index, line in enumerate(lines):
match = HEADING.match(line)
if match:
marks.append((index, len(match.group(1)), match.group(2).strip()))
marks_by_line = {index: (level, title) for index, level, title in marks}
split_points = leaf_split_points(marks)
stack: dict[int, str] = {}
chunks: list[dict[str, str]] = []
buffer: list[str] = []
meta: dict[str, str] | None = None
last_clause = ""
def has_body() -> bool:
return any(not HEADING.match(line) and line.strip() for line in buffer)
def flush() -> None:
nonlocal buffer, meta
if meta is not None and has_body():
body = "\n".join(buffer).strip()
if body:
chunks.append({**meta, "content": body})
buffer = []
for index, line in enumerate(lines):
mark = marks_by_line.get(index)
if mark is not None:
level, title = mark
stack[level] = title
for deeper in [key for key in stack if key > level]:
del stack[deeper]
if CLAUSE.match(title):
last_clause = title
if index in split_points:
flush()
# 所属章取「最近的上级标题」,而不是最外层文档标题(sorted 后取首个会拿到 h1)
ancestors = [key for key in stack if key < level]
chapter = stack[max(ancestors)] if ancestors else ""
path = [stack[key] for key in sorted(stack) if key <= level]
if not CLAUSE.match(title) and last_clause and last_clause not in path:
path = [item for item in (chapter, last_clause) if item] + [title]
meta = {"title": " · ".join(path), "chapter": chapter, "section": title}
buffer.append(line)
flush()
return [chunk for chunk in chunks if chunk["section"].strip() != "目录"]
def chunk_qa(text: str) -> list[dict[str, str]]:
chunks: list[dict[str, str]] = []
for line in text.splitlines():
line = line.strip()
if not line or "\t" not in line:
continue
question, _, answer = line.partition("\t")
chunks.append({
"title": question.strip(),
"chapter": "高频问答",
"section": question.strip(),
"content": f"问:{question.strip()}\n答:{answer.strip()}",
})
return chunks
TABLE_ROW = re.compile(r"^\|(.+)\|\s*$")
LEADING_NUMBER = re.compile(r"^\d+(?:\.\d+)*\s*")
def expand_table_rows(
chunk: dict[str, str], parent_id: str, *, qualify: bool = False
) -> list[dict[str, object]]:
"""把 Markdown 表格的每一行拆成自解释的小块(父块照旧保留)。
为什么需要:现在的粒度是"一个叶子标题 = 一块",产品手册里就是**整个产品小节**
(表格 + 说明)成一块。于是客户问「起投多少」和问「风险高吗」命中同一块、拿到
**完全相同**的整节内容——客户会觉得客服没听懂问题,只是把说明书重贴一遍。
顺带地,整节几百字的向量是"整节的混合语义",与"起投多少"这种具体小问题相似度
天然偏低(实测该问句向量 top1 只有 0.6291,够不到 0.75 门槛)。
小块必须**自解释**:只回「1万元」客户不知道说的是哪个产品,所以带上产品名与行标签。
父块保留,客户问「这个产品怎么样」时仍要能拿到完整一节。
## 2026-09-15 修掉的 bug:表头被当成数据行
原实现用「**第一个非分隔行**」当表头(`if not header: header = cells`),而 `header`
从不重置。于是**同一节里出现第二张表格时,它的表头行被当成数据行**,产出形如
「第九条 问卷内容及评分标准:选项 分值」的**零信息量碎片**:
《个人投资者适当性管理指南》第九条下有 16 张问卷表格 → 15 条碎片,且**正文逐字相同**。
检索时它们必然互相打平(实测把「风险评估问卷怎么评分」的 top1/次优差压到 **0.002**,
客服按"中置信需领先 ≥0.07"判并列 → 转人工),把真正有内容的块挤到第 5 名。
修法:markdown 表格的表头**只可能是紧邻分隔行 `|---|---|` 之前的那一行**,所以按分隔行
认表头,用 `prev` 延迟一行判断。修后块数 636 → 617,正文完全相同的组从 1 组 15 块降到 0。
## 2026-09-21 补的 `qualify`(按源开启,默认关)
上面那套只在**两列**表上成立:`emit` 只取 `cells[0] / cells[1]`。
`docs/43` 的产品清单是 **7 列**(代码/名称/交易所/类型/风险等级/净值/净值日期)、
费率一览是 **5 列**,于是行级子块会退化成「二、产品清单:159700 科创债ETF南方」——
**风险等级、净值、费率全被丢掉**,而这恰恰是客户问单只产品时最想要的三样。
开启后按表头逐列串起来:「…:代码 159700 / 名称 科创债ETF南方 / 风险等级 R2 / …」。
**为什么按源开启、不做成全局**:现有 8 个源里 3~7 列的表格有 269 行,
改它们等于一次性重写 300 来块正文,而回归网只有 46 条金标 + 手写单测,
兜不住这么大面积的内容漂移。**先在新源上证明它更好,再谈是否推广。**
## 2026-09-21 第二个坑:行级子块的"标签"必须是**这一行讲的是谁**
上游 `_prefer_section` 靠子块 `title` 的末段判断"客户问的是不是这一行":
重合就保留聚焦答案,不重合就把整节块换上来(`PROD` 的两列表格里,标签是
**字段名**「起投金额」)。而多列表格的**第 0 列是标识**(`159700`),
拿它当标签时 `159700[:2]` = `15` 永远不重合 ⇒ **每一问都被换成整节块**:
实测「科创债ETF南方怎么样」返回的是**20 只产品的整张表**(真 HTTP + 进程内双重复现)。
修法:`qualify` 模式下标签改取**「名称」列**(本手册两张表都有这一列),
没有该列则退回第 0 列 —— 标签语义统一为"这一行讲的是谁/哪个字段"。
"""
blocks: list[dict[str, object]] = []
name = LEADING_NUMBER.sub("", chunk["section"]).strip() or chunk["section"]
def cell_text(cell: str) -> str:
"""单元格文本:去掉 `⚠️` 这类**编辑标记**(同 `strip_blockquote_lines` 的理由),
并把「—」归一成「无」——费率表里的「—」表示"该产品不收这项费用",
原样带进正文只会让模型与客户都读不准。"""
cleaned = cell.replace("⚠️", "").replace("⚠", "").strip()
return "无" if cleaned in {"", "—", "-"} else cleaned
def row_content(cells: list[str], header: list[str] | None) -> str:
"""一行 → 自解释正文。`qualify` 关时逐字保持既有行为。"""
if not qualify or header is None or len(header) != len(cells):
return f"{name}:{cells[0]} {cells[1]}"
pairs = [
f"{column} {cell_text(cell)}"
for column, cell in zip(header, cells, strict=True)
]
return f"{name}:{' / '.join(pairs)}"
def subject_of(cells: list[str], header: list[str] | None) -> str:
"""行级子块的**标签**:「这一行讲的是谁」——两列表格是字段名,多列表格是主体名。
为什么不能一律用第 0 列:多列表格的第 0 列是**标识**(`代码 159700`),
用它当标签会让 `_prefer_section` 判不出"客户问的就是这一行",
于是把整节块换上来 —— 客户问一只产品,拿回 20 只产品的整张表。
`qualify` 模式下优先取表头里名为「名称」的那一列。
"""
if qualify and header is not None and len(header) == len(cells):
for column, cell in zip(header, cells, strict=True):
if "名称" in column and cell.strip():
return cell.strip()
return cells[0]
def emit(cells: list[str], header: list[str] | None = None) -> None:
if len(cells) < 2:
return
label, value = cells[0], cells[1]
if not label or not value:
return
subject = subject_of(cells, header) or label
blocks.append({
"title": f"{chunk['title']} · {subject}",
"chapter": chunk["chapter"],
"section": f"{name} · {subject}",
"content": row_content(cells, header),
"parent_id": parent_id,
})
header: list[str] | None = None
prev: list[str] | None = None
for line in chunk["content"].splitlines():
match = TABLE_ROW.match(line.strip())
if not match:
continue
cells = [cell.strip() for cell in match.group(1).split("|")]
if all(set(cell) <= {"-", ":", " "} for cell in cells):
# 分隔行 |---|:紧邻它之前的那一行(`prev`)是**表头**,不能当数据行 → 丢弃。
# `qualify` 要靠它给每一列配字段名,所以这里**记下来**再丢弃。
header = prev
prev = None
continue
if prev is not None:
emit(prev, header) # 没被分隔行认领为表头的行 = 数据行
prev = cells
if prev is not None:
emit(prev, header) # 收尾:最后一行也要处理(没有分隔行收尾的表格)
return blocks
def assert_no_duplicate_contents(records: list[dict[str, object]]) -> None:
"""自带守卫:**正文完全相同的块必须为 0**,否则直接失败退出、不生成 jsonl。
为什么用这一条当守卫:表头被误当数据行时的直接后果就是"**多张表格产出逐字相同的块**"
(第九条那 16 张问卷表 → 15 条一模一样的「…:选项 分值」)。这类块在检索里必然互相
打平,把 top1/次优差压到 0.07 门槛之下(实测 0.002)→ 客服判并列转人工。
本脚本是**一次性灌库脚本**、没有单测覆盖(这正是该 bug 活下来的原因),
所以把守卫放在脚本自己的执行路径上:**每次重灌都会跑一遍**。
为什么不是"块长度下限":短块本身是设计的一部分(「评审标准:管理人资质 15%」13 字,
但它是真实的数据行、是有效答案)。**内容逐字重复**才是缺陷特征,长度不是。
"""
seen: dict[str, list[str]] = {}
for record in records:
seen.setdefault(str(record["content"]), []).append(str(record["doc_id"]))
duplicated = {content: ids for content, ids in seen.items() if len(ids) > 1}
if duplicated:
detail = "\n".join(
f" {len(ids)} 份:{content[:60]!r} → {ids[:6]}"
for content, ids in list(duplicated.items())[:5]
)
raise SystemExit(
f"知识块自检失败:有 {len(duplicated)} 组正文完全相同的块。\n"
"这类块在检索里必然互相打平(把 top1/次优差压到 0.07 之下 → 客服转人工),"
"通常是**表格表头被当成了数据行**(见 `expand_table_rows` 的 docstring)。\n"
f"{detail}\n已中止,未写入 jsonl。"
)
records: list[dict[str, object]] = []
for relative, config in SOURCES.items():
text = (Path("knowledge") / relative).read_text(encoding="utf-8")
if config.get("strip_editorial_marks"):
# `>` 行 = 写给**入库人**的编辑说明(版本口径、草稿对照等),不是写给客户的。
# `E3` / `E5b` 会把块正文直接呈现给客户 ⇒ 让它们进语料等于把内部文档名
# 和「两个净值都对,只是日期不同」这种内部讨论摆到客户面前。
# `⚠️` 是同一段说明在表格里的锚点,一起清掉(见 SOURCES 里的注释)。
text = "\n".join(
line.replace("⚠️", "").replace("⚠", "")
for line in text.splitlines()
if not line.lstrip().startswith(">")
)
chunks = chunk_markdown(text)
# 章节白名单:只保留指定章下的块(用于剔除内部管理章节,如高净值规范只留分级与权益)
allowed = config.get("allow_chapters")
if isinstance(allowed, list):
chunks = [
chunk for chunk in chunks
if any(chunk["chapter"].startswith(prefix) for prefix in allowed)
]
# 小节级剔除:与 `allow_chapters` 同一套"先过滤、后编号"的口径 ——
# ⚠️ 过滤集合一变,其后所有父块的编号会平移(`ETF-010` → `ETF-009`),
# 引用 doc_id 的地方(灌库自检、文档)必须跟着改。
excluded = config.get("exclude_sections")
if isinstance(excluded, list):
chunks = [chunk for chunk in chunks if chunk["section"] not in excluded]
for order, chunk in enumerate(chunks, 1):
parent_id = f"{config['prefix']}-{order:03d}"
records.append({
"doc_id": parent_id,
"collection": config["collection"],
"title": chunk["title"],
"content": chunk["content"],
"chapter": chunk["chapter"],
"section": chunk["section"],
"tags": config["tags"],
"doc_no": config["doc_no"],
"version": config["version"],
"effective_date": config["effective_date"],
"expire_date": "", "source_url": "", "reviewer": "",
"source_file": relative,
"visibility": config["visibility"],
"chars": len(chunk["content"]),
})
# 行级子块:挂在父块 doc_id 下(PROD-007-01 这种),父块编号不受新增子块影响,
# 因此反复重跑本脚本得到的 doc_id 是稳定的。
for row_order, block in enumerate(
expand_table_rows(
chunk, parent_id, qualify=bool(config.get("qualify_table_rows"))
),
1,
):
records.append({
"doc_id": f"{parent_id}-{row_order:02d}",
"collection": config["collection"],
"title": block["title"],
"content": block["content"],
"chapter": block["chapter"],
"section": block["section"],
"tags": config["tags"],
"doc_no": config["doc_no"],
"version": config["version"],
"effective_date": config["effective_date"],
"expire_date": "", "source_url": "", "reviewer": "",
"source_file": relative,
"visibility": config["visibility"],
"chars": len(str(block["content"])),
})
for order, chunk in enumerate(
chunk_qa((Path("knowledge") / "faq/高频问答对.txt").read_text(encoding="utf-8")), 1
):
records.append({
"doc_id": f"FAQ-{order:04d}",
"collection": "fin_faq_collection",
"title": chunk["title"], "content": chunk["content"],
"chapter": chunk["chapter"], "section": chunk["section"],
"tags": "高频问答,FAQ",
"doc_no": "", "version": "", "effective_date": "", "expire_date": "",
"source_url": "", "reviewer": "",
"source_file": "faq/高频问答对.txt",
"visibility": "registered" if order in FAQ_REGISTERED_QIDS else "public",
"chars": len(chunk["content"]),
})
_faq_records = [record for record in records if str(record["doc_id"]).startswith("FAQ-")]
_faq_registered = [record for record in _faq_records if record["visibility"] == "registered"]
if len(_faq_records) != FAQ_EXPECTED_COUNT:
raise SystemExit(
f"FAQ 条数不符:实际 {len(_faq_records)},期望 {FAQ_EXPECTED_COUNT}。"
"`knowledge/faq/高频问答对.txt` 应为 `D6.1.3` 的 64 组 + `W6` 补 1 组 + `W21` 补 4 组 = 69 组版本;"
"条数不符通常是镜像被换成了旧版(V1.x 只有 44 条)。已中止,未写入 jsonl。"
)
if len(_faq_registered) != len(FAQ_REGISTERED_QIDS):
raise SystemExit(
f"FAQ 档位不符:registered 实际 {len(_faq_registered)},期望 {len(FAQ_REGISTERED_QIDS)}。"
"档位名单见 `D6.1.2` §附「四、知识库可见性档位标注」。已中止,未写入 jsonl。"
)
# ---- v1.4(2026-09-18)三个派生字段:family_id / param_class / intent ----------------
#
# 为什么加:`D2.4` 附录F 的「同族合并」「计算型参数位」「意图标签」三条能力依赖它们,
# 而此前切片件**不含**这三个字段 ⇒ 设计里写了、数据里没有。三个字段全部**从既有信息
# 派生**,不改任何源文件、不引入新的人工标注,因此重跑本脚本即得。
#
# · `family_id` —— **同族 = 同一个父块**。行级子块(`POL-AST-009-01`)挂在父块
# (`POL-AST-009`)下,父块编号就是族号,故去掉行级子块后缀(`-NN`)即得。
# 用途:同族并列时上层应**合并作答**,而不是把它们当成"两个互不相干的候选"
# 去算 top1/次优差(那会把真实答案判成"并列"→转人工)。
# ⚠️ 后缀只认**两位数字**:`FAQ-0026` / `COMP-001` 是父块本身(四位/三位数字),
# 不能被误削成 `FAQ-` / `COMP-`。
# · `param_class` —— 块里**最主要**的数值型产品要素,供计算型出口定位参数位。
# 判据是「关键字在正文中**最早出现**的那一类」(块的开口主语),同类再按规则表顺序;
# 刻意**不用固定优先级**,否则「费率表里顺带写了一句起投金额」会被整块判成 threshold。
# · `intent` —— 业务意图,按集合映射。`INTENT_BY_QA_PREFIX`(`app/core/
# knowledge_contracts.py`)管的是 `RAG-*` 前缀问答;本脚本**不 import 应用层**
# (它是一次性切片脚本,要保持能单独跑),故在此镜像同一份取值 —— **改一处要改两处**。
FAMILY_SUFFIX = re.compile(r"-\d{2}$")
INTENT_BY_COLLECTION: dict[str, str] = {
"fin_basic_collection": "basic_explain",
"fin_faq_collection": "faq",
"fin_product_collection": "product_inquiry",
"fin_policy_collection": "policy_explain",
}
#: (参数类型, 关键字)。顺序 = 同位置时的优先级。
PARAM_CLASS_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("rate", ("费率", "管理费", "托管费", "申购费", "赎回费", "销售服务费", "业绩报酬", "折扣")),
("threshold", ("起投", "起购", "起点", "门槛", "万元", "万+", "元起", "认购起点", "专户起点", "合格投资者")),
("scale", ("规模", "募集", "亿元")),
("count", ("家数", "家机构", "只基金", "网点")),
)
def family_of(doc_id: str) -> str:
"""同族标识:去掉**两位**行级子块后缀;父块自身即族号。"""
return FAMILY_SUFFIX.sub("", doc_id)
def classify_param(content: str) -> str:
"""块里最主要的数值型产品要素类型;一类都没命中时返回 `none`。"""
best: tuple[int, int, str] | None = None
for rank, (name, keywords) in enumerate(PARAM_CLASS_RULES):
found = [content.find(keyword) for keyword in keywords]
first = min((position for position in found if position >= 0), default=-1)
if first < 0:
continue
candidate = (first, rank, name)
if best is None or candidate[:2] < best[:2]:
best = candidate
return best[2] if best is not None else "none"
for _record in records:
_doc_id = str(_record["doc_id"])
_record["family_id"] = family_of(_doc_id)
_record["param_class"] = classify_param(str(_record["content"]))
_record["intent"] = INTENT_BY_COLLECTION.get(str(_record["collection"]), "faq")
del _record, _doc_id
assert_corpus_gate(records)
assert_no_duplicate_contents(records)
(Path("knowledge") / "_chunks.jsonl").write_text(
"\n".join(json.dumps(record, ensure_ascii=False) for record in records), encoding="utf-8"
)
lines: list[str] = [f"总块数:{len(records)}\n"]
by_collection: dict[str, list[dict[str, object]]] = {}
for record in records:
by_collection.setdefault(str(record["collection"]), []).append(record)
for name, group in sorted(by_collection.items()):
sizes = sorted(int(record["chars"]) for record in group)
lines.append(
f"{name}: {len(group)} 块,字符数 最小 {sizes[0]} / 中位 {sizes[len(sizes)//2]} / 最大 {sizes[-1]}"
)
lines.append("\n各文件块数:")
by_file: dict[str, int] = {}
for record in records:
by_file[str(record["source_file"])] = by_file.get(str(record["source_file"]), 0) + 1
for name, count in sorted(by_file.items()):
lines.append(f" {name}: {count}")
over = [record for record in records if int(record["chars"]) > 1200]
lines.append(f"\n超过 1200 字符的块:{len(over)} 个")
for record in over[:10]:
lines.append(f" {record['doc_id']} {record['chars']} 字符 {str(record['title'])[:64]}")
lines.append("\n反洗钱手册的块标题(核对「第一类」归属是否带上了第十三条):")
for record in records:
if str(record["doc_id"]).startswith("POL-AML"):
lines.append(f" {record['doc_id']} {int(record['chars']):>5} 字符 {str(record['title'])[:70]}")
Path("_chunks_report.txt").write_text("\n".join(lines), encoding="utf-8")
print(f"已生成 {len(records)} 块 → knowledge/_chunks.jsonl;报告见 _chunks_report.txt")