OCR 的输出不仅仅是文字(Text),它是带坐标(Bbox)的文字。 如果你只取 Text 扔进 LangChain,你就丢掉了 50% 的信息量。 我们需要利用 坐标信息字体信息,在切片之前做一次“外科手术”。

1. 第一刀:基于几何坐标的“去噪” (Geometric Cleaning)

这是最简单粗暴,但最有效的方法。 绝大多数文档的页眉都在页面顶部 10% 的区域,页脚都在底部 10% 的区域。

策略: 设定 y_miny_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)。

  1. 检查页尾标点:如果第 N 页的最后一行不以标点符号(。!?.)结尾,说明这句话没说完。
  2. 拼接:将第 N 页的最后一段,和第 N+1 页的第一段,强行合并成一个段落。
  3. 然后再切片

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 工程师的基本功。