财务 PDF 里的文字、数字和图表本来就是矢量。我们不把页面拍成图片再去认,而是把每一页原样转成 SVG,让每个数字既是"看得见的字形",也是"读得出的字符串",并且始终带着页面坐标。检索只嵌自然语言描述;回答时再回到同一份快照里的结构化图表 IR 与 SVG 证据逐字段核验,核不上就拒答。
读这张图只需要记住两件事。第一,主干上的模型步骤只有两个:切版面、算向量;文字对象的转录和资格都是确定性的,可以逐字节重放。第二,图表支线上模型产出的 chart IR 和描述互相不做输入,也不能自证正确,只有"来源资格校验"这一步能让它们进入检索快照。
下表按处理对象模型中的 stage 名列出。存储层统一为内容寻址:每个产物按 sha256 落到 objects/sha256/<digest>,stage 结果以输入指纹为缓存键,读回时校验摘要与长度。
page.get_svg_image(),路径、填充、描边、裁剪、变换矩阵、内嵌图片全部保留,文字以内嵌字体的字形轮廓输出,坐标就是页面 pt;
(2) 文字 span——page.get_text("dict"),每个 span 带文本、bbox、字体、字号、方向,直接来自 PDF 内容流。
normalized_partition 归一。viewBox 并包一层 overflow="hidden",保留整棵 native 子树,不重绘);以及落在 bbox 内的 span 子集。source_span_id)和一段逐字转录的 description,producer 记为 exact-source-transcription-v1。表格由 pdfspine find_tables 依据矢量 ruling 线重建网格得到 TableIR;图表走支线(见 03)。
literal-source-transcription-v1 逐 span 复核;图表类由几何校验与字形油漆证明(source-paint proof)决定,产出 qualified_ir / qualified_description。snapshot_id 为 scope 与成员的内容哈希)→ 原子切换 current-processing 指针(可选再切 current-manifest)。三步幂等,任何一步失败不动指针。另有一份逐页导出的轻量 HTML 审阅页(内嵌该页 SVG、原文 span 与 bbox),供人工核对来源。它是给人看的投影,不是处理阶段:进入模型和检索的始终是 SVG 与 span。
图表是财务 PDF 里最容易被"看错"的东西,也是我们路线与常见做法差别最大的地方。支线从对象级 SVG 出发,分四步:
渲染。 用 resvg 把裁剪 SVG 确定性地渲染为 PNG(model_render),同时生成一份绑定摘要(model_view)。渲染时禁止未解析字体的 <text> 元素——模型看到的每个字形都必须来自内嵌字体。
两路独立推断。 同一张 SVG 渲染图连同结构化的 span 观测,分别交给两次 VLM 调用:一路提取 chart IR(producer model-chart-v1),一路直接写自然语言描述(producer model-description-v1)。描述这一路的提示词明确写着"你没有结构化提取结果作为输入",每个显式数值一句话,形如 During {period}, {series} for {category}: {value} {unit}.,必须引用精确的元素 ID,不得写趋势、估计或比较。两路互不为输入,所以它们的一致是证据,而不是复读。
来源资格校验。 无模型。按图表语法做几何校验(donut 的扇区、bar 的柱与标签关联),并做字形油漆证明:通过 pdfspine 的设备接口重放同一页,证明 IR 引用的那块 paint 确实是文字算子用内嵌字体画出来的,Unicode、字形 ID、渲染矩阵、填充与 alpha 都对得上。数值只能来自显式文本出现,绝不从柱高、颜色、坐标轴插值或箭头几何推出来。
进入快照。 通过校验的 qualified_description 文本进 embedding;qualified_ir、SVG、资格回执作为同一成员的证据随快照保存。回答时命中描述 → 解析同一快照的 IR 与 SVG → 逐字段核验后才形成上下文。
// chart IR 字段结构(figures/models.py) // 示例语义:第 18 页 Distribution Mix donut ChartIR binding: SvgBinding → svg_digest, element 索引 grammar: "donut" title: TextField("Distribution Mix", evidence) period: TextField("1H26", evidence) axes: () // donut 无坐标轴 points: - ChartPoint series: TextField("VONB", evidence) category: TextField("Agency", evidence) unit: TextField("%", evidence) value: NumericObservation(Decimal("72"), EXPLICIT, evidence) - ChartPoint series: "VONB" category: "Partnerships" unit: "%" value: NumericObservation(Decimal("28"), EXPLICIT, evidence) marks: - ChartMark(kind="arc", bbox=(x0, y0, x1, y1), color="#rrggbb", point_ids=("Agency",), evidence) producer: "model-chart-v1:<model指纹>:<请求指纹>:<输出摘要>" verification: VERIFIED | PENDING execution_mode: PRODUCTION // 每个 evidence 都是一组 SVG 元素引用 Evidence(element_ids=("…",), verification, confidence) SvgElement.anchor → page_index, bbox, transform
| 路线 | 文字与数字精度 | 图表里的数值 | 可追溯性 | 可重放 / 可审计 |
|---|---|---|---|---|
| 整页光栅 + OCR | 把本来就是矢量的文字先变成像素再识别,引入本可避免的识别误差;小字号、上下标、脚注最先出错。 | 图形与文字被拍平在同一张位图里,图例、颜色、柱与标签的关联要靠二次猜测。 | 只能给到像素框,对不回 PDF 的元素与内容流。 | 识别结果随引擎与版本漂移。 |
| 整页截图直接喂 VLM | 数字是模型"读"出来的,读错没有独立信号;同一张图两次可能读出不同值。 | 值常从柱高、扇区大小估出,与显式标注无法区分;无值的柱子容易被补一个看上去合理的数。 | 回答只能引用"第几页",无法指到具体元素与坐标;命中的是模型的转述。 | 不可重放,事后无法验证模型当时看到了什么、依据了什么。 |
| 纯文本抽取 / 过早 Markdown 化 | 文字本身准确,但整页拉成线性文本后区域关系被破坏;表格丢失空白、合并单元格拓扑与完整多级表头。 | 图表里的数字散落成孤立字符串,不知道属于哪个系列、哪个分段、哪个期间。 | 位置信息在拉平时丢掉,很难回指区域。 | 可重放,但丢掉的结构无法找回。 |
| 矢量优先(本路线) | 文字直接来自内容流,逐字节精确;字形与 span 两份独立观测互相印证。 | chart IR 每个点带 series / category / unit / value,值只接受显式文本出现;估计值另标 kind,不用于精确回答;无值即 UNAVAILABLE。 | claim → chart IR 字段 → SVG 元素 → 页面 / 区域 / 变换矩阵,字段级引用。 | 解析、裁剪、渲染、校验全部确定性、内容寻址;模型输出带 producer 指纹,可重放核对。 |
我们不是拒绝模型,而是把模型放在它可靠的位置:理解版面、把图表翻译成结构、写一段可核对的描述。凡是页面上已经精确存在的东西——文字、数字、坐标、颜色——一律不经过模型重新生成。
检索是双通道的,索引里只有文本。 问题同时走两条路:一条做 query embedding,在 pinned 快照的 description 向量上做 cosine 检索;一条做 BM25 词法检索,倒排索引建在同一批 description 文本上。两路各出一份排序,用 RRF 融合(k = 60)。两条通道检索的对象完全相同,都是 SVG → 文本那一路产出、并通过资格复核的 description;chart IR、SVG 元素、图表显示值不进任何索引。文本通道保证问"费用率"能召回写着 expense ratio 的那张图,向量通道保证换个说法也能召回。
上下文用 chart IR 构建。 融合后的命中只是成员 ID。构建上下文时回到同一快照,按成员取出 qualified_ir、SVG 证据与资格回执:文字成员给原文转录,图表成员给结构化的 chart IR。检索靠文本找到"哪张图值得看",回答靠 IR 知道"图里到底写了什么"。
引用链是字段级的。 最终引用永远是 claim 或计算输入 → chart IR 字段 → SVG 元素 → PDF 来源版本 / 页 / 区域。每条引用(FieldCitation)带字段路径、chart IR 与 SVG 的资产 ID、SVG 摘要、资格回执 ID,以及精确的元素出现位置。描述被检索命中的分数本身不授予任何证据资格。
计算是精确的。 donut 上的百分点差用 Decimal 精确相减,银行家舍入,出现不精确即抛错,并附计算回执(规则、精度、输入、输出与来源锚点)。柱状图的显示值查询只回读页面上写出的值,不做任何跨期计算。
拒答是正常的业务结果。 默认状态只有 answered 与 abstained,不用附近的数字补位。拒答走 HTTP 200 并带原因;证据损坏是 409;依赖缺失是 503。常见的拒答原因直接来自代码:
一个具体的例子:费用率柱状图上 1H24 与 1H26 有显式标注,1H25 没有。系统对 1H25 的回答是 VALUE_UNAVAILABLE,而不是按柱高读一个数;图上标注的 (130) bps 变化也不被当作可回答的事实,因为它不是我们校验过的显式数据点。全部拒答同样不算通过:评测要求在可回答样本上 100% 精确、100% 覆盖,同时 0 个反例逃逸。