客户甩过来一份200页的产品手册PDF:"帮我们做个能查这个的AI助手。"
我的第一反应:简单,第3篇RAG搭过了——切chunk、存向量库、检索问答,搞定。
结果上线第一天就被投诉:查表单数据查不到,跨章节的对比问题答不对,"帮我总结全文"直接超出token限制。
200页文档和10页文档,不是"量变",是"质变"。 长文档处理的坑,远比想象中多。
先说结论
| 问题 | 错误做法 | 正确做法 |
|---|---|---|
| 文档太长token超限 | 整篇丢给LLM | 分层处理:先检索再生成 |
| 表格/图表提取丢失 | 纯文本提取 | 专用PDF解析工具 |
| 跨章节问题答不准 | 只看top-3 chunk | 父文档检索+上下文窗口 |
| 全文摘要不全 | 从头到尾摘要 | Map-Reduce分片摘要 |
Java人的理解:长文档 ≈ 大数据量查询。 你不会把100万条记录全SELECT出来再处理,而是先WHERE过滤再聚合。AI处理长文档也是:先检索筛选再生成,不是一股脑全塞。
坑1:PDF提取出来全是乱码和断行
翻车现场
用PyPDF2提取200页PDF:
from PyPDF2 import PdfReader
reader = PdfReader("product-manual.pdf")
text = ""
for page in reader.pages:
text += page.extract_text()
结果:
产品名称 型号 价格
---------- ----- -----
智能门锁 A200 899
(续表)
颜色:深空灰 重量:2.3kg
表格被打散成文本流,跨页表格直接断开,多栏排版左右混排。 用这种数据做RAG,检索效果可想而知。
正确做法:用专用PDF解析工具
# 方案1:unstructured(推荐,支持表格/图片/布局)
from unstructured.partition.pdf import partition_pdf
elements = partition_pdf(
"product-manual.pdf",
strategy="hi_res", # 高精度模式
infer_table_structure=True, # 识别表格结构
extract_images_in_pdf=True, # 提取图片
)
# 按类型处理
for element in elements:
if element.category == "Table":
# 表格保留HTML结构
table_html = element.metadata.text_as_html
# 转成Markdown表格更好读
table_md = html_table_to_markdown(table_html)
elif element.category == "Title":
# 标题信息
title = element.text
elif element.category == "NarrativeText":
# 正文
text = element.text
方案2:Marker(PDF转Markdown,效果最好)
# pip install marker-pdf
from marker.converters.pdf import PdfConverter
from marker.models import create_model
from marker.output import text_from_rendered
converter = PdfConverter(artifact_dict=create_model())
rendered = converter("product-manual.pdf")
markdown_text, metadata = text_from_rendered(rendered)
Marker的优势: 把PDF转成结构化Markdown,表格保留为Markdown表格,标题层级完整,代码块完整。直接用第23篇的Markdown结构切分就行。
成本对比:
| 工具 | 速度 | 效果 | 免费 |
|---|---|---|---|
| PyPDF2 | 快 | 差(表格/布局丢失) | 是 |
| unstructured (hi_res) | 慢(需OCR模型) | 好 | 是 |
| Marker | 中等 | 最好 | 是 |
| 商业API(TextIn/百度) | 快 | 好 | 否(按页收费) |
坑2:跨章节问题答不准——检索只看片段不看上下文
翻车现场
用户问:"A200和A300有什么区别?"
A200的参数在第3章,A300的参数在第5章。检索top-4 chunk:
Chunk 1: "A200智能门锁,支持指纹解锁..." (第3章,sim=0.78)
Chunk 2: "A200技术参数:重量2.3kg..." (第3章,sim=0.72)
Chunk 3: "常见问题:如何重置密码..." (FAQ章,sim=0.45)
Chunk 4: "售后服务政策..." (第8章,sim=0.41)
A300的信息根本没检索到! 因为用户的问法里只提了"A200和A300",而A300那个chunk的相似度没进top-4。
正确做法:父文档检索(Parent Document Retriever)
核心思想:用小chunk做检索(精准),命中后返回大chunk(完整上下文)给LLM。
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
# 小chunk:用于精准检索
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
# 大chunk:给LLM看完整上下文
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=800)
# 向量库存小chunk,文档库存大chunk
vectorstore = Chroma(embedding_function=embeddings)
docstore = InMemoryStore()
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=docstore,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# 检索时:用小chunk匹配,返回对应的大chunk
docs = retriever.invoke("A200和A300有什么区别?")
# 返回的是包含A200和A300信息的完整段落
为什么有效? 小chunk(200字符)匹配更精准——"A300"这个关键词在小chunk里密度更高。匹配到后,把包含这个小chunk的大chunk(800字符)取出来,大chunk里有A300的完整参数。
进阶:多向量检索——一个文档存多个检索向量
from langchain.retrievers.multi_vector import MultiVectorRetriever
# 对每个大chunk生成摘要,用摘要做检索向量
summaries = []
for doc in large_chunks:
summary = llm.invoke(f"用一句话总结:{doc.page_content}")
summaries.append(summary)
# 向量库存摘要向量,文档库存原文
retriever = MultiVectorRetriever(
vectorstore=vectorstore,
docstore=docstore,
id_key="doc_id",
)
# 摘要向量 → 检索命中 → 返回对应原文
效果: 用户问"A200和A300区别",摘要向量里"A200和A300智能门锁对比"精准命中,返回包含两者完整参数的原文。
坑3:全文摘要——token超限或摘要不全
翻车现场
用户问"帮我总结一下这份手册的核心内容"。
方案1:把200页全文丢给LLM → token超限(200页≈50万token) 方案2:只取前10页摘要 → 漏掉后半部分内容 方案3:分章摘要再合并 → 每章摘要还行,合并时又超限
正确做法:Map-Reduce分片摘要
from langchain.chains.summarize import load_summarize_chain
# Map-Reduce:先分片摘要,再合并
chain = load_summarize_chain(
llm,
chain_type="map_reduce",
verbose=True
)
# 自动处理:分片→各片摘要→合并摘要
summary = chain.run(docs)
Map-Reduce的问题: 合并时可能丢失细节。更好的方案是Refine(渐进式精炼):
chain = load_summarize_chain(
llm,
chain_type="refine",
verbose=True
)
# Refine逻辑:
# 第1个chunk → 生成初始摘要
# 第2个chunk + 当前摘要 → 更新摘要
# 第3个chunk + 当前摘要 → 更新摘要
# ...
# 最终摘要不断被"精炼",越来越完整
Refine vs Map-Reduce:
| 维度 | Map-Reduce | Refine |
|---|---|---|
| 速度 | 快(分片并行) | 慢(串行精炼) |
| 完整性 | 合并时可能丢信息 | 逐步累积,更完整 |
| token消耗 | 多(每个chunk独立摘要) | 少(只在摘要上追加) |
| 适用 | 大量短文档 | 一个长文档 |
200页手册用Refine更合适: 串行处理虽然慢,但摘要更完整。
实际优化: 先用Map-Reduce做每章摘要,再用Refine合并章节摘要——两阶段处理:
# 阶段1:每章独立摘要(可并行)
chapter_summaries = []
for chapter in chapters:
summary = llm.invoke(f"用3句话总结以下内容:\n{chapter}")
chapter_summaries.append(summary)
# 阶段2:合并章节摘要(Refine)
final_summary = chapter_summaries[0]
for i in range(1, len(chapter_summaries)):
final_summary = llm.invoke(
f"当前摘要:{final_summary}\n\n"
f"新内容:{chapter_summaries[i]}\n\n"
f"将新内容融入当前摘要,保持完整性和连贯性。"
)
坑4:长上下文模型的陷阱——能塞进去不等于能用
翻车现场
2025年模型上下文越来越长:GPT-4o 128K,Claude 200K,Gemini 1M。
"那我直接把200页文档塞进prompt不就行了?不用RAG了!"
llm = ChatOpenAI(model="gpt-4o-128k")
response = llm.invoke(f"根据以下文档回答问题:\n{full_document}\n\n问题:{query}")
3个问题:
- 成本爆炸: 200页≈50万token输入,GPT-4o的输入价格1.25
- 中间内容被忽略: 研究表明,LLM对长上下文中间部分的信息"注意力衰减"——像读一本长篇小说,中间章节记不清
- 响应变慢: 50万token输入,推理时间10-30秒
正确做法:长上下文≠不用RAG,而是RAG+长上下文互补
# 最佳实践:先用RAG筛选相关片段,再用长上下文模型做深度理解
# Step 1: RAG检索top-10(比平时多取一些)
retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
relevant_chunks = retriever.invoke(query)
# Step 2: 用长上下文模型处理多个chunk(不用截断)
context = "\n\n---\n\n".join([chunk.page_content for chunk in relevant_chunks])
llm = ChatOpenAI(model="qwen-long") # 长上下文模型,便宜
response = llm.invoke(
f"根据以下文档片段回答问题,如果多个片段有冲突请指出:\n\n"
f"{context}\n\n"
f"问题:{query}"
)
什么时候用长上下文模型替代RAG?
| 场景 | 用RAG | 用长上下文 |
|---|---|---|
| 文档<50页 | 都行 | 都行 |
| 文档50-200页 | 推荐 | 成本高 |
| 精确查询("XX参数是多少") | 推荐 | 杀鸡用牛刀 |
| 全文理解("总结全文"/"对比分析") | 不够 | 推荐 |
| 多文档交叉分析 | 推荐 | 适合 |
实用建议: 用便宜的长上下文模型(qwen-long、deepseek)做RAG的后端,比用贵的短上下文模型+大量chunk更划算。
回头看
| 坑 | 一句话总结 |
|---|---|
| PDF提取乱码 | 别用PyPDF2,用unstructured或Marker,表格结构不丢 |
| 跨章节答不准 | 父文档检索:小chunk精准匹配,大chunk给完整上下文 |
| 全文摘要不全 | Refine渐进式精炼,或两阶段Map-Reduce+Refine |
| 长上下文不是万能药 | RAG筛选+长上下文深度理解,互补才是正解 |
长文档处理的核心原则和Java处理大数据量一样:不要全量加载,先过滤再处理。 RAG就是AI应用的"WHERE条件",长上下文模型是"聚合函数",两者结合才能高效处理大文档。