Enterprise PDF RAG · 技术路线汇报

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

财务 PDF 里的文字、数字和图表本来就是矢量。我们不把页面拍成图片再去认,而是把每一页原样转成 SVG,让每个数字既是"看得见的字形",也是"读得出的字符串",并且始终带着页面坐标。进入索引的是每个成员的 index text——已验证字段的确定性文本投影,最上面再加一行本页的上下文头;回答时回到同一份快照取出类型化 IR 与 SVG 证据逐字段核验,核不上就拒答。

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

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

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

读这张图只需要记住两件事。第一,主干上的模型步骤只有三个:切版面、抽页级元数据、算向量;文字对象的转录和资格都是确定性的,可以逐字节重放,页级元数据的每个字段也必须在本页正文里找到逐字出处。第二,图表支线上模型产出的 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。输出 资格回执与 qualified_ir / qualified_description。五类对象各有各的证明,全部无模型: 文字按 literal-source-transcription-v1 逐 span 复核; 表格要求每条行列边界与每条单元格边都真有线——只把厚度不超过 3.0 pt 的轴对齐实线(虚线、曲线、斜线一律不算)收为 ruling 观测,再以 0.5 pt 容差逐条比对,合并单元格反过来由"跨过的内部边界上没有线"证明; 图表由几何校验与字形油漆证明(source-paint proof)决定; 流程图要求每个节点框对上一个真实矢量形状、每个标签逐字等于它引用的 span,每条边要有连线加一个填充箭头,箭尖落在目标节点里; 公式把 span 切成有序、不重叠、拼起来等于原文的 token,上下标由 PDF 的 Ts 算子证明。
"source-qualified" 的含义就在这一步:能进入检索的内容,必须由独立于模型分支的校验器从固定来源重新构建并逐字段比对。模型返回一个 VERIFIED 标记不授予任何资格;反过来,VERIFIED 与"存在证据"现在是严格等价的——表格网格没有证据就不可能是 VERIFIED,单元格也就不会印出行、列与表头,模型自然引不到。证不出来的对象不是猜一个,而是整体退回,降级成"只能逐字引用、不声称结构"或干脆不入库。
page_metadata
LLM · 每页一次
输入 整页的文字 span。输出 该页的标题、所属章节、页面类型、语言、涉及期间与地区,每个字段都必须点名它引用的 span,且折空白后逐字出现在其中;对不上的字段被剔除并记入诊断,不做纠正。文档级再零模型地折叠出一个可显示标题与报告期间,期间统一成 1H2026 / FY2024 这样的写法。
这是入库侧唯一为"找得到"服务的模型步骤,也是全自动的关键:没有人需要填标题、分章节或标年份。它的产物有两个用途——检索前的年份/地区过滤条件,以及 index text 最上面那行上下文头。它只抽取、不概括。
embedding
embedding 模型
输入 该成员的 index text,且只有文本。文字、列表、表格成员的 index text 就是它逐字转录的 description;图表、流程图、公式则换成已验证字段的确定性投影——图表投影出标题、期间、语法与每个点的分段 系列 值单位,流程图投影出阅读序的节点标签加每条已证边的 A -> B,公式投影出可读式、线性式与每个 token。最上面再加一行 文档标题 | 页标题 | 章节。输出 一个向量,成为快照成员。SVG、IR 对象与资格回执一律不进向量。
原来只嵌 description,而一张图的描述往往只有标题一句话(Distribution Mix),问分段、问数值就无从匹配。改成投影后,进索引的仍然全是已经通过资格复核的字段,只是排成一串可检索的文本——它不引入任何新信息。模型写的自然语言描述本身仍受约束,以 { [ < ChartIR( 这类结构化序列化开头会被直接拒绝。 命中 index text 只意味着"值得看一眼",回答时数字仍要从 IR 与 SVG 里重新取一遍。
qualify → index → publish
确定性
输入 已入库的 draft。输出 资格清单 → 新的不可变检索快照(snapshot_id 为 scope 与成员的内容哈希)→ 原子切换 current-processing 指针(可选再切 current-manifest)。三步幂等,任何一步失败不动指针。
快照绑定描述、资格策略、模型指纹、维度与成员 lineage。同一来源换了描述或资格,就是一个新快照;回答永远 pin 一个快照,绝不悄悄读 latest。

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

03 · 视觉对象支线

SVG → 类型化 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_ir 里确定性地投影出一串 index text 送进 embedding——标题、期间、语法,加上每个点的分段、系列与显式值;一个点都没有显式值的图表则退回用它的 qualified_description,免得一张只有标签的图靠它引不出来的词爬上排名。qualified_ir、SVG、资格回执作为同一成员的证据随快照保存。回答时命中 index text → 解析同一快照的 IR 与 SVG → 逐字段核验后才形成上下文。

流程图与公式。 流程图沿用同一条支线:模型从裁剪 SVG 读出节点与边,再由无模型的几何证明逐个复核——节点框必须对上一个真实矢量形状,标签必须逐字等于它引用的 span,边必须有连线加一个填充箭头指向目标;对象框里只要有一个 span 没被任何节点或边引用,整个对象就退回,这是防止模型悄悄漏掉一个节点的确定性守卫。只有节点、没有一条证得出的边,也允许入库——代价是上下文里一条边都不印,模型因此无法推断先后顺序。公式更进一步,连 IR 都不靠模型:token 直接从 span 观测出来,必须有序、不重叠、拼起来等于原文;上下标由 PDF 的 Ts 算子逐字证明,证不到就只按字号与基线推断并明确标成推断,可读式也跟着改口径——写成"x 上标 2"而不是"x 的 2 次方"。

// 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 → IR 字段 → SVG 元素 → 页面 / 区域 / 变换矩阵,字段级引用;网格已证明的表格另给出单元格的行、列与表头。 解析、裁剪、渲染、校验全部确定性、内容寻址;模型输出带 producer 指纹,可重放核对。

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

05 · 检索、上下文与拒答

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

问题 文本 query embed query index text 向量 · cosine BM25 词法检索 index text · 倒排索引 RRF 融合 k = 60 · 每通道 50 构建上下文 chart IR + SVG 证据 + 资格回执 typed ChartQA 逐字段核验 · Decimal 精确算 answered / abstained 带字段级引用 或 带拒答原因 member_id context claims 两条通道打分的是同一串 index text;SVG、IR 对象与资格回执不进任何索引,只在构建上下文时从同一快照按成员取出
回答链路(typed ChartQA 端点)。检索是双通道:向量通道与 BM25 词法通道各自在同一批 index text 上出一份排序,RRF 融合后得到成员;之后的上下文构建、核验、计算全部无模型,且解析时会重新校验存储的证据。每次查询只 pin 一个快照。自然语言聊天共用前半段的检索,但在构建上下文之后多一次模型合成,再走同样的逐字段核验——见 06。

检索是双通道的,索引里只有文本。 问题同时走两条路:一条做 query embedding,在 pinned 快照的向量上做 cosine 检索;一条做 BM25 词法检索,倒排索引建在同一批文本上。两路各取 50 个候选,出一份排序,用 RRF 融合(k = 60),默认交出前 10 名。两条通道打分的对象完全相同,都是每个成员那一串 index text;SVG、IR 对象与资格回执不进任何索引。文本通道保证问"费用率"能召回写着 expense ratio 的那张图,向量通道保证换个说法也能召回。

上下文用类型化 IR 构建。 融合后的命中只是成员 ID。构建上下文时回到同一快照,按成员取出 qualified_ir、SVG 证据与资格回执:文字成员给原文转录,表格成员给单元格(网格已证明的还带行、列与表头),图表成员给结构化的 chart IR,流程图给节点与已证的边,公式给 token。检索靠文本找到"哪一块值得看",回答靠 IR 知道"里面到底写了什么"。

引用链是字段级的。 最终引用永远是 claim 或计算输入 → chart IR 字段 → SVG 元素 → PDF 来源版本 / 页 / 区域。每条引用(FieldCitation)带字段路径、chart IR 与 SVG 的资产 ID、SVG 摘要、资格回执 ID,以及精确的元素出现位置。描述被检索命中的分数本身不授予任何证据资格。

计算是精确的,而且只在一个入口。 donut 上的百分点差用 Decimal 精确相减,银行家舍入,出现不精确即抛错,并附计算回执(规则、精度、输入、输出与来源锚点)。柱状图的显示值查询只回读页面上写出的值,不做任何跨期计算。这条能力目前只开在结构化的 typed ChartQA 端点上:自然语言聊天那一侧,模型被明确禁止做任何加减、换算、取整与跨期合并,问"Agency 比 Partnerships 高多少个百分点"会直接拒答并标 needs_calculation。把精确计算接进聊天是后续的事,在那之前宁可拒答,也不让模型自己算。

拒答是正常的业务结果。 默认状态只有 answered 与 abstained,不用附近的数字补位。拒答走 HTTP 200 并带原因;证据损坏是 409;依赖缺失是 503。typed ChartQA 常见的拒答原因直接来自代码(聊天那一侧另有自己的一组,见 06 的第 6、7 步):

一个具体的例子:费用率柱状图上 1H24 与 1H26 有显式标注,1H25 没有。系统对 1H25 的回答是 VALUE_UNAVAILABLE,而不是按柱高读一个数;图上标注的 (130) bps 变化也不被当作可回答的事实,因为它不是我们校验过的显式数据点。全部拒答同样不算通过:评测要求在可回答样本上 100% 精确、100% 覆盖,同时 0 个反例逃逸。

06 · 检索与元数据

入库不需要人工,检索靠每页自己报出来的元数据收敛

一份 PDF 从字节到可检索快照,中间没有任何人工标注:没有人填标题、分章节、标年份,也没有人圈选图表区域。模型只出现在构建阶段,而且每一个被写进元数据的值都必须在本页正文里找到逐字出处,对不上的字段被剔除并记入诊断——它是抽取,不是概括。

页级元数据有两个用途:一是检索前的过滤条件,二是 index text 最上面那行上下文头。Distribution Mix 这种在多份报告里重名的图表,因此能靠"哪份文件、哪一章"区分开;也正因为有了期间过滤加上下文头,中文问"2026 上半年分销渠道占比"能命中第 18 页——而不是因为我们有中文词面通道(见 07)。一次问答固定走下面七步:

步骤 · 做什么 · 为什么这样做
1 · 前置过滤
确定性 · 可撤销
做什么 从问题里解析出期间与地区,只保留页级元数据匹配的成员;封面与目录页永不进候选。地区只在这份文档自己的地区词表里逐字匹配,没有地名库,也没有硬编码的公司。
过滤只允许提精度,不允许把答案滤没。候选数少于席位时,整层收窄被直接丢掉,退回未过滤的排名并在响应里标记 filters_relaxed。开着过滤时向量通道仍在全语料上检索、之后才截断,所以过滤不会饿死它。
2 · 双通道 + RRF
embedding 模型
做什么 BM25 与向量各取 50 个候选,两路打分的是同一串 index text,用 RRF 融合(k = 60),交出前 10 名。
k 取 60 意味着单通道第一名的贡献封顶在 1/61,而两个通道都找得到的普通段落即使名次都在 20 也能超过它——融合天然偏向"两边都认"的证据,而不是某一路的偶然高分。
3 · rerank
可选 · 默认关
做什么 开启时由重排模型对融合结果重新排序。判官读的是每个候选解析出来的证据块,不是索引文本——它看到的与答题模型看到的完全一致。
代价是必须把全部融合候选(最多 100 个)逐个解析,每次解析都要重校验资产摘要并重证结构,实测单次问答从十几秒涨到五十多秒。所以它按请求显式开启,默认关闭。
4 · 视觉对象保底席位
确定性
做什么 前 10 名里若缺少可引用的图表、流程图或公式,各从第 11–20 名里提升一个进来,席位从末位向前让出,且绝不让出已经拿着视觉对象的那一席。
图与公式的可检索文本本来就短,容易被长段落挤掉。有了保底席位,一张已证明的流程图不必依赖提问者恰好说出某个节点的字眼——第 6 页那张三阶段路径图正是靠第 10 席进的上下文。只有标签、没有可引用内容的对象永远不占席。
5 · 一次模型合成
LLM · 每次问答恰好一次
做什么 把选中的上下文块原样交给模型,要求它产出结构化的回答与 claim 列表,每条 claim 点名一个成员和一条该块里印出来的路径。超出预算的块整块丢弃,从不截断。
模型被明确禁止做任何加减、换算、取整与跨期合并;标成不可用、空白的值不得引用也不得推断;流程图的边只有画出来的箭头算数,不得推断先后顺序。答不上来就弃答,不许猜。
6 · 逐字段核验
确定性
做什么 每条 claim 回到存储证据重新比对:引文必须是 span 的逐字子串,单元格必须等于存储的单元格文本,图表值必须等于资格化的 Decimal 或其原样显示串,流程图节点与边必须等于印出来的标签和 A -> B 对,公式必须等于那一行或那个 token。
核不上的 claim 被单独剔除进 rejected 列表并附原因;一条都没留下就整体弃答。表格的行列表头引用还要多一道:只有网格已证明的块才印出行、列与表头,声称的行列与 IR 不符、或表头不属于该单元格的已证表头,同样判为证据外。
7 · 散文门与判定
确定性
做什么 回答正文里的每一个数字都必须出现在某条已验证 claim 或它所引证据里,或者逐字出现在用户的问题里。行首与句首的列表序号(1. / (2) / 第 3 / Step 4)不算数字,条目正文里的金额与百分比照旧受门控。
这是最后一道闸:只要有一个数字逃逸,整个回答改判弃答,而不是删掉那句话交付剩下的。最终只有两种结果——带字段级引用的 answered,或带原因的 abstained。
07 · 尚未完成

已知的缺口,以及它们会怎么表现

下面这些都还没做完或没复验过。它们不动摇"答出来的必须核得上"这条底线——缺口的表现形式是覆盖不足或拒答,不是错答。