LangChain 实战:用混合检索啃下 1000 页 PDF,搭一个长文档问答 Agent

前言:长文档 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-communitylangchain-openailangchain-text-splitters 等),import 路径随版本会变 ,下面的代码以拆包后的写法为准,跑不通时请以官方文档为准对一下路径。


环境准备

Python 3.10+,一条命令装齐:

bash 复制代码
pip install langchain langchain-community langchain-openai \
            faiss-cpu rank-bm25 pymupdf

在项目根目录放个 .env,写上你的 Key(用 OpenAI 或任意兼容接口都行):

bash 复制代码
OPENAI_API_KEY=sk-xxxx
# 如果走兼容网关,再加一行 base_url
# OPENAI_BASE_URL=https://your-gateway/v1

第一步:解析 PDF + 结构感知分块

很多人 RAG 翻车就栽在第一步------按固定字符硬切。正确思路是先按文档天然边界切,再控制粒度 。用 PyMuPDFLoader 把 PDF 读成带页码元数据的文档,再用 RecursiveCharacterTextSplitter 按段落/换行优先切分:

python 复制代码
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_sizechunk_overlap 没有万能值。重叠太小,跨页延续的内容(比如付款条款从第 11 页末尾接到第 12 页开头)会被切断漏检;重叠太大又拖慢处理、增加成本。先用一份代表性文档试几组参数,看召回效果再定,别照搬别人的数字。

中文文档记得在 separators 里加中文标点(。!?),否则按英文句号切会把整段连在一起。


第二步:向量化 + 建本地向量库

把切好的块转成向量,存进 FAISS(本地文件,零服务依赖):

python 复制代码
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 按权重融合:

python 复制代码
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。处理长文档时,用户盯着空屏等十几秒就会走,所以用流式输出做打字机效果:

python 复制代码
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(pytesseractpaddleocr)把图片转成文字,再走上面的流程。

超大文件吃满内存:体积特别大的 PDF 一次性加载容易 OOM,先按页拆成小文件再逐个处理。PyMuPDF 拆分很简单:

python 复制代码
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 报错先去官方文档对一眼路径。


参考与延伸

文中参数与权重均为示例起点,实际效果随文档类型、模型与硬件不同而变化,请以你自己的场景实测为准。

相关推荐
Dazer0071 小时前
Edge 浏览器绕过 HTTPS 证书错误
前端·https·edge
元让_vincent1 小时前
Spark 2.0:面向 Web 的 3DGS 可视化与大场景渲染平台详解
前端·3d·spark·渲染·轻量化·3dgs·lod
KaMeidebaby2 小时前
卡梅德生物技术快报|酵母双杂交 cDNA 文库构建与蛋白互作筛选流程
服务器·前端·数据库·人工智能·算法
沐风___2 小时前
App 上架之后:如何看数据、获取用户与持续迭代产品
服务器·前端·数据库
AAA大运重卡何师傅(专跑国道)2 小时前
力扣hot100
服务器·前端·数据库
GISer_Jing3 小时前
前端沙箱开源项目推荐(React/Next/Vue优先)
前端·react.js·开源
云水一下3 小时前
CSS3从零基础到精通(三):动感地带——过渡、动画、变形与响应式
前端·css3
KaMeidebaby3 小时前
卡梅德生物技术快报|Western Blot 实验应用:肺肠轴机制研究全流程技术解析
前端·数据库·人工智能·算法·百度
达达爱吃肉4 小时前
claude 接入deepseek 运行报错
java·服务器·前端