KDD Cup 2026 Champion:文档筛选与结构化抽取工作流

11398 字
29 分钟

KDD Cup 2026 Champion:文档筛选与结构化抽取工作流

发布于

如果我是主角就的话 当不成英雄也好 没有人感谢也罢 只要你能展露笑容 ——それを世界と言うんだね

文档性质:当前实现说明、真实案例复盘与准确性边界

代码核对日期:2026-08-12

适用链路:doc_relevance_agent → doc_prepare → fanout_struct → solver

这条工作流解决什么问题

一个 task 的 context/doc/ 中可能有多份叙事文档。它们不能直接像 CSV 或数据库一样查询,而且通常只有少数几份与题目真正相关。

当前项目采用一条连续工作流解决这个问题:

  1. doc_relevance_agent 先从候选文档中筛出解题确需的文档;
  2. doc_prepare 将文本型 PDF 转成 Markdown,并只处理筛选后的文档;
  3. Planner 阅读相关文档全文,确定记录全集、字段和属性分区;
  4. 多个 section worker 并发抽取子表;
  5. 程序校验子表结构,并按 record_id 合并为宽表;
  6. 必要时统一日期、数值和单位格式;
  7. 每份文档写入一个同名 SQLite 数据库,供 solver 直接查询。
文档筛选与结构化抽取工作流总览
文档筛选与结构化抽取工作流总览

这条工作流的重点是把少量相关文档转换成可查询、可追溯的结构化数据。

为什么先筛选,再抽取

文档结构化需要 Planner、多个 worker 和字段归一化等多次模型调用,是整条链路中成本较高的阶段。先筛选可以减少无关抽取,也能降低 solver 面对的表数量。

阶段回答的问题阅读范围主要输出
文档筛选解题是否确需这份文档?每份文档的短预览relevant_docs
文档抽取如何把相关文档变成可查询表?相关文档全文context/db/<stem>.db

被判为无关的文档不会被删除。原始文件仍在 context/doc/;文本型 PDF 还可能生成同名 Markdown,供 solver 必要时回读。

工作流的输入与输出

输入来自哪里

输入默认来源在工作流中的作用
问题task.json.question判断题目要查什么实体和指标
Knowledgecontext/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 input1 条包含当前 task 的全部动态材料
候选文档预览7 份全部放在同一条 User input 中
模型回复1 条返回一个结构化对象
verdicts7 条与 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_docrelevant_docsskipped_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 在本地完成:

  1. 从 PDF 文本块中提取带坐标和字号的视觉行;
  2. 按页面上的纵坐标、横坐标恢复阅读顺序;
  3. 删除跨页重复的短页眉、页脚和页码;
  4. 根据正文中位字号与垂直间距识别标题和段落;
  5. 当前一页末段没有句末标点时,与下一页首段合并;
  6. 以“一段一行”的形式写入同名 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"
    }
  }
}

这些字段可以这样理解:

字段含义在示例中的作用
sectionsPlanner 按“属性类型”划分的抽取任务列表把“基本信息”和“季度收益”交给不同 worker 抽取
idsection 的唯一编号后续用于区分 worker、日志和来源行号
title对该 section 内容的简短说明方便模型和人工理解这一块要抽什么
start_line / end_line该属性块在带行号原文中的起止位置表示“基本信息”大致位于第 1~3 行
columns当前 section 的目标列基本信息 worker 只需抽record_id、产品名称和产品类型
record_universe全文所有记录 ID 的完整名单文档中共有P01P02 两条记录,所以每个 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_meaningtask_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 校验当前实际覆盖:

  • sectionsrecord_universeschema 存在且非空;
  • record_universe 没有重复 ID;
  • schema 包含 record_id,每个字段都有非空的 doc_meaningtask_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_meaningtask_meaning 不一致的列才进入归一化:

  1. 收集该列全部非空 distinct 原值;
  2. 将真实值和目标格式交给 think 模型生成 transform(s) -> str
  3. 试运行后,将未覆盖值或异常反馈给模型修订;
  4. 在隔离子进程中对全部 distinct 值执行,并设置超时;
  5. 将原值到新值的映射应用到整列。

例如,目标口径如果是“百分比裸数”,转换可以是:

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的出让前持股数量和出让前持股比例 谢谢
task_15 真实运行总览
task_15 真实运行总览
项目实际结果
候选文档7 份
相关性判断5 轮一致,只召回lc_sharetransfer
被抽取文档1 份,28,357 字符、427 行
Planner首轮通过;50 个 record、4 个 section
Section worker4 个全部首轮通过,每个返回 50 行
合并结果25 列 × 50 行,50 个 record 有 provenance
SQLitecontext/db/lc_sharetransfer.db,20 列 × 50 行
Solverattempt 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_sharetransfer5/5相关标题和正文直接承载 300707 的股权转让指标
lc_actualcontroller0/5无关控制权结构,不是转让前持股数据
lc_executivesholdings0/5无关高管持仓,不是目标转让记录
lc_financialexpense0/5无关财务费用材料
lc_issueandlistagent0/5无关提及 300707 但不含目标指标
lc_sharestru0/5无关静态股权结构,不是动态转让记录
qt_monthdata0/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  受让方信息缺失
task_15 中 record 29 的四段合并
task_15 中 record 29 的四段合并

Planner 为此生成 50 个 ID 的 record_universe 和四个 section:基础信息、交易规模、转让方详情、受让方详情。四个 worker 各抽取 50 行,再按 record_id 合并。

本次计划还有一个值得注意的实现细节:四个 section 的 start_line/end_line 分别是 3/3107/107211/211315/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. 1.55% 被转换为 1.55,而 gold 要求 0.0155;45 个非空比例值全部缩放错误。
  2. section 3 与 section 4 都输出了 sum_before_tran。record 2542 的受让方交易前持股 0 覆盖了转让方的 25,390,048
  3. 中文模糊数字 约六百九十七万股 被转换为 6097,正确值应为 6970000。转换函数没有抛异常,所以统计仍显示成功。
task_15 的真实错误传播链
task_15 的真实错误传播链

这些错误分别暴露了当前实现的三个边界:

  • schema 和 section 列的业务含义没有被程序做一致性检查;
  • 跨 section 的同名非空列会发生后值覆盖;
  • 归一化只检查函数能否覆盖输入并执行,不验证转换后的业务语义。

一句话总结

这不是两个孤立模块,而是一条“先控制处理范围,再把相关散文转换为可查询表”的工作流:doc_relevance_agent 决定哪些文档值得抽取,doc_prepare + fanout_struct 决定如何抽取、校验、合并和落库,solver 最终消费 SQLite;全过程可追溯,但事实正确性仍需对关键结果回查原文。

Last updated on