LangChain + RAG 实战

36 阅读10分钟

本文全程代码可复制,跟着敲一遍,半天搞定一个能读本地文档的 AI 助手。看完记得点个赞,你的支持是我更新的动力 🌟

前言:为什么你需要 RAG?

先聊一个真实场景。

前段时间老板甩给我一个需求: "做个 AI 助手,能回答公司内部文档的问题,比如报销流程、请假规定、技术规范。"

我第一反应是——直接调 GPT-4 不就行了?结果测试完当场傻眼:

  • 😵 幻觉:问它公司的报销额度,它一本正经地编了一套
  • 🕰️ 知识截止:它根本不知道我们今年新出的 OKR 规范
  • 🔒 私有数据:公司文档它压根没见过

这三个硬伤,就是所有想把大模型落地到业务场景里的团队都绕不开的坑。

RAG(Retrieval-Augmented Generation,检索增强生成) 就是目前最主流、性价比最高的解法。

一句话解释 RAG:让大模型在回答问题前,先去知识库里"翻书",找到相关资料后再基于资料生成答案。 本质上就是让它从"闭卷考试"变成"开卷考试"。

而 LangChain 是目前生态最成熟的 RAG 框架,封装了加载、切分、向量化、检索、生成的全流程,不用自己造轮子。

这篇文章我会带你从 0 到 1 实现一个完整的 RAG 应用,所有代码都可以直接复制运行。准备好了吗?我们开始。


一、RAG 核心原理(5 分钟看懂)

在写代码之前,先花 5 分钟搞懂 RAG 到底在干嘛,后面调参才不会抓瞎。

1.1 RAG 工作流程

RAG 其实分两个阶段,我画个图你就懂了:

【离线阶段:构建知识库】
原始文档 → 切分成小块 → 向量化 → 存入向量数据库

【在线阶段:回答问题】
用户提问 → 问题向量化 → 在向量库中搜索相似内容
        → 拼接 Prompt(问题 + 检索到的内容)→ 大模型生成答案

1.2 三个关键环节

① 文档切分(Chunking)

为什么不能把整本书直接塞给大模型?两个原因:

  • 大模型有上下文长度限制(即使是 200K 的 Claude,塞满也很贵)
  • 内容太长,关键信息容易被稀释,模型抓不到重点

所以要把长文档切成一个个小块(chunk),通常每块 500-1000 字。

② 向量化(Embedding)

把文字变成一串数字(通常是几百到几千维的向量),这样计算机才能计算"相似度"。

比如"苹果公司"和"iPhone 制造商"在向量空间里会很接近,而"苹果公司"和"香蕉"就很远。

③ 检索(Retrieval)

用户提问时,把问题也转成向量,然后在向量库里找最相似的 Top-K 个 chunk,作为"参考资料"喂给大模型。

理解了这三步,下面的代码就是把这套流程用 LangChain 翻译一遍而已。


二、手把手实战:6 步搭建 RAG 应用

本节默认你已经装好了 langchainlangchain-communitylangchain-openaichromadb 这几个包。

Step 1:加载文档

LangChain 提供了几十种 Loader,PDF、Markdown、Word、网页、Notion 都能加载。这里以最常用的 PDF 为例:

from langchain_community.document_loaders import PyPDFLoader

# 加载单个 PDF
loader = PyPDFLoader("./docs/company_handbook.pdf")
documents = loader.load()

print(f"加载了 {len(documents)} 页")
print(documents[0].page_content[:200])  # 看看第一页前 200 字

如果要加载整个目录下的所有文档:

from langchain_community.document_loaders import DirectoryLoader

loader = DirectoryLoader(
    "./docs",
    glob="**/*.pdf",           # 递归匹配所有 PDF
    loader_cls=PyPDFLoader,
    show_progress=True,        # 显示进度条
)
documents = loader.load()

💡 小贴士:Markdown 文件用 UnstructuredMarkdownLoader,网页用 WebBaseLoader,想了解全部可选的 Loader 去官方文档搜 "Document Loaders"。

Step 2:文本切分

这一步最容易被忽略,但对最终效果影响巨大。切得太碎,上下文丢失;切得太大,检索不精准。

最常用的是 RecursiveCharacterTextSplitter,它会优先按段落切,段落太长再按句子切,以此类推:

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,          # 每块 500 字符
    chunk_overlap=50,        # 相邻块重叠 50 字符(避免关键信息被切断)
    separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],  # 中文优化
)

chunks = text_splitter.split_documents(documents)
print(f"切分成 {len(chunks)} 个 chunk")

⚠️ 踩坑提示:默认的 separators 是英文的,中文文档一定要自己传一份带中文标点的,否则切出来惨不忍睹。

Step 3:向量化 + 存入向量库

这一步把文本变成向量并存起来。向量库选型这里给个建议:

场景推荐
本地快速验证Chroma(零配置)
数据量大(百万级)Milvus、Qdrant
已有 PostgreSQLpgvector
纯内存、最快FAISS

先用 Chroma 跑通:

from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

# 初始化 Embedding 模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# 创建向量库并持久化到本地
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",   # 存到本地磁盘,下次不用重新向量化
)

下次使用直接加载:

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

💰 省钱提示:OpenAI 的 text-embedding-3-smalltext-embedding-ada-002 便宜 5 倍,效果还更好,无脑选它。国产模型可以用 BGE、M3E,本地部署免费。

Step 4:构建 Retriever

向量库转成 Retriever 只要一行:

retriever = vectorstore.as_retriever(
    search_type="similarity",        # 或者 "mmr"(兼顾相关性和多样性)
    search_kwargs={"k": 4},          # 返回 Top-4 相似片段
)

# 测试一下
docs = retriever.invoke("公司的年假有多少天?")
for doc in docs:
    print(doc.page_content[:100], "\n---")

关于 search_type

  • similarity:纯按相似度排序,最常用
  • mmr(Maximal Marginal Relevance):在相关的结果里选更多样的,避免 Top-K 都是重复内容

Step 5:组装 RAG Chain(LCEL 写法)

LangChain 0.1+ 推荐用 LCEL(LangChain Expression Language) 语法,用管道符 | 串起各个组件,可读性很高:

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

# 1. 定义 Prompt 模板
prompt = ChatPromptTemplate.from_template("""
你是一个严谨的 AI 助手。请基于下面提供的上下文回答用户问题。
如果上下文中没有相关信息,直接说"我不知道",不要编造。

上下文:
{context}

问题:{question}

答案:
""")

# 2. 初始化大模型
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# 3. 辅助函数:把检索到的 docs 拼成字符串
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

# 4. 用 LCEL 组装 Chain
rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

# 5. 调用
answer = rag_chain.invoke("公司的年假有多少天?")
print(answer)

这段代码看懂了,你就掌握了 LangChain 的精髓。本质上就是:把一个个"可运行单元"(Runnable)用管道符串起来,数据从左流到右。

Step 6:加上对话记忆,支持多轮聊天

上面的 Chain 只能回答单轮问题,实际场景用户会追问:

用户:公司年假多少天? AI:5 天。 用户:那病假呢? (← 这里需要记住前面在聊"假期")

解决办法是引入 History Aware Retriever——在检索前,先把问题结合历史对话重写一遍:

from langchain.chains import create_history_aware_retriever, create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import MessagesPlaceholder

# 1. 重写问题的 Prompt
contextualize_q_prompt = ChatPromptTemplate.from_messages([
    ("system", "基于聊天历史和最新问题,生成一个独立完整的问题(不用回答)。"),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
])

history_aware_retriever = create_history_aware_retriever(
    llm, retriever, contextualize_q_prompt
)

# 2. 回答问题的 Prompt
qa_prompt = ChatPromptTemplate.from_messages([
    ("system", "基于以下上下文回答问题:\n\n{context}"),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
])

question_answer_chain = create_stuff_documents_chain(llm, qa_prompt)

# 3. 组装成完整的对话链
rag_chain = create_retrieval_chain(history_aware_retriever, question_answer_chain)

# 4. 调用(手动维护 chat_history)
chat_history = []
response = rag_chain.invoke({
    "input": "公司年假多少天?",
    "chat_history": chat_history,
})
print(response["answer"])

# 把这一轮加入历史,再问下一个
from langchain_core.messages import HumanMessage, AIMessage
chat_history.extend([
    HumanMessage(content="公司年假多少天?"),
    AIMessage(content=response["answer"]),
])

response2 = rag_chain.invoke({
    "input": "那病假呢?",      # 它知道你在问"病假多少天"
    "chat_history": chat_history,
})
print(response2["answer"])

到这里,一个能读文档、支持多轮对话的 RAG 应用就完整了。


三、效果优化:从"能用"到"好用"

跑通 Demo 只是起点。真正上线你会发现各种问题:检索不准、答案重复、遇到复杂问题就翻车。下面几个优化手段按性价比排序。

3.1 Query Rewriting:让提问更"检索友好"

用户问"年假咋算",但文档里写的是"员工年度带薪休假计算方式"。直接拿原问题去检索,很可能匹配不上。

解法是在检索前让 LLM 先把问题改写成更规范的形式:

rewrite_prompt = ChatPromptTemplate.from_template(
    "把下面的口语化问题改写成更适合文档检索的书面形式:\n{question}"
)
rewrite_chain = rewrite_prompt | llm | StrOutputParser()

rewritten = rewrite_chain.invoke({"question": "年假咋算"})
# 输出: "员工年假的计算方式和天数规定是什么?"

3.2 Rerank:给检索结果精排

向量检索召回 Top-20 后,用一个更精细的 Cross-Encoder 模型 重新排序,取 Top-4。这一招能让答案质量明显提升。

from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder

model = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base")
compressor = CrossEncoderReranker(model=model, top_n=4)

compression_retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=vectorstore.as_retriever(search_kwargs={"k": 20}),
)

3.3 Hybrid Search:向量 + 关键词双路召回

纯向量检索有个弱点:对精确关键词(如型号、人名、专有名词)不敏感。

解法是同时跑一个 BM25 关键词检索,把两路结果融合:

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 4

ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vectorstore.as_retriever(search_kwargs={"k": 4})],
    weights=[0.3, 0.7],   # BM25 权重 0.3,向量检索权重 0.7
)

3.4 Chunk 策略调优

这是最"土"但最有效的优化。不同文档类型适合不同切分策略:

  • 技术文档 / API 文档:按标题层级切分(MarkdownHeaderTextSplitter
  • 法律 / 合同:按条款切,保留条款编号
  • 代码库:用 Language.PYTHON 这种语言特定的分割器
  • 常规文本chunk_size=500, chunk_overlap=50 是个安全起点

建议你针对自己的场景做个小实验:同样 10 个问题,用不同 chunk_size(300 / 500 / 800 / 1200)各跑一遍,人工打分看哪个效果最好。


四、踩坑记录(建议收藏)

这一节是我真金白银踩过的坑,帮你避雷。

坑 1:中文切分一片狼藉

默认 RecursiveCharacterTextSplitter 按英文标点切,中文文档可能一刀切在"不"和"要"中间。一定要传中文 separators(见 Step 2 代码)。

坑 2:Embedding 费用超预期

一次性向量化几万个 chunk,账单可能吓人。两个建议:

  • 本地模型(BGE、M3E)跑海量数据,OpenAI Embedding 只用于查询
  • 一定要 persist_directory 持久化,别每次启动都重新 Embedding

坑 3:Token 超限报错

检索到的 Top-K 太长,拼 Prompt 的时候把上下文窗口撑爆了。解法:

  • 减小 k
  • 用 Rerank 只留最相关的 3-4 段
  • 对长 chunk 做摘要再喂给 LLM

坑 4:答案"看起来对但细节错"

典型幻觉。Prompt 里一定要加一句: "如果上下文中没有明确信息,请回答'我不知道'。" 能减少 80% 的瞎编。

坑 5:向量库更新麻烦

Chroma 支持 add_documents 增量添加,但删除旧版本文档比较麻烦。建议给每个 chunk 加 metadata(如 sourceversion),更新时按 metadata 删除旧的再加新的。


写在最后

RAG 这条路走下来,你会发现它 80% 的效果取决于工程细节——怎么切、怎么检索、怎么排序,而不是选哪个大模型。

这篇只讲了最经典的 RAG,其实还有很多有意思的进阶方向:

  • Agentic RAG:让 LLM 自己决定要不要检索、检索几次
  • GraphRAG:用知识图谱增强检索,适合关系型查询
  • Multi-Modal RAG:支持图片、表格、图表的多模态检索

这些我会放在后续文章里慢慢展开,感兴趣的朋友可以点个关注不迷路 🫡


如果这篇文章对你有帮助,点赞 + 收藏 + 关注 三连走一波,这是我持续更新最大的动力。