Enterprise PDF RAG · 技术路线汇报

矢量优先的财务 PDF 问答路线

财务 PDF 里的文字、数字和图表本来就是矢量。我们不把页面拍成图片再去认,而是把每一页原样转成 SVG,让每个数字既是"看得见的字形",也是"读得出的字符串",并且始终带着页面坐标。检索只嵌自然语言描述;回答时再回到同一份快照里的结构化图表 IR 与 SVG 证据逐字段核验,核不上就拒答。

PDF → SVG → 文本 → embedding SVG → chart IR → 回答上下文 parser: pdfspine 0.11.0(纯 Rust) 2026-09
原件里观察到了什么
页面 SVG、文字 span、对象裁剪,全部内容寻址、不可变。
系统如何解释它
文字类对象逐字转录;图表由模型从同一张 SVG 独立产出 chart IR 与描述,再由无模型的来源校验授予资格。
哪些证据有资格支持这次回答
每个数字回指 chart IR 字段 → SVG 元素 → 页面/区域;命中分数本身不算证据。
01 · 全景

一条主干,一条图表支线,两种颜色

主干 · 文字与版面 PDF 原件 sha256 内容寻址 pdfspine 解析 页面 SVG + 文字 span 版面切分 partition · LLM 对象裁剪 svg · source_text 逐字转录 ir · description embedding 只嵌 description 文本 检索快照 不可变 · 内容寻址 打开 整页 bbox span 文本 向量 Chart 对象 支线 · 图表 裁剪 SVG · 渲染 svg · model_render 模型解读 chart IR + 描述 ir · description · VLM 来源资格校验 qualification 几何 + 字形油漆证明 仅 description 文本 IR · 证据 · 资格回执 随成员保存,不进向量 校验不过 → 不入库 确定性步骤(无模型,可重放) 模型步骤(LLM / VLM / embedding) 原件 / 产物 不进向量,随快照成员保存
入库全景。主干把页面变成 SVG 与文字 span,切分成对象后逐字转录,只有自然语言描述进入 embedding;图表支线从同一张对象 SVG 出发,由 VLM 独立产出 chart IR 与描述,再由无模型的来源校验授予资格。模型只在三处出现,且每一处的输出都要过一道确定性校验才能落库。

读这张图只需要记住两件事。第一,主干上的模型步骤只有两个:切版面、算向量;文字对象的转录和资格都是确定性的,可以逐字节重放。第二,图表支线上模型产出的 chart IR 和描述互相不做输入,也不能自证正确,只有"来源资格校验"这一步能让它们进入检索快照。

02 · 主干拆解

PDF → SVG → 文本 → embedding,每一步在做什么

下表按处理对象模型中的 stage 名列出。存储层统一为内容寻址:每个产物按 sha256 落到 objects/sha256/<digest>,stage 结果以输入指纹为缓存键,读回时校验摘要与长度。

stage · 输入 → 输出 · 为什么这样做
canonical
确定性
输入 PDF 字节。输出 原件的 sha256 身份、页数与页框,写入不可变 manifest。
后面所有产物、快照和回答都绑定这一个摘要。换了文件就是新的身份,不会悄悄覆盖。
页面观测(pdfspine)
确定性 · 无 OCR
输入 每一页。输出 两份彼此独立的观测: (1) 整页 native SVG——page.get_svg_image(),路径、填充、描边、裁剪、变换矩阵、内嵌图片全部保留,文字以内嵌字体的字形轮廓输出,坐标就是页面 pt; (2) 文字 span——page.get_text("dict"),每个 span 带文本、bbox、字体、字号、方向,直接来自 PDF 内容流。
矢量页面上的数字本来就有精确坐标和精确字符串,没有理由先拍成像素再去认。pdfspine 是唯一的 parser,纯 Rust、无 C 依赖、无网络,结果可逐字节重放;OCR 在 SDK 里只是显式调用的独立能力,不作为主链路的回退。SVG 帧必须等于物理页帧,旋转页、非默认裁剪直接报错而不是猜。
partition
LLM
输入 页面 SVG 与 span。输出 对象区域列表,每个对象是 Text / List / Table / Chart / Diagram / Image / Formula / Group 之一,附 bbox;随后由确定性的 normalized_partition 归一。
版面理解是模型擅长的事,而且这一步的输出只有"哪里是什么",不产生任何数值——模型在这里犯错的上限是分区不准,而不是编出一个数字。
svg · source_text
确定性
输入 对象 bbox。输出 对象级 SVG 裁剪(只改 viewBox 并包一层 overflow="hidden",保留整棵 native 子树,不重绘);以及落在 bbox 内的 span 子集。
裁剪不重绘,所以对象 SVG 里的每个元素仍能对回整页 SVG 和页面坐标;span 与字形是两份独立观测,谁也不替谁背书,后面的资格校验正是要把它们对上。
ir · description
确定性(文字类对象)
输入 对象的 span。输出 类型化 IR(TextIR / ListIR / GroupIR,每个片段带 source_span_id)和一段逐字转录的 description,producer 记为 exact-source-transcription-v1。表格由 pdfspine find_tables 依据矢量 ruling 线重建网格得到 TableIR;图表走支线(见 03)。
凡是原文能逐字给出的,就不让模型改写。文字对象的"描述"就是原文本身,检索命中的是原文语义而不是转述。
qualification
确定性
输入 该对象的 ir、description、svg。输出 资格回执:文字类按 literal-source-transcription-v1 逐 span 复核;图表类由几何校验与字形油漆证明(source-paint proof)决定,产出 qualified_ir / qualified_description
"source-qualified" 的含义就在这一步:能进入检索的内容,必须由独立于模型分支的校验器从固定来源重新构建并逐字段比对。模型返回一个 VERIFIED 标记不授予任何资格。
embedding
embedding 模型
输入 通过资格复核的 description 文本,且只有文本。输出 一个向量,成为快照成员。SVG、IR、图表显示值一律不进 embedding;描述若以 { [ < ChartIR( 这类结构化序列化开头会被直接拒绝。
向量空间里只放自然语言,语义检索才干净;结构化数据不当"内容"而当"证据",留在快照里等回答时逐字段核验。这样一条描述被命中只意味着"值得看一眼",数字仍要从 IR 与 SVG 里取。
qualify → index → publish
确定性
输入 已入库的 draft。输出 资格清单 → 新的不可变检索快照(snapshot_id 为 scope 与成员的内容哈希)→ 原子切换 current-processing 指针(可选再切 current-manifest)。三步幂等,任何一步失败不动指针。
快照绑定描述、资格策略、模型指纹、维度与成员 lineage。同一来源换了描述或资格,就是一个新快照;回答永远 pin 一个快照,绝不悄悄读 latest。

另有一份逐页导出的轻量 HTML 审阅页(内嵌该页 SVG、原文 span 与 bbox),供人工核对来源。它是给人看的投影,不是处理阶段:进入模型和检索的始终是 SVG 与 span。

03 · 图表支线

SVG → chart IR → 回答上下文

图表是财务 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
series / category / unit / value
每个数据点四个字段分别带证据。donut 里指标(VONB)是 series,分段(Agency)是 category,单位是 %。
NumericObservation.kind
EXPLICIT 表示值来自页面上显式写出的文本;由位置、柱高、面积或视觉模型估出的值属于 estimated,不用于精确数值回答;没有值就是 UNAVAILABLE,value 为空。
ChartMark
定位到的图形假设:类型(arc、bar…)、bbox、颜色、它对应哪些点。它记录几何与图例映射,但自身不是被验证的关系。
Evidence → SvgElement
所有字段的证据最终落到 SVG 元素,元素锚点带页码、bbox 与变换矩阵。这就是"可追溯到页面坐标"的实现方式。
回答时的显示值
IR 里不存"72%"这样的显示串。回答时从 SVG 的原始 span 回读整串,要求数字与单位来自同一 span、正则精确匹配、Decimal 相等,否则报错。
04 · 为什么不走常见路线

OCR、整页截图喂 VLM、纯文本抽取各自丢了什么

路线 文字与数字精度 图表里的数值 可追溯性 可重放 / 可审计
整页光栅 + OCR 把本来就是矢量的文字先变成像素再识别,引入本可避免的识别误差;小字号、上下标、脚注最先出错。 图形与文字被拍平在同一张位图里,图例、颜色、柱与标签的关联要靠二次猜测。 只能给到像素框,对不回 PDF 的元素与内容流。 识别结果随引擎与版本漂移。
整页截图直接喂 VLM 数字是模型"读"出来的,读错没有独立信号;同一张图两次可能读出不同值。 值常从柱高、扇区大小估出,与显式标注无法区分;无值的柱子容易被补一个看上去合理的数。 回答只能引用"第几页",无法指到具体元素与坐标;命中的是模型的转述。 不可重放,事后无法验证模型当时看到了什么、依据了什么。
纯文本抽取 / 过早 Markdown 化 文字本身准确,但整页拉成线性文本后区域关系被破坏;表格丢失空白、合并单元格拓扑与完整多级表头。 图表里的数字散落成孤立字符串,不知道属于哪个系列、哪个分段、哪个期间。 位置信息在拉平时丢掉,很难回指区域。 可重放,但丢掉的结构无法找回。
矢量优先(本路线) 文字直接来自内容流,逐字节精确;字形与 span 两份独立观测互相印证。 chart IR 每个点带 series / category / unit / value,值只接受显式文本出现;估计值另标 kind,不用于精确回答;无值即 UNAVAILABLE claim → chart IR 字段 → SVG 元素 → 页面 / 区域 / 变换矩阵,字段级引用。 解析、裁剪、渲染、校验全部确定性、内容寻址;模型输出带 producer 指纹,可重放核对。

我们不是拒绝模型,而是把模型放在它可靠的位置:理解版面、把图表翻译成结构、写一段可核对的描述。凡是页面上已经精确存在的东西——文字、数字、坐标、颜色——一律不经过模型重新生成。

05 · 检索、上下文与拒答

用文本找到它,用 chart IR 读懂它,核不上就不答

问题 文本 query embed query description 向量 · cosine BM25 词法检索 description 文本 · 倒排索引 RRF 融合 k = 60 构建上下文 chart IR + SVG 证据 + 资格回执 typed ChartQA 逐字段核验 · Decimal 精确算 answered / abstained 带字段级引用 或 带拒答原因 member_id context claims 两条通道检索的都只是 SVG → 文本那一路产出的 description;chart IR、SVG 元素、显示值不进任何索引,只在构建上下文时从同一快照按成员取出
回答链路。检索是双通道:向量通道与 BM25 词法通道各自在同一批 description 文本上出一份排序,RRF 融合后得到成员;之后的上下文构建、核验、计算全部无模型,且解析时会重新校验存储的证据。每次查询只 pin 一个快照。

检索是双通道的,索引里只有文本。 问题同时走两条路:一条做 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 个反例逃逸。