KDD Cup 2026 Champion:文档筛选与结构化抽取工作流
发布于
如果我是主角就的话 当不成英雄也好 没有人感谢也罢 只要你能展露笑容 ——それを世界と言うんだね
文档性质:当前实现说明、真实案例复盘与准确性边界
代码核对日期:2026-08-12
适用链路:
doc_relevance_agent → doc_prepare → fanout_struct → solver
这条工作流解决什么问题
一个 task 的 context/doc/ 中可能有多份叙事文档。它们不能直接像 CSV 或数据库一样查询,而且通常只有少数几份与题目真正相关。
当前项目采用一条连续工作流解决这个问题:
doc_relevance_agent先从候选文档中筛出解题确需的文档;doc_prepare将文本型 PDF 转成 Markdown,并只处理筛选后的文档;- Planner 阅读相关文档全文,确定记录全集、字段和属性分区;
- 多个 section worker 并发抽取子表;
- 程序校验子表结构,并按
record_id合并为宽表; - 必要时统一日期、数值和单位格式;
- 每份文档写入一个同名 SQLite 数据库,供 solver 直接查询。
这条工作流的重点是把少量相关文档转换成可查询、可追溯的结构化数据。
为什么先筛选,再抽取
文档结构化需要 Planner、多个 worker 和字段归一化等多次模型调用,是整条链路中成本较高的阶段。先筛选可以减少无关抽取,也能降低 solver 面对的表数量。
| 阶段 | 回答的问题 | 阅读范围 | 主要输出 |
|---|---|---|---|
| 文档筛选 | 解题是否确需这份文档? | 每份文档的短预览 | relevant_docs |
| 文档抽取 | 如何把相关文档变成可查询表? | 相关文档全文 | context/db/<stem>.db |
被判为无关的文档不会被删除。原始文件仍在 context/doc/;文本型 PDF 还可能生成同名 Markdown,供 solver 必要时回读。
工作流的输入与输出
输入来自哪里
| 输入 | 默认来源 | 在工作流中的作用 |
|---|---|---|
| 问题 | task.json.question | 判断题目要查什么实体和指标 |
| Knowledge | context/knowledge.md | 提供字段命名、单位、标识符和业务口径 |
| 视频口径 | workdir/_video_frames/video_input.json,缺失时退回 timeline.txt | 补充视频中说明的筛选条件、字段和表 |
| 候选文档 | context/doc/ | 相关性筛选支持.md、.markdown、.txt 和 .pdf |
| 抽取模型 | 主流程注入的 no-think / think 模型 | 前者负责筛选、规划和抽取,后者负责生成格式转换函数 |
相关性判断只读取每份文档的前 40 行预览,每行最多 400 个字符;Knowledge 最多注入 8,000 个字符,视频文本最多注入 6,000 个字符。
最终会生成什么
对每份成功抽取的文档,当前实现生成:
context/doc/<stem>.md
↓
context/db/<stem>.db
└─ table <stem>
同时在 task 日志目录中保存计划、模型轨迹、完整结果、TSV、缓存键和汇总信息。SQLite 面向 solver,TSV 和 JSON 面向人工复盘。
文档筛选:doc_relevance_agent
候选文档如何装配
程序扫描 context/doc/,按文件 stem 聚合候选文档。同 stem 的 Markdown、文本和 PDF 被视为同一份文档;生成预览时优先读取 Markdown 或文本,其次才尝试从 PDF 提取文字。
这里要区分“筛选支持”和“抽取支持”:相关性阶段能预览 .md、.markdown、.txt 和 .pdf,但当前 doc_prepare 最终只枚举 *.md 进入结构化流程。PDF 会先生成同名 .md,因此可以继续抽取;单独存在的 .markdown 或 .txt 不会被当前主抽取入口处理。
如果 PDF 无法提取足够文字,相关性 prompt 中会放入:
(无法读取预览: 可能是扫描件/图片型 pdf)
这不会阻断 task,也不会直接把文档判为无关。
一次相关性判断调用包含什么
一次调用由两部分消息组成:
一次相关性判断调用
├─ System instructions(固定)
│ └─ 判断标准、召回原则和输出格式
│
└─ User input(随 task 变化)
├─ question
├─ knowledge.md 的开头部分
├─ 视频文本(可选)
└─ 当前 task 的全部候选文档预览
其中,System instructions 每轮相同;User input 则由当前 task 的材料动态拼接。
当前源码中的 System instructions 本身就是中文。去掉篇幅较长的强调和反例后,其等效含义是:
判断每个候选文档是否是完成当前数据分析任务的必要输入。直接承载答案数据、提供必经实体映射,或被视频明确点名的文档应判为相关;仅仅同领域、主题相近或只有背景价值的文档应判为无关。拿不准时偏向召回。必须覆盖输入清单中的每一份文档,不得新增文档名。
判为相关的三种主要情况是:
- 文档直接包含题目所需的字段、指标或记录;
- 文档提供必经映射,例如把题面的外部产品代码映射到其他数据源使用的内部 ID;
- 视频明确指向该文档中的字段、实体、表或统计口径。
以下情况不能单独作为相关理由:同一数据库、同一行业、主题沾边、可能有参考价值,或仅仅也含有某种编号字段。
字段顺序刻意设计为 doc_name → reason → relevant,让模型先写依据,再给布尔结论。
一次真实调用的 User input 结构示意
下面取自仓库中的一组真实输入。此处只用它说明“一次调用收到什么”,对应任务的完整执行过程会在后文介绍。
为控制篇幅,下面省略了大段 Knowledge 和文档预览。方括号中的文字是本文添加的省略说明,不会真的发送给模型。真实调用会放入 knowledge.md 开头最多 8,000 个字符,并放入全部 7 份候选文档各自的前 40 行预览。
## task question
显示300707的出让前持股数量和出让前持股比例 谢谢
## knowledge
[knowledge.md 的前 8,000 个字符,下面只摘录与本例有关的内容]
……
lc_sharetransfer:
- secucode:六位证券代码
- sumbeforetran:出让前持股数量
- pctbeforetran:出让前持股比例
……
## 候选 doc 清单
### doc stem: lc_actualcontroller
[该文档前 40 行预览]
……
### doc stem: lc_executivesholdings
[该文档前 40 行预览]
……
### doc stem: lc_financialexpense
[该文档前 40 行预览]
……
### doc stem: lc_issueandlistagent
[该文档前 40 行预览]
……
### doc stem: lc_sharestru
[该文档前 40 行预览]
……
### doc stem: lc_sharetransfer
威唐工业(300707)股权结构变动……
[其余预览内容省略]
### doc stem: qt_monthdata
[该文档前 40 行预览]
……
本例没有视频,所以 User input 中没有“视频口径”段。只有当前 task 存在已生成的视频文本时,程序才会在 Knowledge 与候选文档之间插入:
## 视频口径 (操作讲解视频的幻灯片+旁白文本)
[旁白和版面文字,最多 6,000 个字符]
上面的 question、Knowledge 和 7 份文档预览会被拼成一条 User input,完整发送给模型一次。模型读取一次后,需要在同一条回复中为 7 份文档分别返回一条 verdict。
| 一次调用中的对象 | 本例数量 | 含义 |
|---|---|---|
| User input | 1 条 | 包含当前 task 的全部动态材料 |
| 候选文档预览 | 7 份 | 全部放在同一条 User input 中 |
| 模型回复 | 1 条 | 返回一个结构化对象 |
verdicts | 7 条 | 与 7 份候选文档一一对应 |
同一轮模型输出的结构如下。为节省篇幅,这里只展示其中 3 条;真实回复必须覆盖全部 7 份文档。
{
"verdicts": [
{
"doc_name": "lc_actualcontroller",
"reason": "文档描述实际控制人关系,不包含题目要求的股权转让前持股数据。",
"relevant": false
},
{
"doc_name": "lc_sharetransfer",
"reason": "文档直接记录300707的出让前持股数量和持股比例。",
"relevant": true
},
{
"doc_name": "qt_monthdata",
"reason": "文档记录基金月度持仓,不是上市公司股权转让记录。",
"relevant": false
}
]
}
因此,“一次调用同时判断全部文档”的准确含义是:程序把 question、Knowledge、可选视频文本和全部候选文档预览拼成一条 User input;模型读取一次,并在一条回复中逐文档给出判断。
五轮投票如何聚合
上一节描述的是一轮判断。当前程序默认并发收集 5 个成功轮次;每轮都会收到结构相同的完整 User input,并重新判断全部候选文档。失败或超时的轮次不计票,程序会在有限批次内补发。得到成功轮次后,再对每份文档单独聚合票数。
| 情况 | 最终处理 |
|---|---|
true 票更多 | 判为相关 |
| 平票 | 判为相关,召回优先 |
| 某轮遗漏该文档 | 该轮对该文档弃权 |
| 所有成功轮都遗漏该文档 | 默认相关 |
| 所有调用均失败或超时 | 所有文档默认相关 |
最后得到 per_doc、relevant_docs、skipped_docs、理由和票数。例如:
{
"relevant_docs": ["lc_sharetransfer"],
"skipped_docs": [
"lc_actualcontroller",
"lc_executivesholdings",
"lc_financialexpense",
"lc_issueandlistagent",
"lc_sharestru",
"qt_monthdata"
],
"vote_tally": {
"lc_actualcontroller": "0/5",
"lc_executivesholdings": "0/5",
"lc_financialexpense": "0/5",
"lc_issueandlistagent": "0/5",
"lc_sharestru": "0/5",
"lc_sharetransfer": "5/5",
"qt_monthdata": "0/5"
}
}
文档准备与结构化抽取:doc_prepare
doc_prepare 接收上一步生成的 relevant_stems。文档之间串行处理,避免与 task 级并发叠加;单份文档内部的 section worker 才并发执行,默认并发上限为 6。
PDF 如何转换成 Markdown
在相关性过滤之前,程序会尝试把 context/doc/*.pdf 全部转成同名 .md。已存在同名 Markdown 时跳过,不重复转换。
转换由 PyMuPDF 在本地完成:
- 从 PDF 文本块中提取带坐标和字号的视觉行;
- 按页面上的纵坐标、横坐标恢复阅读顺序;
- 删除跨页重复的短页眉、页脚和页码;
- 根据正文中位字号与垂直间距识别标题和段落;
- 当前一页末段没有句末标点时,与下一页首段合并;
- 以“一段一行”的形式写入同名 Markdown。
这里的“文本型 PDF”是指内部存在可选择、可复制的文字层;“扫描型 PDF”通常只有页面图片。程序读取前 5 页的可提取字符数,平均每页不少于 50 个字符才视为文本型 PDF。低于阈值只告警并跳过,不会自动 OCR。
相关性过滤与缓存
PDF reflow 完成后,程序只保留 stem 位于 relevant_stems 中的 Markdown。若 relevant_stems=None,表示相关性阶段整体失败或调用方没有启用过滤,此时抽取全部 Markdown。
每份文档的缓存键由以下内容共同计算:
文档内容 SHA-1
+ knowledge.md 内容 SHA-1(没有则记 no_knowledge)
+ no-think 模型名
+ pipeline 版本 fanout_v2
缓存文件位于当前 task 的 logs/<task>/doc_extract/。因此正常目录组织下,它主要用于同一 task、同一日志目录的复跑;不同 task 的缓存目录相互隔离,不会自然跨 task 命中。任一键要素变化都会重新抽取。
Planner:先规划,不抽具体值
程序先给原文每行增加 0001\t正文 形式的行号,再把以下内容交给 Planner:
- 带行号的完整文档;
knowledge.md顶部最多 80 行、4,000 字符的全局口径;- 在
knowledge.md中按当前 doc stem 做不区分大小写子串搜索后,取每个命中行前 5 行、后 10 行并合并的局部摘录,最多 6,000 字符。
所谓“局部 knowledge 摘录”,就是当前文档在 Knowledge 中对应字段说明附近的一小段上下文。它用于统一列名、单位和业务含义,不是记录数据源。
Planner 输出一份 JSON 计划,核心内容包括:
{
"sections": [
{
"id": 1,
"title": "基本信息",
"start_line": 1,
"end_line": 3,
"columns": ["record_id", "product_name", "product_type"]
},
{
"id": 2,
"title": "季度收益",
"start_line": 5,
"end_line": 7,
"columns": ["record_id", "q1_return", "q2_return"]
}
],
"record_universe": ["P01", "P02"],
"schema": {
"record_id": {
"doc_meaning": "产品主键,字符串",
"task_meaning": "产品主键,字符串"
},
"q1_return": {
"doc_meaning": "带百分号字符串,如 3.2%",
"task_meaning": "单位为百分比的裸数,如 3.2"
}
}
}
这些字段可以这样理解:
| 字段 | 含义 | 在示例中的作用 |
|---|---|---|
sections | Planner 按“属性类型”划分的抽取任务列表 | 把“基本信息”和“季度收益”交给不同 worker 抽取 |
id | section 的唯一编号 | 后续用于区分 worker、日志和来源行号 |
title | 对该 section 内容的简短说明 | 方便模型和人工理解这一块要抽什么 |
start_line / end_line | 该属性块在带行号原文中的起止位置 | 表示“基本信息”大致位于第 1~3 行 |
columns | 当前 section 的目标列 | 基本信息 worker 只需抽record_id、产品名称和产品类型 |
record_universe | 全文所有记录 ID 的完整名单 | 文档中共有P01、P02 两条记录,所以每个 worker 都必须输出这两行;缺少数据时填 NULL,不能删掉该 ID |
schema | 最终宽表所有字段的统一定义 | 规定每列叫什么、在原文中是什么含义、入库后应是什么口径 |
doc_meaning | 字段在原文中的真实写法和单位 | q1_return 在文档中可能写成带 % 的 3.2% |
task_meaning | 字段写入数据库时需要对齐的目标口径 | 目标要求保存为不带% 的 3.2 |
其中最关键的是 record_universe。可以把它理解成一份“记录点名册”:Planner 先从全文列出所有 ID,之后每个 section worker 都必须按照同一份名单输出,最终才能稳定地按 record_id 合并各张子表。
全文识别出 P01、P02
↓
record_universe = ["P01", "P02"]
↓
基本信息 worker:输出 P01、P02
季度收益 worker:输出 P01、P02
↓
按 record_id 合并成两条完整记录
doc_meaning 和 task_meaning 的区别是“忠实抽取”与“目标口径”:worker 先按 doc_meaning 保留原文值;两者不一致时,后面的归一化阶段再把值转换成 task_meaning 要求的格式。
Fan-out:按属性块并发抽取
叙事文档经常不是“一行包含一条完整记录”,而是先集中描述所有实体的基础信息,再描述同一批实体的指标。Planner 按属性维度切出 section,每个 section 交给一个 worker,这个“一拆多”的过程就是 fan-out。
每个 worker 都会收到完整文档、完整 record_universe、统一 schema 和自己的 section 定义。当前实现不会按照 start_line/end_line 裁切文档;这些行号是计划和审计信息,不是强制读取边界。
Worker 只按文档原始口径抽取,不负责单位换算。输出必须是一张 Markdown 子表:
| __src_line | record_id | product_name | product_type |
|---|---|---|---|
| 2 | P01 | Alpha | 股票型 |
| 3 | P02 | Beta | 债券型 |
规则包括:
- 每个
record_id恰好一行; - 当前 section 没有某条记录的数据时仍保留该行,业务字段填
NULL; - 同一记录分散在多处的信息合并到一行;
- 数值和特殊格式优先忠实保留原文;
__src_line记录当前 section 中该记录的来源行。
程序校验、重试与降级
Planner 和 worker 的模型输出都要经过确定性代码校验。
Planner 校验当前实际覆盖:
sections、record_universe、schema存在且非空;record_universe没有重复 ID;- schema 包含
record_id,每个字段都有非空的doc_meaning和task_meaning; - section 第一列是
record_id,列名符合 snake_case; start_line/end_line是正整数,且单个 section 内起点不大于终点。
Worker 校验当前实际覆盖:
- 回复未因 token 上限截断;
- 能解析出 Markdown 表;
- 表头和每行列数正确;
- 行数等于
record_universe大小; - 没有缺失、额外或重复的
record_id。
Planner 首次失败会收到错误原因并重试一次;第二次仍失败则当前文档抽取失败。Worker 首次失败也会针对缺失或重复 ID 重试一次;第二次仍失败则保留最后一次尽力结果,并记录失败 section 数。
宽表合并与来源追踪
所有 section 子表按 record_id 合并成一张宽表。__src_line 不作为业务列落库,而是转存到 provenance:
{
"29": {
"1": "3",
"2": "107",
"3": "211",
"4": "315"
}
}
其含义是:record 29 的四组属性分别来自原文第 3、107、211 和 315 行。
合并采用“非空值覆盖”逻辑。同名列如果在多个 section 中都有非空值,后处理的 section 会覆盖先处理的 section;因此 Planner 必须为不同业务含义使用不同列名。当前代码没有自动阻止跨 section 的业务列重名。
字段归一化
只有 Planner 声明 doc_meaning 与 task_meaning 不一致的列才进入归一化:
- 收集该列全部非空 distinct 原值;
- 将真实值和目标格式交给 think 模型生成
transform(s) -> str; - 试运行后,将未覆盖值或异常反馈给模型修订;
- 在隔离子进程中对全部 distinct 值执行,并设置超时;
- 将原值到新值的映射应用到整列。
例如,目标口径如果是“百分比裸数”,转换可以是:
3.2% → 3.2
1.8% → 1.8
NULL → NULL
某个值转换失败时保留原值,并记入 normalize_stats。但“函数执行成功”只表示没有报错,不代表换算结果在业务上正确。
SQLite 落库并交给 solver
每份文档写入一个同名数据库,数据库中也只有一张同名表:
context/db/产品档案.db
└─ table 产品档案
随后数据源运行时扫描 context/db/*.db,将表注册到 solver 的查询面。render_solver_hint 告诉 solver 表名、列名、记录数和来源文档;若成功 section 比例低于 95%,还会标记“抽取不完美”。
task_15:一次真实运行如何经过整条链路
下面的数据来自 2026-08-11 使用当前代码和配置执行 task_15 后保存的日志与产物,不是构造示例。
题目是:
显示300707的出让前持股数量和出让前持股比例 谢谢
| 项目 | 实际结果 |
|---|---|
| 候选文档 | 7 份 |
| 相关性判断 | 5 轮一致,只召回lc_sharetransfer |
| 被抽取文档 | 1 份,28,357 字符、427 行 |
| Planner | 首轮通过;50 个 record、4 个 section |
| Section worker | 4 个全部首轮通过,每个返回 50 行 |
| 合并结果 | 25 列 × 50 行,50 个 record 有 provenance |
| SQLite | context/db/lc_sharetransfer.db,20 列 × 50 行 |
| Solver | attempt 1 完成,共 10 轮模型响应 |
| 总耗时 | 307.1 秒 |
| 阶段耗时 | relevance 8.9 秒;prepare 243.7 秒;solver 37.4 秒 |
| 运行产物 | 成功生成prediction.csv |
| 本地 gold 对比 | 0/2 列匹配,比较分数 0.0 |
从题目到相关文档
knowledge.md 提供了三个直接锚点:
lc_sharetransfer
secucode Security code,6 位股票代码
sumbeforetran Total shares held before transfer
pctbeforetran Shareholding percentage before transfer (ratio)
题目中的 300707 + 出让前持股数量 + 出让前持股比例 与 lc_sharetransfer 的表语义直接对应。五轮投票结果如下:
| 文档 | 票数 | 结果 | 理由摘要 |
|---|---|---|---|
lc_sharetransfer | 5/5 | 相关 | 标题和正文直接承载 300707 的股权转让指标 |
lc_actualcontroller | 0/5 | 无关 | 控制权结构,不是转让前持股数据 |
lc_executivesholdings | 0/5 | 无关 | 高管持仓,不是目标转让记录 |
lc_financialexpense | 0/5 | 无关 | 财务费用材料 |
lc_issueandlistagent | 0/5 | 无关 | 提及 300707 但不含目标指标 |
lc_sharestru | 0/5 | 无关 | 静态股权结构,不是动态转让记录 |
qt_monthdata | 0/5 | 无关 | 基金月度持仓 |
相关性判断后,doc_prepare 仍先将 3 份文本型 PDF reflow 成 Markdown,然后才过滤出 lc_sharetransfer.md 进入结构化抽取。这与“PDF 转换不受相关性过滤”的当前代码一致。
为什么这份文档需要 fan-out
lc_sharetransfer.md 把同一批 50 个 record 分散在四个属性块中:
| 文档区域 | 原文行号 | 内容 |
|---|---|---|
| 基础信息块 | 3~103 的奇数行 | 公司、内部代码、简称、secucode |
| 交易规模块 | 107~207 的奇数行 | 日期、转让数量、相关比例 |
| 转让方块 | 211~311 的奇数行 | 转让方、出让前后数量与比例 |
| 受让方块 | 315~415 的奇数行 | 受让方、受让前后数量与比例 |
| 全文总结 | 417 行以后 | 不属于单条 record 的结论 |
例如 record 29 的事实分散在四处:
line 3 公司 = 威唐工业,secucode = 300707
line 107 日期 = 2018年11月14日,转让数量 = 300,000
line 211 转让方出让前 = 2,441,732 股,1.55%
line 315 受让方信息缺失
Planner 为此生成 50 个 ID 的 record_universe 和四个 section:基础信息、交易规模、转让方详情、受让方详情。四个 worker 各抽取 50 行,再按 record_id 合并。
本次计划还有一个值得注意的实现细节:四个 section 的 start_line/end_line 分别是 3/3、107/107、211/211、315/315,只指向各块第一条记录,并没有覆盖完整区间。计划仍能通过并完成抽取,因为当前校验只检查单 section 的行号类型和大小关系,而 worker 实际读取的是全文。
从宽表到 solver 查询
四个 worker 均在 attempt 0 通过结构校验,合并后得到 25 列 × 50 行。落库前剔除 4 个 *_noise 列和已有最终值对应的 internal_code_raw,SQLite 最终保留 20 列 × 50 行。
Solver 查看 schema 后执行:
SELECT sum_before_tran, pct_before_tran
FROM lc_sharetransfer
WHERE secucode = '300707'
查询成功生成 50 行 × 2 列的 prediction.csv。这说明工作流和查询均跑通,但不等于答案值已经正确。
为什么流程成功,最终比较仍为 0
Gold 中的目标比例是 ratio:
SumBeforeTran PCTBeforeTran
2441732 0.0155
5469268 0.0348
预测中存在三类实际错误:
1.55%被转换为1.55,而 gold 要求0.0155;45 个非空比例值全部缩放错误。- section 3 与 section 4 都输出了
sum_before_tran。record 2542 的受让方交易前持股0覆盖了转让方的25,390,048。 - 中文模糊数字
约六百九十七万股被转换为6097,正确值应为6970000。转换函数没有抛异常,所以统计仍显示成功。
这些错误分别暴露了当前实现的三个边界:
- schema 和 section 列的业务含义没有被程序做一致性检查;
- 跨 section 的同名非空列会发生后值覆盖;
- 归一化只检查函数能否覆盖输入并执行,不验证转换后的业务语义。
一句话总结
这不是两个孤立模块,而是一条“先控制处理范围,再把相关散文转换为可查询表”的工作流:doc_relevance_agent 决定哪些文档值得抽取,doc_prepare + fanout_struct 决定如何抽取、校验、合并和落库,solver 最终消费 SQLite;全过程可追溯,但事实正确性仍需对关键结果回查原文。