财报 PDF 证据页检索
这个 Skill 解决什么
财报问答的失败,绝大多数不是「答错了」,而是证据页没被召回。一旦原始报表页不在候选集里,下游再强的模型也只能编。
本 Skill 把「问题 → 证据页」这一步做扎实:给定一份或多份财报 PDF 和一批问题,输出每道题最可能承载答案的页码,并给出为什么选它。
适用:10-K / 10-Q / 年报 / 业绩公告的分页检索;证据页召回评估;PDF 检索管道排障。
不适用:需要精确数值计算的估值建模;非财报类 PDF(合同、论文)—— 报表结构先验不成立。
证据页的定义
一道题的证据页,是答案数字或事实直接印在版面上的那一页。
三个容易判错的地方:
- 答案页 ≠ 包含关键词最多的页。MD&A 里会反复提到「毛利率下降 3.2 个百分点」,但真正的证据页是利润表那一页。
- 比率题通常横跨 2–3 张主报表。毛利率要利润表两行;资产周转率要利润表收入 + 资产负债表总资产;资本密集度要资产负债表 PP&E + 总资产。只召回一张表会漏。
- 脚注页也算证据页。债务到期结构、分部拆分经常只出现在附注里。
工作流
第 1 步:确认解析质量,再谈检索
在调排序之前,先证伪「解析层坏了」。用一段原文做探针:
- 取 20 个问题,每个问题列出 3–5 个必须出现的证据词项(数字原样、科目名原样)
- 统计这些词项在解析后文本里的命中比例
- 阈值:证据词项召回 ≥ 0.95、整页文本 F1 ≥ 0.95
不达标先修解析(表格错位、双栏串行、页眉页脚污染),不要往下调排序。解析错了,排序再好也无意义。本 Skill 附带 scripts/audit_page_budget.py 可同时检查解析后的页长分布。
第 2 步:确定入库的硬约束(最容易整题归零的一步)
分页检索系统普遍有单条记录长度上限,且批量入库是原子的。真实案例:上限 8192 UTF-8 字节,一份 10-K 里 4 页超标,导致整篇文档一页都没进库,表现为「召回率 0」,而日志里根本看不到逐页失败信息。
必须做的三件事:
- 按字节而不是字符裁剪。中文、特殊符号、连字符都会让字节数远超字符数。用
len(text.encode("utf-8"))判断。 - 扣除前置前缀的预算。若入库时拼了
document_id + "\n" + text,可用预算 =上限 - len(document_id.encode()) - 1。 - 裁到词边界,不要硬切。在
budget附近回退到最近的空格/换行,避免产生半截单词污染检索。
参考实现见 references/retrieval-recipes.md 的 fit_entry_text()。
验证方式:把语料全部过一遍裁剪函数,统计被裁剪的页数与裁剪后词项留存比例(应 ≥ 0.95)。不要靠"应该没问题"。
第 3 步:设计排序信号
按「先词法、后语义」的顺序加权,实测有效的权重:
| 信号 | 权重 | 说明 |
|---|---|---|
| 精确短语命中 phrase_hits | 0.35 | 由问题自动生成的候选短语,见第 4 步。排序的主导信号 |
| 数字/金额共现 | 0.20 | 问题含数字或百分比时,同页出现相近数值加分 |
| 科目名词项覆盖 | 0.20 | 从问题抽出的会计科目,按 IDF 加权覆盖 |
| 报表标题邻近 | 0.15 | 页内出现报表标题且与问题类型匹配 |
| 语义/稠密检索融合 | 0.05 | 只作补充,不要给它高权重 |
| 文档作用域命中 | 0.05 | 问题点名了某公司/ticker,同文档页优先 |
关键教训:新增的查询扩写如果只接进稠密检索通道(权重 0.05),对最终排序几乎无影响。要么接进 phrase_hits,要么别加。
第 4 步:从问题生成短语(决定成败的一步)
按问题里的指标线索反查应该出现的短语:
- 问题含
ratio / margin / turnover / capital intensive / EBITDA / capex / liquidity / return on / leverage / intensity / per share / free cash flow / working capital之一时,追加全部报表标题短语(见下表) - 问题含主题词(债务、存货、诉讼、并购、分部)时,追加该主题的专属短语:
- 债务 →
long-term debt、debt maturities、short-term borrowings - 存货 →
inventories、cost of goods sold、cost of sales - 诉讼 →
legal proceedings、contingencies、litigation - 并购 →
business combinations、acquisitions - 分部 →
reportable segments、segment information
- 债务 →
报表标题各家叫法不同,必须全收:
statements of operations / statement of operations
statements of income / statement of income
statements of earnings / statement of earnings
balance sheet
statement of cash flows
漏掉 statements of earnings 这类变体,会让一批公司的比率题系统性失分。
第 5 步:识别问题指向的文档
多文档场景下,先判断问题是否点名了某家公司,命中则把候选集收敛到该文档。
ticker 识别的四个边界情况(全部踩过):
| 情况 | 例子 | 处理 |
|---|---|---|
| ticker 是公司名的子序列 | AMEX ⊂ americanexpress | 用子序列匹配而非前缀匹配 |
| 混合大小写 | JnJ | 缩略词正则放宽为「2–6 字符且含 ≥2 个大写」,同时要非全大写才能捞到 |
| 数字开头 | 3M | 单独取文档名 _ 分隔的第一段做核,不要求首字符是字母 |
| 文档名是下划线分隔 | VERIZON_2022_10K | 取第一段做核,并对全文做归一化(去空格、转小写) |
只有唯一命中才锁定。命中 2 个及以上时返回空集合,退化为全局检索 —— 强行锁定比不锁定更糟。
三个反直觉的坑
坑 1:FTS 不保证短语语义。 很多检索后端的 full-text search 会把带引号的短语拆成词袋 AND,停用词(and、of、the)直接丢弃。于是 "property plant and equipment" 和 "property and equipment" 命中集合完全相同。验证方法:拿两个只有停用词差异的短语各查一次,比对结果集;相同就说明是词袋语义,此时短语长度不构成区分度,需要靠权重和其他信号补。
坑 2:不要按文档轮流分配候选名额。 为了"公平"给每份文档分同样多的 top-K 名额,会让好页被本文件的差页挤掉。实测总召回从 0.767 崩到 0.500。正确做法:全局排序 + 文档作用域过滤。
坑 3:本地评估集和线上判题集可能不是同一批文件。 如果本地 N 页、线上 M 页且 N ≠ M,说明文档集假设错了。用页数、题数做反解,枚举可能的组合,然后让策略在所有候选组合上都达标,而不是只调好一套。
自检清单
提交前逐条过:
- [ ] 解析质量门槛达标(证据词项召回 ≥ 0.95)
- [ ] 全语料过一遍字节裁剪,无超限,词项留存 ≥ 0.95
- [ ] 入库是逐页独立提交,还是批量原子提交?(原子提交必须保证零超限)
- [ ] 报表标题变体是否全收(含
statements of earnings) - [ ] 比率题是否给了跨报表的短语(利润表 + 资产负债表)
- [ ] ticker 四类边界是否覆盖(子序列 / 混合大小写 / 数字开头 / 下划线分隔)
- [ ] 文档锁定是否只在唯一命中时生效
- [ ] 评估是否覆盖所有候选文档集组合,而非单一组合
- [ ] 每组召回是否独立达标(不是看平均值)——多数评测按组设门槛,一组挂掉整题 0 分
附带资源
references/retrieval-recipes.md—— 可直接抄的实现片段:字节裁剪、短语生成、ticker 匹配、报表标题表scripts/audit_page_budget.py—— 扫描语料页长分布,找出会触发的超限页并给出裁剪后留存率
微信扫一扫