让 RAG 读懂真实文件:从 TXT 到 PDF、PPT、HTML 的实战

从玩具到真实场景

上一篇跑通的 RAG 只处理了 6 行手写的纯文本。这只能证明流程能跑,证明不了它有用。真实场景的文档长这样:

  • 财报 PDF,66 页,图文混排,有表格和目录
  • 产品 PPT,每页一个 slide,文本框散落在不同位置
  • 网页 HTML,内容被 <script> 和 <style> 包围

要让 RAG 处理这些,核心动作只有一个:在向量化之前,先把文件内容抽成纯文本。之后的切块、向量化、检索、生成,全部复用上一篇的流程。

不同格式,不同解析器

格式解析库抽取方式
PDFpypdf 或 pdfplumber逐页抽取文字层
PPT/PPTXpython-pptx遍历每页的文本框
HTMLbeautifulsoup4剔除脚本样式后取文本
TXT/MD内置直接读取

安装依赖:

bash

cd /opt/rag-nvidia
source venv/bin/activate
pip install pypdf python-pptx beautifulsoup4

文件解析层

python

from pypdf import PdfReader
from pptx import Presentation
from bs4 import BeautifulSoup
from pathlib import Path


def parse_pdf(path):
    reader = PdfReader(path)
    return "\n".join(page.extract_text() or "" for page in reader.pages)


def parse_pptx(path):
    prs = Presentation(path)
    texts = []
    for slide in prs.slides:
        for shape in slide.shapes:
            if shape.has_text_frame:
                texts.append(shape.text_frame.text)
    return "\n".join(texts)


def parse_html(path):
    html = Path(path).read_text(encoding="utf-8", errors="ignore")
    soup = BeautifulSoup(html, "html.parser")
    for tag in soup(["script", "style"]):
        tag.decompose()
    return soup.get_text(separator="\n")


def parse_text(path):
    return Path(path).read_text(encoding="utf-8", errors="ignore")


PARSERS = {
    ".pdf": parse_pdf,
    ".ppt": parse_pptx,
    ".pptx": parse_pptx,
    ".html": parse_html,
    ".htm": parse_html,
    ".txt": parse_text,
    ".md": parse_text,
}


def parse_file(path):
    ext = Path(path).suffix.lower()
    if ext not in PARSERS:
        raise ValueError(f"不支持的文件类型:{ext}")
    return PARSERS[ext](path)

文本切块:让检索更精准

真实的 PDF 抽出来的是一整段连续文本。如果直接整篇送进 Embedding,向量会丢失局部语义,检索准确率大幅下降。

解决方案是滑动窗口切块,并在相邻块之间保留一段重叠,避免切在句子中间时丢失上下文:

python

CHUNK_SIZE = 800
CHUNK_OVERLAP = 100


def chunk_text(text, chunk_size=CHUNK_SIZE, overlap=CHUNK_OVERLAP):
    text = "\n".join(line.strip() for line in text.splitlines() if line.strip())
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        start += chunk_size - overlap
    return chunks

800/100 是经验值。段落长就调大,短句列表就调小。

实测:66 页财报 PDF

上传的是一份 66 页的财报 PDF(KOKOPELLI Inc. 2026 财年第一季度)。在运行完整的 RAG 脚本之前,先做一步快速检查——确认 PDF 有没有文字层

bash

python3 -c "
from pypdf import PdfReader
r = PdfReader('data/koko.pdf')
print('pages:', len(r.pages))
for i, p in enumerate(r.pages[:3]):
    t = p.extract_text() or ''
    print(f'page {i+1}: {len(t)} chars, preview: {t[:80]!r}')
"

输出:

text

pages: 66
page 1: 146 chars, preview: 'Financial Results\nKOKOPELLI Inc. (stock code: 4167)...'
page 2: 231 chars, preview: '01 Company & Business Overview\nP.3-15\n02 Highlights...'
page 3: 30 chars, preview: '01\nCompany & Business Overview'

有文字层,可以直接处理。如果每页都是 0 字符,说明是扫描版图片 PDF,需要走 OCR 路径。

完整流程

bash

python3 rag.py data/koko.pdf

脚本会依次执行:

  1. 解析 PDF → 抽取全部文字
  2. 切块 → 生成数百个片段
  3. 向量化 → 调用 NVIDIA Embedding API
  4. 存入 Qdrant 本地数据库
  5. 进入交互式问答

踩坑记录

1. 扫描版 PDF 抽出空白
pypdf 只能读 PDF 的文字层。扫描版 PDF 本质是图片,没有文字层。判断方法就是看每页的字符数是否为 0。是的话,需要先用 pytesseract + pdf2image 做 OCR,但速度慢、占内存,在资源紧张的 VPS 上要谨慎。

2. PDF 段落结构丢失
pypdf 抽出来的文本会丢失原始布局,表格、代码块可能变成乱序文本。对排版要求高的场景,可以换成 pdfplumber,它对布局的保留更好:

python

import pdfplumber

def parse_pdf(path):
    with pdfplumber.open(path) as pdf:
        return "\n".join(page.extract_text() or "" for page in pdf.pages)

3. 大文档触发 API 限流
66 页 PDF 会产生几百个片段,每个片段一次 Embedding 调用。NVIDIA 免费层约 40 请求/分钟,大文档很容易触发 429。解决方案是加批处理 + 重试

python

import time

def embed_texts(texts, input_type="passage", batch_size=10, max_retries=5):
    all_vectors = []
    for i in range(0, len(texts), batch_size):
        batch = texts[i:i + batch_size]
        for attempt in range(max_retries):
            try:
                resp = client.embeddings.create(
                    model=os.getenv("EMBEDDING_MODEL"),
                    input=batch,
                    extra_body={"input_type": input_type},
                )
                all_vectors.extend(d.embedding for d in resp.data)
                break
            except Exception as e:
                if attempt == max_retries - 1:
                    raise
                wait = 2 ** attempt
                print(f"重试 {attempt+1}/{max_retries},等待 {wait}s:{e}")
                time.sleep(wait)
        time.sleep(1)  # 批次间暂停,避免触发限流
    return all_vectors

4. PPT 里的图表和图片读不到
python-pptx 只能读文本框内容。图表数据、图片里的文字无法抽取,需要另外处理。

5. HTML 里的动态内容读不到
BeautifulSoup 只能解析静态 HTML。SPA 或需要 JavaScript 渲染的页面,要先用 playwright 或 selenium 渲染后再解析。

交互式问答

脚本索引完成后会进入问答循环:

text

输入问题开始问答(q 退出)

问题:

输入问题后会同时显示检索到的原始片段LLM 生成的回答,方便对比验证检索质量。

适合测试这份财报的问题:

  • FY2026 第一季度的营收是多少?
  • 公司的主要业务是什么?
  • 这份财报提到了哪些关键话题?
  • KOKOPELLI 的股票代码是多少?

总结

从纯文本到真实文件,RAG 的复杂度主要增加在解析层。一旦文件被抽成纯文本,后面的切块、向量化、检索、生成,全部复用之前的逻辑。

关键原则:

  • 先验证文字层,扫描版 PDF 直接换 OCR 路径
  • 切块加重叠,避免语义断裂
  • 大文档加批处理和重试,应对 API 限流
  • 解析库按格式选,PDF 用 pypdf/pdfplumber,PPT 用 python-pptx,HTML 用 BeautifulSoup

这套方案的核心优势仍然是极简:不部署额外服务,不引入新的容器,所有逻辑在一个脚本里。后续要服务化,只需把 QdrantClient(path=...) 改成 QdrantClient(url=...),业务代码不用改。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注