"""把 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」+ 附录B `v1.3` 裁定「高净值服务分层门槛 # (金卡 50 万+ / 白金 200 万+ / 钻石 600 万+ / 私行 1000 万+)保持 `registered`」 # ⇒ 故 `HNW-004`—`HNW-007` 对访客不可见,访客问「高净值客户有什么权益」走 # 「引导登录」,**不泄露档位与门槛**。改回 public 前先读那两处裁定。 "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": "公司信息,金融牌照,资质", }, } #: 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` 的档位隔离会被无声破坏)。 FAQ_EXPECTED_COUNT = 65 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) -> 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。 """ blocks: list[dict[str, object]] = [] name = LEADING_NUMBER.sub("", chunk["section"]).strip() or chunk["section"] def emit(cells: list[str]) -> None: if len(cells) < 2: return label, value = cells[0], cells[1] if not label or not value: return blocks.append({ "title": f"{chunk['title']} · {label}", "chapter": chunk["chapter"], "section": f"{name} · {label}", "content": f"{name}:{label} {value}", "parent_id": parent_id, }) 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`)是**表头**,不能当数据行 → 丢弃。 prev = None continue if prev is not None: emit(prev) # 没被分隔行认领为表头的行 = 数据行 prev = cells if prev is not None: emit(prev) # 收尾:最后一行也要处理(没有分隔行收尾的表格) 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") 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) ] 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), 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 组 = 65 组版本;" "条数不符通常是镜像被换成了旧版(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")