本文全程代码可复制,跟着敲一遍,半天搞定一个能读本地文档的 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 应用
本节默认你已经装好了
langchain、langchain-community、langchain-openai、chromadb这几个包。
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 |
| 已有 PostgreSQL | pgvector |
| 纯内存、最快 | 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-small比text-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(如 source、version),更新时按 metadata 删除旧的再加新的。
写在最后
RAG 这条路走下来,你会发现它 80% 的效果取决于工程细节——怎么切、怎么检索、怎么排序,而不是选哪个大模型。
这篇只讲了最经典的 RAG,其实还有很多有意思的进阶方向:
- Agentic RAG:让 LLM 自己决定要不要检索、检索几次
- GraphRAG:用知识图谱增强检索,适合关系型查询
- Multi-Modal RAG:支持图片、表格、图表的多模态检索
这些我会放在后续文章里慢慢展开,感兴趣的朋友可以点个关注不迷路 🫡
如果这篇文章对你有帮助,点赞 + 收藏 + 关注 三连走一波,这是我持续更新最大的动力。