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)是禁令条款,故意保留
This commit is contained in:
张胜宇
2026-09-21 12:26:42 +08:00
parent 36d9ba9b9f
commit 94ce44a851
14 changed files with 772 additions and 74 deletions
+122 -8
View File
@@ -79,6 +79,40 @@ SOURCES: dict[str, dict[str, str]] = {
"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 其他产品的查询"],
},
}
@@ -191,7 +225,9 @@ 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]]:
def expand_table_rows(
chunk: dict[str, str], parent_id: str, *, qualify: bool = False
) -> list[dict[str, object]]:
"""把 Markdown 表格的每一行拆成自解释的小块(父块照旧保留)。
为什么需要:现在的粒度是"一个叶子标题 = 一块",产品手册里就是**整个产品小节**
@@ -214,24 +250,79 @@ def expand_table_rows(chunk: dict[str, str], parent_id: str) -> list[dict[str, o
修法: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 emit(cells: list[str]) -> None:
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']} · {label}",
"title": f"{chunk['title']} · {subject}",
"chapter": chunk["chapter"],
"section": f"{name} · {label}",
"content": f"{name}:{label} {value}",
"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())
@@ -240,13 +331,15 @@ def expand_table_rows(chunk: dict[str, str], parent_id: str) -> list[dict[str, o
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) # 没被分隔行认领为表头的行 = 数据行
emit(prev, header) # 没被分隔行认领为表头的行 = 数据行
prev = cells
if prev is not None:
emit(prev) # 收尾:最后一行也要处理(没有分隔行收尾的表格)
emit(prev, header) # 收尾:最后一行也要处理(没有分隔行收尾的表格)
return blocks
@@ -282,6 +375,16 @@ def assert_no_duplicate_contents(records: list[dict[str, object]]) -> None:
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")
@@ -290,6 +393,12 @@ for relative, config in SOURCES.items():
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({
@@ -310,7 +419,12 @@ for relative, config in SOURCES.items():
})
# 行级子块:挂在父块 doc_id 下(PROD-007-01 这种),父块编号不受新增子块影响,
# 因此反复重跑本脚本得到的 doc_id 是稳定的。
for row_order, block in enumerate(expand_table_rows(chunk, parent_id), 1):
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"],