LangChain 学习笔记(六):Retriever 与 RAG 实战,从检索到生成完整串起来
这是 LangChain 学习笔记系列的第六篇。前面几篇已经整理了模型调用、工具调用、结构化输出、Few-shot、文档加载、文本切分、Embedding、Redis 向量存储和 RediSearch Index。这一篇把这些组件串起来,重点讲 Retriever 和完整 RAG Chain:用户提问后,系统如何检索相关文档,再把文档交给模型生成答案。
1. 这一篇要解决什么问题
前面已经知道:
文档可以被切成 chunk; chunk 可以通过 embedding 变成向量; 向量和 metadata 可以存进 Redis / Chroma / FAISS / Pinecone; RediSearch Index 可以根据 query vector 找到相似文档。但真实应用里还差最后一步:
怎么把检索结果交给模型? 怎么组织 prompt? 怎么让模型只根据资料回答? 怎么把整条流程封装成一个可运行的 RAG chain?这一篇的主线是:
VectorStore -> Retriever -> format_docs -> Prompt -> ChatModel -> OutputParser -> final answer一句话:
Retriever 负责取资料,LLM 负责基于资料生成答案。2. 从第五篇衔接到这里
第五篇讲的是 Redis 向量存储:
Document -> text + metadata -> embedding -> Redis hash -> RediSearch Index -> similarity_search / MMR search也就是说,第五篇重点在:
数据怎么存,怎么搜。这一篇重点在:
搜出来之后,怎么交给模型回答。如果用一张流程图表示:
用户问题 | v Retriever 检索相关文档 | v format_docs 格式化上下文 | v PromptTemplate 组装问题和上下文 | v ChatModel 生成答案 | v OutputParser 提取结果3. Retriever 是什么
Retriever 可以理解成:
检索器。它的职责很单一:
输入一个查询字符串; 输出一组相关 Document。例如:
docs=retriever.invoke("数据库表怎么设计?")返回:
[Document(page_content="数据库:MySQL + MyBatis...",metadata={...}),Document(page_content="分库分表:按 user_id 哈希分库...",metadata={...}),]注意:
Retriever 不负责生成最终答案。 Retriever 只负责找资料。最终自然语言回答还是由模型完成。
4. VectorStore 和 Retriever 的区别
VectorStore 是向量存储。
它负责:
存向量; 存文本; 存 metadata; 提供相似性搜索、MMR 搜索等能力。Retriever 是对搜索能力的封装。
它负责:
把“根据 query 查文档”这件事包装成 Runnable。可以这样理解:
VectorStore 更像数据库; Retriever 更像查询接口。例如:
docs=vector_store.similarity_search("数据库表怎么设计?",k=4)这是直接调用向量库搜索。
而:
retriever=vector_store.as_retriever()docs=retriever.invoke("数据库表怎么设计?")是先把向量库转成 Retriever,再用标准 Runnable 方式调用。
5. 为什么 Retriever 也是 Runnable
LangChain 里很多组件都是 Runnable。
Retriever 也可以:
retriever.invoke("数据库表怎么设计?")这很重要,因为它能接入 LCEL:
retriever|format_docs意思是:
先检索文档; 再把文档格式化成字符串。这和前面学过的:
prompt|model|parser是同一套组合方式。
所以 Retriever 的价值不只是“能查文档”,而是:
它能作为 RAG chain 的一个标准组件参与编排。6.as_retriever基本用法
最简单写法:
retriever=vector_store.as_retriever()调用:
docs=retriever.invoke("数据库表怎么设计?")默认情况下,很多向量库会使用相似性搜索。
也就是:
把用户问题转成 query embedding; 在向量库里找最相似的 k 个 document embedding; 返回对应 Document。7.search_type是什么
as_retriever可以指定搜索类型:
retriever=vector_store.as_retriever(search_type="similarity",search_kwargs={"k":4})常见search_type:
similarity mmr similarity_score_threshold分别对应:
similarity: 返回最相似的 k 个文档。 mmr: 返回相关但尽量不重复的文档。 similarity_score_threshold: 只返回相似度达到阈值的文档。8. similarity 搜索
写法:
retriever=vector_store.as_retriever(search_type="similarity",search_kwargs={"k":4})含义:
找和 query 最相似的 4 个文档。适合:
问题明确; 知识库质量较好; 希望直接找最相关内容。缺点:
可能返回重复内容。比如用户问:
Redis 向量索引是什么?可能返回 4 个都在讲 RediSearch Index 的相似片段。
9. MMR 搜索
写法:
retriever=vector_store.as_retriever(search_type="mmr",search_kwargs={"k":4,"fetch_k":20,})含义:
先取 20 个相似候选; 再从里面选 4 个既相关又不重复的文档。fetch_k是候选池大小。
k是最终返回数量。
适合:
文档内容容易重复; 问题需要多个角度; 希望上下文覆盖更全面。比如问:
数据库表怎么设计?MMR 可能返回:
MySQL 表结构 分库分表 索引设计 数据访问层而不是只返回一堆 MySQL 表结构片段。
10. similarity_score_threshold 搜索
写法:
retriever=vector_store.as_retriever(search_type="similarity_score_threshold",search_kwargs={"score_threshold":0.75,"k":10,})含义:
只返回相似度达到阈值的文档。适合:
宁愿少返回,也不要返回明显不相关内容; 知识库里噪声较多; 希望降低幻觉风险。注意:
不同向量库的 score 方向可能不同。有些是:
分数越高越相似。有些是:
距离越小越相似。所以阈值不要凭空设,应该先观察实际返回结果。
11.search_kwargs常见参数
search_kwargs是传给底层搜索方法的参数。
常见:
k: 最终返回多少个文档。 fetch_k: MMR 初始候选数量。 score_threshold: 相似度阈值。 filter: metadata 过滤条件。例如:
retriever=vector_store.as_retriever(search_type="mmr",search_kwargs={"k":4,"fetch_k":20,"filter":filter_condition,})这表示:
先按 filter 限定文档范围; 再做 MMR 检索。12. metadata 过滤和 Retriever
如果向量库支持 metadata 过滤,可以把过滤条件放到search_kwargs里。
以 Redis / RedisVL 风格为例:
fromredisvl.query.filterimportTag,Num category_is_qa=Tag("category")=="QA"num_is_under_50=Num("num")<50filter_condition=category_is_qa&num_is_under_50 retriever=vector_store.as_retriever(search_type="mmr",search_kwargs={"k":4,"fetch_k":20,"filter":filter_condition,})意思是:
只在 category=QA 且 num<50 的文档中检索。metadata 过滤在真实项目里很重要。
比如:
只查某个用户有权限看的文档; 只查某个课程; 只查某类资料; 只查某个时间范围; 只查某个文件来源。13. 先单独测试 Retriever
在写完整 RAG chain 之前,建议先测试 Retriever。
docs=retriever.invoke("数据库表怎么设计?")fori,docinenumerate(docs):print("="*40)print("doc",i)print(doc.page_content[:300])print(doc.metadata)先确认:
能不能召回相关文档; metadata 是否完整; 返回数量是否合适; 是否有明显重复; 是否有无关结果。不要一上来就接 LLM。
因为如果最终回答不好,你要先判断:
是检索错了? 还是 prompt 没写好? 还是模型总结错了?Retriever 单独测试就是为了定位问题。
14. RAG Prompt 应该怎么写
RAG prompt 的核心是把:
用户问题 检索上下文 回答规则放在一起。
示例:
fromlangchain_core.promptsimportChatPromptTemplate prompt=ChatPromptTemplate.from_messages([("human","""你是负责回答问题的助手。 请只基于给定的上下文回答问题。 如果上下文中没有答案,就说不知道。 回答要简洁,不要编造。 问题: {question} 上下文: {context} 答案:""")])这里有几个关键约束:
只基于上下文回答; 没有答案就说不知道; 不要编造; 回答简洁。这能降低模型凭空发挥的概率。
15.format_docs是什么
Retriever 返回的是 Document 列表。
但 PromptTemplate 通常需要字符串。
所以要把 Document 列表格式化成字符串。
defformat_docs(docs):return"\n\n".join(doc.page_contentfordocindocs)比如 Retriever 返回:
[Document(page_content="数据库:MySQL + MyBatis...",metadata={...}),Document(page_content="分库分表:按 user_id 哈希分库...",metadata={...}),]format_docs后变成:
数据库:MySQL + MyBatis... 分库分表:按 user_id 哈希分库...然后填到 prompt 的:
{context}16. 带来源的format_docs
真实项目里,最好把来源也带上。
defformat_docs(docs):formatted=[]fori,docinenumerate(docs,1):source=doc.metadata.get("source","unknown")page=doc.metadata.get("page","")formatted.append(f"[资料{i}] 来源:{source}页码:{page}\n{doc.page_content}")return"\n\n".join(formatted)这样 prompt 里会有:
[资料1] 来源:redis-vector.md 页码:3 Redis 支持向量搜索...好处:
模型更容易引用来源; 用户更容易追溯答案; 调试时更容易知道召回了哪篇文档。17. RunnablePassthrough 是什么
RAG chain 里常见:
fromlangchain_core.runnablesimportRunnablePassthrough然后:
{"context":retriever|format_docs,"question":RunnablePassthrough()}这行最容易让人懵。
它的意思是:
同一个用户输入,要被分成两路处理。假设输入是:
"数据库表怎么设计?"第一路:
输入 -> retriever -> docs -> format_docs -> context第二路:
输入 -> RunnablePassthrough -> question也就是:
context 用来放检索资料; question 用来保留原始问题。18. 完整 RAG Chain
现在可以串完整链路:
fromlangchain_core.runnablesimportRunnablePassthroughfromlangchain_core.output_parsersimportStrOutputParserfromlangchain_core.promptsimportChatPromptTemplatefromlangchain_openaiimportChatOpenAI model=ChatOpenAI(model="gpt-4o-mini")prompt=ChatPromptTemplate.from_messages([("human","""你是负责回答问题的助手。 请只基于给定的上下文回答问题。 如果上下文中没有答案,就说不知道。 最多只用三句话,回答要简明扼要。 问题: {question} 上下文: {context} 答案:""")])defformat_docs(docs):return"\n\n".join(doc.page_contentfordocindocs)rag_chain=({"context":retriever|format_docs,"question":RunnablePassthrough(),}|prompt|model|StrOutputParser())answer=rag_chain.invoke("数据库表怎么设计?")print(answer)这条链完整执行顺序:
输入问题 -> retriever 检索相关文档 -> format_docs 把文档变成上下文字符串 -> RunnablePassthrough 保留原问题 -> prompt 组装 question + context -> model 生成答案 -> StrOutputParser 提取字符串19. 这条链里每一步的输入输出
假设输入:
数据库表怎么设计?第一步:
retriever.invoke("数据库表怎么设计?")输出:
[Document(page_content="数据库:MySQL + MyBatis...",metadata={...}),Document(page_content="分库分表:按 user_id 哈希分库...",metadata={...}),]第二步:
format_docs(docs)输出:
数据库:MySQL + MyBatis... 分库分表:按 user_id 哈希分库...第三步:
RunnablePassthrough()输出原问题:
数据库表怎么设计?第四步:
prompt.invoke({"question":"数据库表怎么设计?","context":"数据库:MySQL + MyBatis...\n\n分库分表..."})输出:
HumanMessage(...)第五步:
model.invoke(messages)输出:
AIMessage第六步:
StrOutputParser()输出:
最终字符串答案20. 如何保留检索到的来源
上面的rag_chain最终只返回字符串答案。
但真实项目里通常还想返回:
答案 引用文档 来源链接 页码最直接的方式是分两步写:
question="数据库表怎么设计?"docs=retriever.invoke(question)context=format_docs(docs)answer=(prompt|model|StrOutputParser()).invoke({"question":question,"context":context,})print(answer)fordocindocs:print(doc.metadata.get("source"))print(doc.metadata.get("page"))这样更容易调试,也更容易把来源展示到前端。
工程上不一定所有东西都要硬塞进一条 LCEL 链。
有时候分步骤写更清楚。
21. 流式输出 RAG 答案
如果希望模型边生成边显示,可以用:
forchunkinrag_chain.stream("数据库表怎么设计?"):print(chunk,end="",flush=True)这里的流式输出发生在模型生成阶段。
注意:
Retriever 通常不是流式的; 它会先一次性检索出文档; 然后模型再流式生成答案。所以用户体验上是:
先等待检索完成; 再看到模型逐字/逐块输出。22. Web 项目里的 SSE
如果接前端页面,可以用 SSE。
流程是:
后端 rag_chain.stream() -> 一边收到 chunk -> 一边通过 SSE 推给浏览器 -> 前端实时显示答案关系是:
LangChain stream: 后端从 chain 里流式拿结果。 SSE: 后端把结果流式推给前端。它们不是同一个东西,但经常一起用。
23. LangSmith 怎么调试 RAG
RAG 出问题时,不能只看最终答案。
要看每一步:
用户问题是什么? Retriever 检索到了哪些文档? 文档内容是否相关? metadata 是否正确? prompt 最终长什么样? 模型是否遵守“只基于上下文回答”? token 消耗多少? 哪一步耗时最多?LangSmith 可以追踪整条链路。
比如:
retriever step: 输入 query,输出 docs。 prompt step: 输入 question/context,输出 messages。 model step: 输入 messages,输出 AIMessage。 parser step: 输入 AIMessage,输出字符串。这对定位问题很关键。
24. RAG 常见问题:检索不到正确文档
如果回答不对,第一步先看 Retriever。
可能原因:
文档切分不合理; embedding 模型不适合; query 表达和文档表达差距太大; k 太小; metadata filter 太严格; 向量库里根本没有相关内容; 文档入库时 source 或 category 搞错; 中文标点/换行导致切分效果差。排查方式:
docs=retriever.invoke(question)fordocindocs:print(doc.page_content)print(doc.metadata)先确认检索质量,再看模型回答。
25. RAG 常见问题:检索到了但模型没用上
如果检索结果是对的,但模型回答还是不准确,可能是 prompt 问题。
常见原因:
context 太长,重点被淹没; prompt 没明确要求基于上下文; 没有要求不知道就说不知道; 资料格式太乱; 多个 chunk 之间顺序混乱; 模型被问题里的错误假设带偏。可以优化:
压缩 context; 给每段资料编号; 要求引用资料编号; 明确“不得使用上下文之外的信息”; 必要时先 rerank 再生成。26. RAG 常见问题:返回内容重复
如果返回的 chunk 很重复,可以考虑:
使用 MMR; 调大 fetch_k; 调小 chunk_overlap; 对入库文档去重; 减少相似重复 chunk; 增加 metadata 过滤。例如:
retriever=vector_store.as_retriever(search_type="mmr",search_kwargs={"k":4,"fetch_k":20,})27. RAG 常见问题:回答出现幻觉
如果模型编造内容,可以从几个方向处理:
prompt 明确只基于上下文; 上下文不足时回答不知道; 提高检索质量; 返回来源并要求引用; 使用 similarity_score_threshold; 对低置信度问题拒答; 不要把无关文档塞进 context。RAG 不能完全消灭幻觉,但可以显著降低。
前提是:
检索到的上下文足够准确。28. RAG 参数怎么调
常见调参思路:
chunk_size: 影响文档粒度。 chunk_overlap: 影响边界语义保留和重复率。 k: 影响上下文数量。 fetch_k: 影响 MMR 候选池大小。 score_threshold: 影响结果相关性门槛。 prompt: 影响模型是否忠于上下文。不要只调一个参数。
建议按顺序排查:
1. 文档解析是否完整 2. chunk 是否合理 3. retriever 是否召回正确内容 4. prompt 是否清楚 5. model 是否适合29. 一个完整 RAG 应用可以长什么样
以课程资料问答系统为例:
离线入库阶段: PDF / Markdown -> Loader -> Document -> Splitter -> chunks -> Embedding -> VectorStore在线查询阶段: 用户问题 -> Retriever -> docs -> format_docs -> Prompt -> LLM -> Answer -> Sources如果再加 Web:
FastAPI 接收问题 -> RAG Chain -> stream 输出 -> SSE 推给前端 -> 前端展示答案和来源如果再加调试:
LangSmith 记录每一步输入输出这就是一个完整的 RAG 应用闭环。
30. 容易混淆的点
30.1 Retriever 会生成答案吗
不会。
Retriever 只检索文档。 LLM 才生成答案。30.2 VectorStore 和 Retriever 是一个东西吗
不是。
VectorStore 是存储和搜索能力。 Retriever 是把搜索能力封装成 Runnable。30.3 RunnablePassthrough 是不是没用
不是。
它在 RAG chain 里负责保留原始问题。
同一个输入会同时走两路:
一路去检索 context; 一路保留为 question。30.4 format_docs 是不是必须
不一定叫这个名字,但需要这个步骤。
因为:
Retriever 返回 Document 列表; Prompt 需要字符串 context。所以需要把 docs 转成文本。
30.5 RAG 有了就不会幻觉吗
不会。
RAG 只是给模型提供外部上下文。
如果:
检索错了; 上下文不完整; prompt 不严格; 模型不遵守上下文;仍然可能幻觉。
31. 工程实践:先检索评估,再生成优化
很多 RAG 项目一开始就盯着最终回答。
但更有效的方式是拆开评估:
第一步:评估 Retriever 问题能不能召回正确文档? 第二步:评估 Prompt 正确文档给到模型后,模型能不能答对? 第三步:评估最终链路 检索 + 生成整体效果如何?也就是说,RAG 调优不要一锅端。
应该分层看:
入库质量 检索质量 生成质量 最终体验如果检索已经错了,后面的模型再强也很难稳定答对。
32. 本篇总结
这一篇把完整 RAG 查询链路串起来了。
核心流程:
用户问题 -> Retriever 检索 Document -> format_docs 格式化上下文 -> Prompt 组合 question 和 context -> ChatModel 基于上下文生成答案 -> OutputParser 提取最终字符串几个关键结论:
Retriever 输入 query,输出 Document 列表。 VectorStore 可以通过 as_retriever 变成 Retriever。 search_type 控制检索策略。 similarity 返回最相似文档。 MMR 返回相关但不重复的文档。 similarity_score_threshold 可以过滤低相关文档。 RunnablePassthrough 用于保留原始输入。 format_docs 把 Document 列表转成 prompt 可用的 context。 LangSmith 能帮助调试 RAG 每一步。 RAG 效果要分层排查:入库、检索、生成。一句话概括:
RAG 的本质不是让模型记住知识,而是在回答前帮模型找对资料,再让模型基于资料组织答案。33. 系列总结:从调用模型到构建 RAG 应用
到第六篇为止,这个系列已经形成一条完整路线:
第一篇: ChatModel、Message、PromptTemplate,解决如何组织输入并调用模型。 第二篇: Tool Calling、ToolMessage、结构化输出,解决模型如何连接外部工具和稳定返回对象。 第三篇: Few-shot、示例选择器、RAG 入门,解决如何给模型补充示例和外部知识。 第四篇: 文档加载、文本切分、Embedding,解决外部知识如何被加工成可检索内容。 第五篇: Redis 向量存储、RediSearch Index,解决向量和 metadata 如何被存储和搜索。 第六篇: Retriever 与 RAG Chain,解决如何从检索走到最终生成答案。对应到一个真实项目:
文档导入 -> 文本切分 -> 向量化 -> 向量存储 -> Retriever -> RAG Chain -> 流式问答 -> 来源追踪 -> LangSmith 调试这已经是一个比较完整的 LangChain RAG 应用开发闭环。
34. 下一步可以做什么
接下来最适合进入项目实战。
比如:
基于 LangChain 的课程资料 RAG 问答系统可以实现:
PDF / Markdown 文档导入; RecursiveCharacterTextSplitter 切分; OpenAIEmbeddings 向量化; Redis / Chroma / FAISS 存储; Retriever 检索; MMR 去重; ChatPromptTemplate 组织上下文; ChatOpenAI 生成答案; StrOutputParser 输出结果; stream / SSE 流式返回; LangSmith 调试链路; 答案展示来源。这个项目能把前六篇的内容全部串起来。
相比单独说“学过 LangChain”,更有说服力的是:
我基于 LangChain 实现了一个课程资料 RAG 问答系统,支持文档解析、文本切分、向量检索、MMR 去重、流式输出、来源追踪和链路调试。这就是从学习笔记走向项目能力的下一步。
当然,也可以将自己之前学过的其他技术栈结合起来,实现一个集合多个技术栈加上这个LangChain的agent相关技术,组合成的一个新项目。