大语言模型虽然知识渊博,但有两个硬伤:知识停留在训练截止日期,而且无法访问你本地的私有文档。想让模型准确回答基于产品手册、合同条款或内部知识库的问题,RAG(检索增强生成)是目前最成熟且性价比最高的方案。它的思路很直观:先把你给的文档拆成片段,转换成向量存进数据库;提问时,用语义检索找出最相关的片段,然后把这些片段作为“参考资料”连同问题一起交给大模型,让模型基于原文生成答案。LangChain 把这一整条链路封装得很干净,我们只需要聚焦在数据和逻辑上。这篇文章会用五个连贯的步骤,带你从零实现一个能“读懂”PDF 的问答系统,并且我会把每个步骤的设计考量和常见坑点都说清楚。

一、环境准备与核心依赖

先确保 Python 版本 ≥ 3.9,然后安装以下依赖:

pip install langchain langchain-openai langchain-chroma chromadb tiktoken pypdf

简单解释一下每个包的作用:langchain 是链式调用框架,langchain-openai 负责与 OpenAI 的对话和嵌入模型交互,langchain-chromachromadb 搭建本地向量数据库,tiktoken 用于 token 计算,pypdf 则用来解析 PDF。示例默认使用 OpenAI 的模型,因此你需要准备一个 OpenAI API Key,并通过环境变量设置(生产环境务必用.env或密钥管理服务,不要硬编码):

import os
os.environ["OPENAI_API_KEY"] = "sk-你的密钥"

如果你希望完全本地部署,后文也会给出模型替换的思路。

二、从原始文档到可检索的知识片段

RAG 的知识库构建包含三个紧密衔接的操作:加载文档 → 文本分割 → 向量化存储。LangChain 已经为每个环节提供了丰富的工具,大部分场景下我们只需要按需组合。

1. 加载 PDF,让文本“现形”

LangChain 内置了几十种文档加载器,PDF、Word、Markdown 甚至 Notion 都能一行加载。这里用最常见的 PyPDFLoader 来演示:

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("./data/产品手册.pdf")
docs = loader.load()
print(f"加载页数:{len(docs)}")

执行后,docs 里的每个元素都是一个 Document 对象,包含 page_content(该页提取出的纯文本)和 metadata(页码、文件路径等)。如果是扫描版 PDF,文本提取会失败,这时候你就需要先走 OCR 流程(可以配合 pytesseractUnstructured 等库),我们这里假设文档是原生可提取文字的。

2. 文本分割:平衡完整性与精准度

直接把整页内容扔给模型是不现实的,上下文窗口有限,而且粒度太粗检索效果会很差。我们需要把长文档切成若干个“语义块”。RecursiveCharacterTextSplitter 是一个对中文也很友好的分割器,它会按标点符号的优先级递归切割,尽量保持段落完整。

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,      # 每个文本块的目标最大字符数
    chunk_overlap=50,    # 相邻块之间的重叠字符数,防止关键句被拦腰截断
    separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""]
)
chunks = text_splitter.split_documents(docs)
print(f"切分后得到 {len(chunks)} 个文本块")

关于参数,我自己在中文文档上的习惯是:chunk_size 设为 300~800,具体看文档的句子长度;chunk_overlap 保留 10%~20% 的大小,保证断点处的语义连贯性。separators 列表最后的空字符串 "" 代表在无标点可用时最终按字符强制切割,避免卡死。切分之后你可以随机抽查几个块的内容,确认没有被莫名其妙截断,这是一个很实用的调试小技巧。

3. 向量化并持久化,让文本“可搜索”

文本块需要转换成向量才能进行语义相似度计算。这里使用 OpenAI 的 text-embedding-3-small 模型,它在成本与效果之间达到了很好的平衡。接着把所有向量和对应的原始文本存入 Chroma 向量库,并持久化到本地目录,这样下次启动就不用重复计算了。

from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db"   # 指定持久化路径,方便复用
)
print("向量库已构建并保存到 ./chroma_db")

之后如果要复用已有向量库,只需要两行代码:

vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)

到这里,你的私有知识库已经就绪,接下来我们把它和大模型对接起来。

三、用 LangChain 串联检索与生成

RAG 的核心在于“检索—增强—生成”这条流水线。LangChain 通过两条内置链让它变得极其简单:一条负责把检索到的文档塞进提示词并交给模型生成答案,另一条负责自动执行“用户提问 → 检索 → 生成”的全过程。

首先初始化大语言模型,这里选用响应快、成本低的 gpt-4o-minitemperature 设为 0 以保证回答稳定。

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

接下来是提示词模板。虽然 LangChain Hub 提供了现成的 RAG 提示词,但需要额外安装 langchainhub 且依赖网络。为了保持示例的独立性和可控性,我直接写了一个清晰实用的模板,你可以根据业务需求随时调整语气和指令。

from langchain.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_template("""
你是一个严谨的文档问答助手,只根据下面提供的上下文信息回答问题。
如果上下文中找不到答案,请如实回答“未找到相关信息”,不要编造。
上下文:
{context}

问题:{input}
回答:""")

然后构建两条链并组合:

from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains import create_retrieval_chain

# 文档组合链:将检索到的文档填入 prompt 并生成答案
combine_docs_chain = create_stuff_documents_chain(llm, prompt)

# 完整检索链:用户问题 → 检索器召回文档 → 交给组合链生成答案
retrieval_chain = create_retrieval_chain(
    retriever=vectorstore.as_retriever(search_kwargs={"k": 3}),
    combine_docs_chain=combine_docs_chain
)

search_kwargs={"k": 3} 表示每次从向量库中召回与问题最相似的前 3 个文本块。这个 k 值的设置很关键:太小可能丢失信息,太大会引入噪音且增加 token 消耗。建议初期先用 vectorstore.similarity_search("测试问题", k=5) 观察召回结果的相关性,再根据文档密度调整。3~5 通常是安全区间。

四、运行问答并返回溯源

整个链路搭建完成后,你就可以像聊天一样提问了。invoke 方法会依次执行检索和生成,最后把答案以及用到的源文档片段一起返回。

question = "产品支持的最高工作温度是多少?"
result = retrieval_chain.invoke({"input": question})

print("💡 回答:", result["answer"])
print("\n📄 参考来源:")
for i, doc in enumerate(result["context"]):
    # PyPDFLoader 的页码是从 0 开始的,这里加 1 让显示更符合习惯
    page = doc.metadata.get("page", 0) + 1
    snippet = doc.page_content[:120].replace("\n", " ")
    print(f"[{i+1}] 第{page}页:{snippet}……")

输出大致如下:

💡 回答: 产品支持的最高工作温度为85°C。

📄 参考来源:
[1] 第4页:……工作环境:温度 -20°C 至 +85°C,湿度 10%~90%(无凝露)……
[2] 第8页:……在高温(>85°C)环境下持续运行可能触发保护电路……

你看,模型不仅给出了准确的温度值,还直接引用了原文出处。这正是 RAG 最大的价值:让答案有据可查,大幅降低幻觉风险。如果你想要多轮对话能力,只需在构建链时注入 RunnableWithMessageHistoryConversationBufferMemory,就可以支持“那它的湿度范围呢?”这类连续追问。

五、完整代码与后续优化

下面是合并了所有步骤的完整可运行脚本,你只需修改 PDF 文件路径和 API Key,即可直接体验。

import os
os.environ["OPENAI_API_KEY"] = "sk-你的密钥"

from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_chroma import Chroma
from langchain.prompts import ChatPromptTemplate
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains import create_retrieval_chain

# 1. 加载 PDF
loader = PyPDFLoader("./data/产品手册.pdf")
docs = loader.load()

# 2. 分割文本
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500, chunk_overlap=50,
    separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""]
)
chunks = text_splitter.split_documents(docs)

# 3. 向量化并持久化存储
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(
    chunks, embeddings, persist_directory="./chroma_db"
)

# 4. 构建检索增强链
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt = ChatPromptTemplate.from_template("""
你是一个严谨的文档问答助手,只根据下面提供的上下文信息回答问题。
如果上下文中找不到答案,请如实回答“未找到相关信息”,不要编造。
上下文:
{context}

问题:{input}
回答:""")
combine_docs_chain = create_stuff_documents_chain(llm, prompt)
retrieval_chain = create_retrieval_chain(
    retriever=vectorstore.as_retriever(search_kwargs={"k": 3}),
    combine_docs_chain=combine_docs_chain
)

# 5. 提问并展示来源
question = "产品支持的最高工作温度是多少?"
result = retrieval_chain.invoke({"input": question})
print("💡 回答:", result["answer"])
print("\n📄 参考来源:")
for i, doc in enumerate(result["context"]):
    page = doc.metadata.get("page", 0) + 1
    snippet = doc.page_content[:120].replace("\n", " ")
    print(f"[{i+1}] 第{page}页:{snippet}……")

这个原型已经可以直接服务于一些内部知识库场景,但要达到生产级体验,还有几个值得深入的方向:

  • 混合检索:语义检索对同义词和模糊表达很有效,但可能会漏掉精确关键词匹配。结合 BM25 等传统检索方式,可以兼顾召回和准确。
  • 重排序(Re-ranking):检索出一批候选块后,用一个轻量的排序模型(如 Cohere Rerank 或 bge-reranker)做二次精选,把最相关的片段放到提示词的最前面。
  • 复杂文档解析:对于扫描件、表格、图文混排的 PDF,仅靠 PyPDFLoader 不够。可以引入 Unstructured 这类工具做版面分析,提取出更规范的文本。
  • 完全本地化部署:把 OpenAI 替换为通过 Ollama 运行的 Llama3,嵌入模型换成 HuggingFaceEmbeddings,就能实现数据完全不出本地的私有化 RAG。
  • 对话记忆:加上 RunnableWithMessageHistory,让系统记住之前的问题和检索结果,轻松处理“那它的电压范围呢?”这样的追问。

这篇文章从原理到代码,完整拆解了用 LangChain 搭建 RAG 文档问答系统的全过程。你可以直接拿这套骨架去改造企业知识库、客服助手等应用。实际动手过程中如果遇到坑或者有新想法,欢迎在评论区交流,我们一起完善它。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐