PDF表格RAG双轨制:DOC索引+FACTS存储实战方案
1. 项目概述:当RAG遇上“表格地狱”,为什么90%的PDF问答系统在真实业务中当场崩溃?
我刚结束一个客户试点项目,客户给我的不是几份普通合同或白皮书,而是整整273个数字PDF文件——全是季度财报、海关统计简报、大宗商品月度分析。它们长得都差不多:前两页是带多级标题的叙述性文字,中间突然插进一张横跨三栏的宽表,表头分两行,第一行写“单位:USD/tonne”,第二行才是“加拿大”“美国”“欧盟”;表格下方还藏着三行小字号脚注,其中一行写着“*出口价格已扣除运输补贴”。用户提的问题也特别“实在”:
- “Q4营收环比增长了多少?请用一句话说明原因。”(语义类)
- “2025年4月加拿大冷冻薯条出口均价(美元/吨)是多少?”(数值类)
结果呢?我们第一版基于LangChain+Chroma的RAG系统上线三天就被打回原形。语义问题答得似是而非,比如把“Q4营收增长12%”错答成“Q3增长12%”;数值问题更离谱——同一个问题,三次提问返回三个不同数字:287、291、283。后来一查日志,发现系统把表格里“加拿大”列的第12行(对应Apr-2025)、第13行(May-2025)、第11行(Mar-2025)全当候选答案扔给了LLM,而LLM在没有上下文约束的情况下,随机挑了一个。
这根本不是模型能力问题,而是整个数据处理链路从根上就错了。传统RAG默认所有内容都是“文本”,但现实中的业务PDF根本不是这样:表格是结构化数据,脚注是元数据,多栏排版是视觉逻辑,合并单元格是语义陷阱。你用固定512字符窗口切表格,等于把一张Excel表按行高硬切成三段再拼回去——它还能算数吗?你把800行价格数据全塞进向量库,相当于让图书馆管理员靠闻纸张味道找某本书,而不是查ISBN号。
所以这篇笔记不讲“RAG是什么”“Embedding怎么选”,只讲一件事:如何让RAG在满是表格的PDF海洋里,既看得懂人话,又算得准数字。核心就一句话:把“找地方”和“找数字”彻底分开——DOC索引负责告诉你“答案可能在哪一页哪个表里”,FACTS存储负责给你“精确到小数点后两位、带单位、带来源页码”的唯一正确值。这个思路不是理论推演,而是我在客户现场连续踩了17次坑、重写了4版pipeline后,用真金白银验证出来的。如果你手头也有大量带表格的PDF要处理,别急着调API,先看看这些坑你是不是也正踩着。
2. 核心设计哲学:为什么必须拆成DOC索引 + FACTS存储双轨制?
2.1 传统RAG的“三重幻觉”陷阱
几乎所有失败案例,根源都在试图用同一套机制处理两类完全不同的需求。我把它总结为“三重幻觉”:
第一重幻觉:以为表格是文本
你用PyMuPDF把PDF转成纯文本,表格变成一堆用空格和制表符对齐的字符。结果“2025年4月”和“加拿大”被切到不同chunk里,embedding向量根本学不会“时间+地理+指标”这组强关联。实测显示,当表格列数>5时,单纯文本嵌入的检索准确率直接跌破35%——比随机猜好不了多少。
第二重幻觉:以为向量搜索能替代SQL
把整张价格表转成字符串塞进Chroma,然后问“加拿大4月价格”,系统会返回最相似的chunk,比如包含“加拿大”和“价格”字样的段落。但它无法区分这是“进口价格”还是“出口价格”,更无法校验“4月”是否在表头日期范围内。向量搜索的本质是语义近似,而数值查询的本质是精确匹配+范围过滤。
第三重幻觉:以为OCR精度决定一切
客户曾坚持要用最高精度OCR(哪怕单页耗时2分钟),结果发现真正拖慢系统的不是OCR,而是后续的“表格理解”环节。OCR再准,如果不能识别出“第2行是单位,第3行是国家,第4行开始才是数据”,生成的文本仍是垃圾。我们最终放弃追求100% OCR准确率,转而用Docling这类结构感知型工具,单页处理时间从120秒降到8秒,且表格结构还原准确率反升12%。
2.2 DOC索引:专攻“发现”——轻量、可解释、支持溯源
DOC索引的核心使命只有一个:在海量PDF中快速定位到最可能包含答案的页面和表格区域。它不存原始数据,只存“线索”。因此设计原则非常明确:
- 极简主义:每个DOC chunk必须<800 tokens。为什么?因为你要喂给embedding模型,而主流模型(text-embedding-3-large)在400-700 token区间效果最稳。超过这个长度,首尾信息会被严重压缩,标题和表格预览的权重反而下降。
- 结构优先:绝不使用
RecursiveCharacterTextSplitter暴力切分。必须先用标题层级(H1/H2/H3)做粗分,再在每个section内按句子切分。我见过太多案例,把“Q4营收增长12%”这句话切在“增长”和“12%”中间,导致embedding向量丢失关键数值。 - 预览即索引:表格不存全文,只存3样东西:① 表标题+合并后的表头(如“加拿大出口均价(USD/tonne)”);② 5行代表性数据(首、中、末+两行异常值);③ 元数据(page=12, table_id=TBL-047, has_facts=true)。这个预览的Markdown格式是精心设计的:
这样做的好处是:向量搜索时,“加拿大”“4月”“USD/tonne”这些关键词天然聚在一起,检索命中率提升3倍以上。| 国家 | 2025年4月 | 2025年5月 | 变动 | |---|---|---|---| | 加拿大 | 287.5 | 291.2 | +1.3% | | 美国 | 265.8 | 268.4 | +1.0% | | ... | ... | ... | ... |
2.3 FACTS存储:专攻“精度”——结构化、可计算、可审计
FACTS存储解决的是“找到之后怎么办”。它的设计完全抛弃文本思维,转向数据库范式:
一行一事实:每张表格必须熔解(melt)成标准长表格式。原始宽表:
国家 | 2025年4月 | 2025年5月 加拿大 | 287.5 | 291.2→ 转为FACTS行:
{"series_name":"出口均价","geo":"CA","period_ym":202504,"value":287.5,"unit_std":"/TONNE","currency":"USD","doc_id":"Q4_Report.pdf","table_id":"TBL-047","page":12}这样设计后,“查加拿大4月价格”就变成一条确定性SQL:
SELECT value FROM facts WHERE geo='CA' AND period_ym=202504 AND series_name='出口均价'。没有歧义,没有概率,只有结果。湖仓一体化:FACTS数据必须存为Parquet格式(非CSV!),并注册到Hive Metastore或AWS Glue Catalog。为什么?因为Parquet的列式存储+谓词下推,能让Trino引擎在扫描10亿行数据时,只读取geo、period_ym、series_name三列的索引,响应时间稳定在200ms内。而CSV方案在同样数据量下,平均耗时4.2秒。
元数据即生命线:每个FACTS行强制携带
doc_id、table_id、page。这不是为了“好看”,而是为了溯源。当用户质疑“为什么是287.5不是288.0”,系统能立刻返回:“该值来自Q4_Report.pdf第12页表格TBL-047,原文为‘287.5 USD/tonne’”。这种可审计性,在金融、审计等场景是刚需。
2.4 双轨协同:路由逻辑决定系统成败
分离只是第一步,关键是如何让AI“知道该用哪个工具”。我们的路由规则极其简单,但经过200+次AB测试验证:
问题分类器:用轻量级BERT模型(仅12MB)判断问题类型。训练数据就两句话:
- 语义类:“Q4营收变化原因?”“总结主要风险因素”
- 数值类:“2025年4月加拿大价格?”“平均值是多少?” 准确率98.7%,误判成本远低于复杂LLM分类。
混合检索策略:
- 数值类问题:直连FACTS。先解析问题中的geo(加拿大→CA)、period(4月→202504)、series(价格→export_price),生成SQL过滤条件,直接查Parquet。
- 语义类问题:先DOC后FACTS。例如问“Q4变化原因”,先在DOC索引中检索“Q4”“变化”“原因”,拿到Top3预览(含表格标题和片段),再根据预览中的
table_id去FACTS查对应表格的数值趋势,最后让LLM综合文本描述+精确数据生成回答。
提示:不要试图让LLM自己决定走哪条路。我们在Agent层硬编码了路由逻辑,因为LLM在压力测试中,对“数值类问题”的识别准确率只有73%。把确定性任务交给规则,把创造性任务留给模型,这才是工程思维。
3. 实操全流程:从PDF到可查询FACTS的8个关键步骤
3.1 步骤0:发现冲刺(Discovery Sprint)——省下80%返工时间
很多团队一上来就写代码,结果两周后发现:90%的PDF表格头有合并单元格,30%的脚注修改了主表数值,20%的文档混用中英文。这就是没做发现冲刺的代价。我的标准流程是:
A. 文档采样铁律:不随机抽,按“三维度覆盖法”:
- 结构维度:至少1份纯文本报告、1份全表格统计简报、1份图文混排产品手册;
- 语言维度:客户实际使用的全部语种(我们遇到过中/英/西/葡四语混排);
- 质量维度:1份完美PDF(由InDesign导出)、1份Word转PDF(字体嵌入不全)、1份扫描件(虽本次不处理,但要记录)。
B. 黄金测试集构建:不是随便写10个问题。必须包含:
- 边界案例:如“2025年4月”在表头写成“Apr-2025”,问题却用“202504”;
- 歧义案例:如“价格”在文档中同时出现“FOB价格”“CIF价格”“离岸价”,问题只说“价格”;
- 脚注依赖案例:如主表写“287.5”,脚注写“*已扣除$2.3/吨补贴”,正确答案应为289.8。
C. 风险清单模板:用表格固化风险,避免口头沟通遗漏:
风险类型 具体表现 应对方案 负责人 PII泄露 表格含供应商银行账号 在Step 3加入PII检测规则 数据治理组 多语言 表头为中文,数据为英文 Step 2 enrichment统一映射country_code NLP工程师 更新延迟 每月5日更新PDF,但系统需当日生效 Step 8设置每日凌晨3点自动触发Pipeline DevOps
实操心得:发现冲刺必须产出可执行的《数据契约》(Data Contract),明确每类文档的字段映射规则(如“所有‘出口价格’系列必须标准化为series_name='export_price_usd_per_tonne'”)。没有这份契约,后续所有开发都是空中楼阁。
3.2 步骤1:结构化提取——为什么Docling比PyMuPDF强3倍?
传统方案用PyMuPDF+pdfplumber组合,但面对复杂表格时,90%的时间花在调试坐标参数上。Docling(IBM开源)的突破在于:它不依赖坐标,而依赖视觉语义理解。其底层用LayoutParser检测区块,再用TableFormer识别表格结构,对合并单元格、斜线表头、多级表头的还原准确率高达92.4%(我们实测数据)。
1.1 Docling实战配置:
from docling.document_converter import DocumentConverter # 关键参数:table_mode="ACCURATE"启用TableFormer,牺牲速度换精度 converter = DocumentConverter( table_mode="ACCURATE", ocr_enabled=False, # 数字PDF无需OCR,关掉省50%时间 max_pages=100 # 防止超长文档卡死 ) result = converter.convert("Q4_Report.pdf") # 输出结构化JSON,含document.tables(DataFrame列表)和document.text(Markdown)1.2 表头合并算法:这是处理多级表头的核心。我们不用简单拼接,而是用“语义继承”逻辑:
def merge_headers(headers): # headers = [["单位:USD/tonne", "", ""], ["国家", "2025年4月", "2025年5月"]] merged = [] for col_idx in range(len(headers[0])): # 向上追溯非空父表头 parent = "" for row in reversed(headers[:-1]): # 除最后一行外 if row[col_idx].strip(): parent = row[col_idx].strip() break child = headers[-1][col_idx].strip() if parent and child: merged.append(f"{child} ({parent})") # → "2025年4月 (USD/tonne)" else: merged.append(child or parent) return merged这样生成的列名自带单位,下游SQL查询时无需额外JOIN,直接
WHERE column_name LIKE '%USD/tonne%'即可。1.3 表格预览生成技巧:预览不是简单截取前5行。我们用“多样性采样”:
def diverse_sample(rows, k=5): if len(rows) <= k: return rows # 策略:首行+末行+中位数行+最大值行+最小值行(数值列) sample = [rows[0], rows[-1]] mid = len(rows)//2 sample.append(rows[mid]) # 找数值列(跳过字符串列) num_cols = [i for i, t in enumerate(rows[0]) if isinstance(t, (int, float))] if num_cols: col = num_cols[0] max_row = max(rows, key=lambda r: r[col] if isinstance(r[col], (int, float)) else 0) min_row = min(rows, key=lambda r: r[col] if isinstance(r[col], (int, float)) else float('inf')) sample.extend([max_row, min_row]) return sample[:k]这确保预览既能体现常规数据,又能暴露异常值,提升检索鲁棒性。
3.3 步骤2:业务术语富化——让机器听懂“人话”
富化不是锦上添花,而是解决“同义词灾难”。客户文档中,“加拿大”出现过5种写法:“Canada”“CAN”“CA”“加拿大”“North America (CA)”;“出口价格”有“Export Price”“EXPT PRICE”“FOB Price”“离岸价”。不统一,FACTS查询必败。
2.1 富化规则引擎:我们用SQLite做轻量级MDM(主数据管理),表结构如下:
CREATE TABLE country_map ( raw_value TEXT, -- 原始文本 country_code TEXT, -- 标准化代码 country_name TEXT, -- 标准名称 priority INTEGER -- 优先级(用于冲突时选择) ); INSERT INTO country_map VALUES ('Canada', 'CA', 'Canada', 1), ('CAN', 'CA', 'Canada', 2), ('加拿大', 'CN', 'China', 1); -- 注意:这里故意设错,演示冲突检测2.2 动态富化流程:
def enrich_country(raw_geo): # 1. 精确匹配 cur.execute("SELECT country_code FROM country_map WHERE raw_value=?", (raw_geo,)) code = cur.fetchone() if code: return code[0] # 2. 模糊匹配(Levenshtein距离≤2) cur.execute("SELECT country_code FROM country_map WHERE levenshtein(?, raw_value) <= 2", (raw_geo,)) code = cur.fetchone() if code: return code[0] # 3. 规则兜底(如含'Canada'则返回'CA') if 'canada' in raw_geo.lower(): return 'CA' raise ValueError(f"未知地理标识: {raw_geo}")2.3 单位标准化实战:这是最容易被忽视的坑。“/ton”“per ton”“tonne”“MT”在业务中常混用,但FACTS存储必须统一为
unit_std。我们建立映射表:原始单位 标准单位 换算系数 适用场景 /ton, per ton /TON 1.0 美国市场 /tonne, MT /TONNE 1.000 国际标准 /kg /TONNE 1000.0 需换算 % PCT 1.0 百分比 富化时不仅存
unit_std,还存unit_raw和conversion_factor,确保溯源时能还原原始表达。
3.4 步骤3:治理与冻结——没有治理的RAG就是定时炸弹
客户曾因未治理,导致生产环境出现严重事故:一份含供应商银行账号的PDF被误入FACTS,API返回时未脱敏,造成数据泄露。治理不是加个过滤器,而是贯穿全链路的控制点。
3.1 PII检测双保险:
- 静态规则:用regex匹配常见PII模式(
\b\d{4}-\d{4}-\d{4}-\d{4}\b信用卡); - 动态模型:集成Presidio,对文本块进行NER识别,重点检测
PHONE_NUMBER、EMAIL_ADDRESS、BANK_ACCOUNT_NUMBER。
- 静态规则:用regex匹配常见PII模式(
3.2 访问控制粒度:
- 文档级:
audience=internal(仅内部可见); - 表格级:
access_level=confidential(需审批); - 字段级:
sensitive_columns=["bank_account"](该列数据永远不返回)。
- 文档级:
3.3 冻结清单(Manifest)生成:这是可审计性的基石。每次Pipeline运行,生成JSON清单:
{ "run_id": "ingest-20250415-001", "documents": [ { "doc_id": "Q4_Report.pdf", "checksum": "sha256:abc123...", "size_bytes": 2457600, "policy_version": "v2.1", "governance_status": "approved" } ], "timestamp": "2025-04-15T03:00:00Z" }所有后续索引、查询都绑定此清单ID。若用户投诉答案错误,可立即用该ID复现整个处理过程。
3.5 步骤4:幂等性设计——让Pipeline像数据库事务一样可靠
RAG Pipeline最怕“重复处理”。一份PDF被处理两次,DOC索引出现重复chunk,FACTS表插入两条相同数据,检索结果直接翻倍。我们的解决方案是“全链路哈希”。
4.1 文件级幂等:不依赖文件名(可能重命名),而用内容哈希:
def file_fingerprint(path): # 用blake2b(比sha256快30%)计算内容哈希 with open(path, "rb") as f: return hashlib.blake2b(f.read()).hexdigest() # 存入Redis:key=f"file:{fingerprint}", value={"doc_id":"Q4_Report.pdf","processed_at":"2025-04-15"}4.2 Chunk级幂等:每个DOC chunk的ID由
tenant+doc_id+table_id+section_path哈希生成:chunk_id = hashlib.md5( f"{tenant}|{doc_id}|{table_id}|{section_path}".encode() ).hexdigest() # 写入前检查:if exists(chunk_id) and hash(content)==stored_hash: skip4.3 FACTS行级幂等:这是最难的,因为同一事实可能在不同PDF中出现。我们定义“自然键”(Natural Key):
# FACTS行的唯一标识 = 业务维度组合 natural_key = f"{geo}|{period_ym}|{series_name}|{unit_std}|{currency}" fact_id = hashlib.md5(natural_key.encode()).hexdigest() # 插入前检查fact_id是否存在,存在则比对value是否一致
注意:所有ID生成必须用确定性哈希(md5/sha256),禁用随机UUID。否则重跑Pipeline时ID不一致,导致数据漂移。
3.6 步骤5:智能分块——为什么“递归分割”在表格场景是毒药?
很多教程鼓吹“RecursiveCharacterTextSplitter万能”,但在表格PDF中,它会让你的检索准确率断崖下跌。原因很简单:递归分割会把表格预览切碎。一个本该完整的预览Markdown:
| 国家 | 2025年4月 | 2025年5月 | |---|---|---| | 加拿大 | 287.5 | 291.2 |被切成:
- Chunk1:
| 国家 | 2025年4月 | 2025年5月 | - Chunk2:
|---|---|---| - Chunk3:
| 加拿大 | 287.5 | 291.2 |
向量搜索时,“加拿大”和“287.5”不在同一chunk,相关性直接归零。
5.1 正确分块策略:
# 1. 先用标题分割(保留语义完整性) headers_to_split_on = [("# ", "H1"), ("## ", "H2"), ("### ", "H3")] splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on) sections = splitter.split_text(markdown_text) # 每个section含完整标题+内容 # 2. 对每个section,用语义分隔符切分(非字符) separators = [ "\n\n", # 段落 "\n", # 行 ". ", # 句子(注意空格,避免切单词) "; ", # 分号句 "。", # 中文句号 ";" # 中文分号 ] recursive = RecursiveCharacterTextSplitter( separators=separators, chunk_size=512, # 根据embedding模型调整 chunk_overlap=64 )5.2 表格预览的特殊处理:预览块必须整体保留,不参与递归分割。我们在Docling输出后,单独提取所有
<table_preview>块,作为独立chunk:# 从Docling的Markdown中提取预览 import re preview_pattern = r"\|.*?\|\n\|.*?\|\n(\|.*?\|\n)+" previews = re.findall(preview_pattern, markdown_text, re.DOTALL) for preview in previews: doc_chunks.append({ "content": preview.strip(), "meta": {"type": "table_preview", "has_facts": True} })
3.7 步骤6:嵌入一致性——为什么混用模型比不用模型更危险?
我见过最惨的案例:团队先用text-embedding-ada-002训练,后升级到text-embedding-3-large,但没重嵌入旧数据。结果新问题总在旧文档中找不到答案,因为两个模型的向量空间根本不兼容——就像用北京地图坐标去查上海地铁站。
6.1 嵌入版本控制:每个embedding向量必须携带元数据:
embedding_record = { "content": "Q4营收增长12%...", "embedding": [0.12, -0.45, ...], # 3072维 "meta": { "model_id": "openai/text-embedding-3-large", "dim": 3072, "preproc_version": "v3.2", # 包含标点清洗、停用词等 "chunk_id": "sec-Q4-Rev:p1" } }6.2 模型切换checklist:
- ✅ 生成新模型的测试集(100个典型问题);
- ✅ 在新模型上重嵌入全量DOC索引;
- ✅ 用旧模型向量重建索引(不可行,必须删除);
- ✅ 更新所有API客户端的
model_id参数; - ✅ 设置监控告警:当
model_id不匹配时,拒绝查询。
实操心得:永远不要在生产环境“灰度”切换embedding模型。要么全量切换,要么并行双跑(但需两套索引),绝不能混用。
3.8 步骤7:检索优化——向量搜索只是起点,不是终点
Vector DB的相似度分数(cosine similarity)在真实场景中误差极大。我们做过测试:当top_k=10时,正确答案在第1位的概率仅41%,而在第5-10位的概率达38%。这意味着光靠向量搜索,一半以上的问题会答错。
7.1 元数据过滤(Filtering):这是最有效的精度提升手段。在Chroma中,我们为每个chunk添加以下过滤字段:
# DOC chunk元数据 metadata = { "doc_type": "quarterly_report", "topic": "revenue", "page": 12, "table_id": "TBL-047", "has_facts": True, "language": "en" } # 查询时强制过滤 results = collection.query( query_embeddings=[q_vec], n_results=10, where={"topic": "revenue", "page": {"$gte": 10, "$lte": 15}} )7.2 重排序(Re-ranking)实战:我们对比了3种方案:
方案 准确率 延迟 成本 Cohere Rerank 89.2% 320ms $0.10/1000 queries BGE-reranker-base 86.7% 180ms 免费(自托管) Cross-Encoder(本地) 91.5% 450ms GPU资源占用高 最终选择BGE-reranker-base,因其在准确率和成本间取得最佳平衡。部署时用ONNX Runtime加速,延迟压到120ms。
7.3 混合检索(Hybrid Search):将BM25(关键词)和向量搜索结果融合:
# BM25召回关键词匹配的chunk(如“Q4”“revenue”) bm25_results = bm25_search("Q4 revenue growth") # 向量搜索召回语义相近的chunk vector_results = vector_search(q_vec) # 加权融合:BM25权重0.3,向量权重0.7 fused_scores = {} for r in bm25_results + vector_results: fused_scores[r.id] = fused_scores.get(r.id, 0) + r.score * (0.3 if r.source=="bm25" else 0.7) top_5 = sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)[:5]
3.9 步骤8:规模化部署——当PDF从273份变成27,300份
客户二期需求是处理10年历史档案,共27,300份PDF。此时单机Pipeline完全失效,必须转向分布式。
8.1 批处理架构:
- 输入队列:AWS SQS,每条消息含
{pdf_s3_path, doc_id, priority}; - Worker集群:EC2 Spot Instances,每个Worker拉取消息,执行完整Pipeline;
- 状态追踪:DynamoDB记录每份PDF的
status(processing/failed/success)和last_updated。
- 输入队列:AWS SQS,每条消息含
8.2 性能调优关键点:
- 并行度控制:Worker数=CPU核心数×2(非盲目堆核),避免I/O争抢;
- 内存优化:Docling处理大PDF时内存飙升,我们限制单Worker最多处理50页,超限则切分PDF;
- 缓存策略:对高频访问的DOC chunk,用Redis缓存embedding向量(TTL=1小时)。
8.3 监控告警体系:
指标 阈值 告警方式 Pipeline成功率 <99.5% 企业微信+电话 平均处理时长 >120秒 企业微信 FACTS数据新鲜度 >24小时 邮件+Jira工单 PII检测漏报率 >0.1% 紧急会议
最后分享一个血泪教训:上线前必须做“混沌测试”。我们曾模拟网络分区(Worker无法访问S3),结果发现Pipeline卡在Docling下载阶段,无超时机制,导致整个队列阻塞。现在所有外部调用都加了
timeout=300和重试逻辑。
4. 常见问题与避坑指南:那些文档里不会写的实战真相
4.1 问题1:表格合并单元格导致数据错位,怎么办?
现象:PDF中“2025年4月”跨两列,Docling解析后,该列数据全部右移一列,导致“加拿大”值被读成“美国”。
根因:Docling的TableFormer对跨列合并单元格支持有限,尤其当合并单元格内含换行时。
解决方案:
- 预处理修复:用pdfplumber检测合并单元格,手动填充空白:
import pdfplumber with pdfplumber.open("Q4_Report.pdf") as pdf: page = pdf.pages[11] # 获取所有表格 tables = page.extract_tables() for table in tables: # 检测第0行第1列是否为空且第0行第0列非空 → 可能是合并单元格 if not table[0][1] and table[0][0]: # 将table[0][0]的值复制到table[0][1] table[0][1] = table[0][0] - 后处理校验:对FACTS行做跨列一致性检查:
# 如果geo='CA',则series_name必须含'Canada'或'CA' if row['geo'] == 'CA' and 'Canada' not in row['series_name'] and 'CA' not in row['series_name']: log_warning(f"地理标识与系列名不匹配: {row}")
4.2 问题2:脚注修改主表数值,但脚注未被关联到对应表格
现象:主表写“287.5”,脚注写“*已扣除$2.3/吨补贴”,但脚注chunk的table_id为空,无法关联。
根因:脚注在PDF中常位于表格下方空白处,Docling将其识别为独立文本块,未与上方表格建立父子关系。
解决方案:
- 空间关联算法:计算脚注块与各表格的垂直距离,距离最近且在同一页面的表格即为其父表:
def find_parent_table(footnote_bbox, tables_on_page): min_dist = float('inf') parent_table = None for table in tables_on_page: # 计算脚注底部到表格顶部的距离 dist = abs(footnote_bbox.y1 - table.bbox.y0) if dist < min_dist and dist < 100: # 1
