OCR 的输出不仅仅是文字(Text),它是带坐标(Bbox)的文字。 如果你只取 Text 扔进 LangChain,你就丢掉了 50% 的信息量。 我们需要利用 坐标信息 和 字体信息,在切片之前做一次“外科手术”。
1. 第一刀:基于几何坐标的“去噪” (Geometric Cleaning)
这是最简单粗暴,但最有效的方法。 绝大多数文档的页眉都在页面顶部 10% 的区域,页脚都在底部 10% 的区域。
策略: 设定 y_min 和 y_max 阈值。凡是落在这个区域之外的文字,一律通过规则过滤掉,不进入 Embedding 环节。
代码实战(基于 OCR 结果的清洗):
Python
def clean_ocr_results(ocr_pages, page_height=1000):
"""
ocr_pages: list of pages, each page contains list of lines
line format: {'text': '...', 'bbox': [x0, y0, x1, y1]}
"""
cleaned_text = []
# 定义“安全区”:去除顶部 10% 和底部 10%
header_threshold = page_height * 0.1
footer_threshold = page_height * 0.9
for page in ocr_pages:
page_content = []
for line in page:
x0, y0, x1, y1 = line['bbox']
text = line['text']
# 1. 规则过滤:页眉页脚
center_y = (y0 + y1) / 2
if center_y < header_threshold or center_y > footer_threshold:
continue # 跳过噪音
# 2. 规则过滤:页码 (正则匹配 "Page 1 of 10" 或纯数字)
if re.match(r'^Page \d+', text) or text.isdigit():
continue
page_content.append(text)
cleaned_text.append("\n".join(page_content))
return cleaned_text
2. 第二刀:语义切片 (Semantic Chunking)
剔除了噪音后,怎么切分正文? 按“500字符”硬切是下策。上策是 “按标题切”。
OCR 通常能提供字体大小(或者你可以通过 bbox 高度计算)。
- 大号字 = 一级标题(Header 1)
- 中号字 = 二级标题(Header 2)
- 小号字 = 正文
策略: 将 OCR 结果重构为 Markdown 格式(# Title, ## Subtitle),然后使用 LangChain 的 MarkdownHeaderTextSplitter。
逻辑伪代码:
Python
def ocr_to_markdown(lines):
md_text = ""
base_font_height = calculate_median_height(lines) # 计算正文平均高度
for line in lines:
height = line['bbox'][3] - line['bbox'][1]
# 如果字体高度是正文的 1.5 倍,认为是标题
if height > base_font_height * 1.5:
md_text += f"\n## {line['text']}\n"
else:
md_text += f"{line['text']}"
return md_text
# 然后调用 LangChain
from langchain.text_splitter import MarkdownHeaderTextSplitter
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=[("##", "Section")])
chunks = splitter.split_text(md_text)
这样切出来的 Chunk,天然带有元数据 {'Section': '2023财务报表'},检索时极其精准。
3. 第三刀:跨页合并 (Cross-Page Merging)
OCR 最大的痛点是 “断句”。 第 5 页最后一行是:“本协议有效期至”,第 6 页第一行是:“2025年12月31日”。 如果你按页切片,这两个 Chunk 都会变成废话。
工程策略:段落拼接算法
不要把每一页当成独立的文档,要把它们当成一个流(Stream)。
- 检查页尾标点:如果第 N 页的最后一行不以标点符号(。!?.)结尾,说明这句话没说完。
- 拼接:将第 N 页的最后一段,和第 N+1 页的第一段,强行合并成一个段落。
- 然后再切片。
Python
def merge_cross_page(pages_text):
full_text = ""
for i, page_text in enumerate(pages_text):
# 去除首尾空白
page_text = page_text.strip()
if not page_text: continue
# 检查上一页的结尾
if full_text and not full_text.endswith(('.', '。', '!', '?', '”')):
# 如果上一页没说完,直接拼接到上一页最后,加个空格而不是换行
full_text += " " + page_text
else:
# 如果上一页说完了,这一页另起一段
full_text += "\n\n" + page_text
return full_text
4. 进阶:利用版面分析模型 (Layout Analysis)
如果你的文档非常复杂(双栏排版、侧边栏注释),简单的 y 坐标过滤就失效了。 这时候你需要 Layout Analysis 模型(如 SurYa, PaddleStructure, DocLayNet)。
它们会返回每个区域的标签:Header, Footer, Sidebar, Table, Body。
最佳实践: 在入库 RAG 之前,只提取 label 为 Body (正文) 和 Table (表格) 的区域。 直接丢弃 Sidebar (侧边栏广告) 和 Footer (页脚)。
这相当于有一个 AI 在帮你做初筛,虽然增加了一些推理成本(GPU),但能让 Embedding 的质量提升一个数量级。
总结
做 RAG 系统,Garbage In, Garbage Out(垃圾进,垃圾出) 是铁律。
OCR 识别出的字符只是素材,不是成品。
- 坐标过滤:是成本最低的降噪手段,必做。
- Markdown 重构:是提升语义理解的关键,推荐做。
- 跨页拼接:是解决语义断层的补丁,必须做。
别让用户的提问淹没在“第几页”的页码噪音里。清洗数据,是 RAG 工程师的基本功。