补历史欠账: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)是禁令条款,故意保留
574 lines
32 KiB
Python
574 lines
32 KiB
Python
"""把 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")
|