前言:长文档 RAG,到底难在哪
上个月我接了个活,帮客户分析一份 800 页的技术白皮书。第一反应是丢给通用大模型,结果几分钟就提示上下文超限,账单也跑上去了,却只读完前面一小部分。后来自己用 RAG 做,第一版按固定字符数硬切、直接全量向量检索,问它「第三章的结论是否被第八章推翻」,它答得驴唇不对马嘴。
这就是长文档 RAG 的两个真实痛点:
- 上下文窗口塞不下:几百页文档的全文,再大的窗口也扛不住,硬塞只会烧钱还丢信息。
- 粗暴分块毁掉语义:按 1000 字符一刀切,一个段落被劈成两半,检索回来的片段支离破碎,模型只能靠猜。
这篇文章不卖任何"神器",就用 LangChain 这套大家都能 pip install 的真实工具,从零搭一个能啃长文档的问答 Agent。核心三招:结构感知分块、混合检索(BM25 + 向量)、流式输出。代码我都按当前版本验证过思路,踩过的坑也一并写出来。
技术选型:全是能装上的真东西
先把家伙什摆出来,都是 PyPI 上真实存在、社区在用的库:
| 库 | 作用 | 备注 |
|---|---|---|
langchain / langchain-community | RAG 编排框架 | 加载器、检索器、向量库封装 |
langchain-openai | LLM 与 Embedding 接入 | 也可换成兼容接口 |
pymupdf | PDF 解析 | 比纯文本加载器更稳,能拿到页/块结构 |
faiss-cpu | 本地向量库 | 轻量、零服务依赖 |
rank-bm25 | BM25 关键词检索 | BM25Retriever 的底层依赖 |
说明:LangChain 近期版本把包拆得比较细(
langchain-community、langchain-openai、langchain-text-splitters等),import 路径随版本会变,下面的代码以拆包后的写法为准,跑不通时请以官方文档为准对一下路径。
环境准备
Python 3.10+,一条命令装齐:
pip install langchain langchain-community langchain-openai \
faiss-cpu rank-bm25 pymupdf
在项目根目录放个 .env,写上你的 Key(用 OpenAI 或任意兼容接口都行):
OPENAI_API_KEY=sk-xxxx
# 如果走兼容网关,再加一行 base_url
# OPENAI_BASE_URL=https://your-gateway/v1
第一步:解析 PDF + 结构感知分块
很多人 RAG 翻车就栽在第一步——按固定字符硬切。正确思路是先按文档天然边界切,再控制粒度。用 PyMuPDFLoader 把 PDF 读成带页码元数据的文档,再用 RecursiveCharacterTextSplitter 按段落/换行优先切分:
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 1. 加载 PDF,每页是一个 Document,带 page 等元数据
docs = PyMuPDFLoader("report.pdf").load()
# 2. 递归分块:优先按段落、再按句子切,尽量不破坏语义边界
splitter = RecursiveCharacterTextSplitter(
chunk_size=800, # 每块字符数,按你的文档类型调
chunk_overlap=120, # 重叠区间,防止跨块信息断裂
separators=["\n\n", "\n", "。", "!", "?", " ", ""],
)
chunks = splitter.split_documents(docs)
print(f"共切出 {len(chunks)} 块")
踩坑提醒:
chunk_size和chunk_overlap没有万能值。重叠太小,跨页延续的内容(比如付款条款从第 11 页末尾接到第 12 页开头)会被切断漏检;重叠太大又拖慢处理、增加成本。先用一份代表性文档试几组参数,看召回效果再定,别照搬别人的数字。
中文文档记得在 separators 里加中文标点(。!?),否则按英文句号切会把整段连在一起。
第二步:向量化 + 建本地向量库
把切好的块转成向量,存进 FAISS(本地文件,零服务依赖):
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# 从分块构建向量库;首次会调用 embedding 接口,之后可落盘复用
vector_store = FAISS.from_documents(chunks, embeddings)
vector_store.save_local("faiss_index") # 落盘,下次直接 load 省钱
# 复用:FAISS.load_local("faiss_index", embeddings, allow_dangerous_deserialization=True)
第一次建索引会按块数调用 embedding 接口,文档大就会慢一点;
save_local落盘后,后续查询直接加载,不用重复花钱。
第三步:混合检索——先粗筛,再精排
这是长文档检索的关键。纯向量检索对专业术语、精确编号(接口名、条款号)经常不敏感;纯关键词又抓不住语义近义。把两者融合才稳:用 BM25Retriever 做关键词召回,用向量检索做语义召回,再用 EnsembleRetriever 按权重融合:
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
# 关键词检索(BM25,靠 rank-bm25)
bm25 = BM25Retriever.from_documents(chunks)
bm25.k = 5
# 语义检索(向量)
vector_retriever = vector_store.as_retriever(search_kwargs={"k": 5})
# 融合:权重可调,偏术语就抬高 BM25,偏语义就抬高向量
hybrid = EnsembleRetriever(
retrievers=[bm25, vector_retriever],
weights=[0.4, 0.6],
)
hits = hybrid.invoke("第三章的结论是否被第八章推翻?")
for d in hits:
print(d.metadata.get("page"), d.page_content[:60])
思路上就是「先看目录定位章节,再精读那一页」——廉价的关键词筛选挡掉大头,把语义计算留给真正相关的候选。权重 [0.4, 0.6] 只是起点,按你的文档实测调。
第四步:拼问答 + 流式输出
检索到上下文后,拼进 prompt 交给 LLM。处理长文档时,用户盯着空屏等十几秒就会走,所以用流式输出做打字机效果:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0, streaming=True)
prompt = ChatPromptTemplate.from_template(
"你是严谨的文档分析助手。只依据下面的资料回答,"
"答不出就说\"资料中未提及\",并标注引用页码。\n\n"
"资料:\n{context}\n\n问题:{question}"
)
def ask(question: str):
hits = hybrid.invoke(question)
context = "\n\n".join(
f"[第{d.metadata.get('page','?')}页] {d.page_content}" for d in hits
)
messages = prompt.format_messages(context=context, question=question)
# 逐 token 流式打印
for chunk in llm.stream(messages):
print(chunk.content, end="", flush=True)
ask("这份合同里对甲方不利的条款有哪些?请标注页码。")
几个关键点:
- temperature=0:文档问答要的是忠实,不是创意,调低能明显减少瞎编。
- prompt 里强制「答不出就说未提及」:这是压制大模型幻觉最有效的一招,比任何后处理都管用。
- 要求标注页码:把检索块的
page元数据喂进 prompt,模型就能给出可回溯的引用,用户能翻回原文核对。
想要更规范的链式写法,可以用官方的
create_retrieval_chain+create_stuff_documents_chain(见检索链文档);上面这种手写法更直观,也更容易看清每一步在干嘛。
实战避坑
扫描版 PDF 读出来是乱码:那是图片型 PDF,没有文本层。先过一层 OCR(pytesseract 或 paddleocr)把图片转成文字,再走上面的流程。
超大文件吃满内存:体积特别大的 PDF 一次性加载容易 OOM,先按页拆成小文件再逐个处理。PyMuPDF 拆分很简单:
import fitz # PyMuPDF
doc = fitz.open("large_manual.pdf")
pages_per_file = 100
for i in range(0, doc.page_count, pages_per_file):
sub = fitz.open()
sub.insert_pdf(doc, from_page=i,
to_page=min(i + pages_per_file, doc.page_count) - 1)
sub.save(f"part_{i // pages_per_file}.pdf")
多文档容易串台:同时检索多份文档时,给每份在加载时打上来源标签(写进 Document.metadata),并在 prompt 里要求模型注明来源,否则它会笼统地说「第一份文档说……」,用户分不清。
总结:什么场景适合,什么场景别硬上
适合:长文档、知识密集型任务——技术手册、法律合同、学术论文、行业报告。这套结构感知分块 + 混合检索能精准定位内容,配上"答不出就说未提及"的 prompt,比直接把全文塞给大模型靠谱得多。
不适合:实时短对话(客服、闲聊)。为长文档检索准备的这套链路,用在短问答上属于杀鸡用牛刀,延迟和成本都不划算。
一句话:长文档 RAG 没有银弹,能不能用,取决于你分块切得准不准、检索召得全不全、prompt 压不压得住幻觉。把这三点调好,剩下的就是按你的文档实测迭代。代码我尽量写成能跑的样子,但版本会变,遇到 import 报错先去官方文档对一眼路径。
参考与延伸
- LangChain RAG 教程(官方):python.langchain.com/docs/tutori…
EnsembleRetriever混合检索:python.langchain.com/docs/how_to…- FAISS 向量库集成:python.langchain.com/docs/integr…
- PyMuPDF 文档:pymupdf.readthedocs.io
- LlamaIndex(另一套同类方案,可对照):docs.llamaindex.ai
文中参数与权重均为示例起点,实际效果随文档类型、模型与硬件不同而变化,请以你自己的场景实测为准。