第四章 · RAG 检索增强生成 —— 让模型看着你的文档回答
系列:LangChain 入门教学及零基础面试常问题目
本章定位:解决"模型不知道你的私有文档"和"知识有截止日期"两大痛点。
〇、写在前面
LLM 的知识止于训练截止日期,更读不到你的私有文档;而把整本文档塞进提示词,既会超出上下文窗口又贵得离谱。RAG(Retrieval-Augmented Generation,检索增强生成) 的解法是"先检索、再生成":提前把文档切成小块并向量化入库,提问时只把最相关的几个片段动态塞进提示词。
相比微调,RAG 有三个优势:
- 成本低:不需要重新训练模型,只做一次离线索引;
- 知识可随时更新:换一份文档、重建一次向量库即可;
- 能给出答案出处:命中片段可以回传前端做溯源展示。
本章用四步搭起一条标准 RAG 流水线:切分 → 向量化建库 → 检索 → 生成。其中检索结果如何拼进提示词,正是第二章 RunnablePassthrough 的真实用武之地。
一、概念铺垫:RAG 在做什么?
1.1 一句话定位
RAG = 检索(Retrieval)+ 生成(Generation)。先从向量库里检索出与问题最相关的几个文档片段,再把这些片段塞进提示词让 LLM 据此作答。
1.2 四步流水线全景
file_content(原始长文档) question(用户提问)
│ ① RecursiveCharacterTextSplitter 切分 │
▼ │
list[Document] × N 块(chunk_size / chunk_overlap) │
│ ② OpenAIEmbeddings 逐块向量化 │ 用同一 embedding 模型向量化
▼ ▼
FAISS 向量库 ◀──── ③ as_retriever(k=top_k) 相似度检索(L2 距离,越小越相关)
│
▼ 命中 top_k 个片段
④ stuff 策略:全部片段填入 {context} ──▶ RAG_PROMPT ──▶ ChatOpenAI ──▶ answer
两个阶段:
- 离线索引(前两步):一份文档只需做一次,产出向量库;
- 在线问答(后两步):每次提问都会走,检索 + 生成。
本项目为教学方便每次请求都重建向量库,模块十会演示按文档指纹做缓存的生产做法。
1.3 关键认知:embedding 与检索
- 嵌入(embedding) 把文本映射成高维向量,语义相近的文本向量距离更近;
- 检索就是在向量空间里找最近邻;
- FAISS 返回的分数是 L2 距离,越小越相关(本章最容易踩的坑,后面会反复强调)。
1.4 在 LangChain 生态中的位置
- 文本切分器在独立包 langchain_text_splitters;
- FAISS 封装在 langchain_community.vectorstores;
- 两个链工厂函数在主包 langchain.chains.combine_documents / langchain.chains.retrieval;
- 关键:as_retriever() 返回的检索器本身实现了 Runnable 协议 —— 第二章学的管道符可以直接把它接进链里,示例 3 会用纯 LCEL 手写一条等价 RAG 链。
二、分步教学:四步搭起 RAG 流水线
第 1 步 · 切分:RecursiveCharacterTextSplitter
「递归字符切分器」是最常用的切分器。它按 separators 的优先级顺序递归尝试:先按段落(\\n\\n)切,切出来的块还超过 chunk_size 就退一级按行切,再退按句号切……直到满足长度,尽量在语义边界断开而不是硬切。
# 库来源:pip install langchain-text-splitters
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 每块目标长度(按字符数)
chunk_overlap=50, # 相邻块重叠长度,保住边界语义
separators=["\\n\\n", "\\n", "。", "!", "?", ";", ",", " ", ""],
)
docs = splitter.create_documents([file_content]) # -> list[Document]
print(len(docs), docs[0].page_content[:50])
要点:
- chunk_size:每块目标字符数。太小丢上下文,太大混入无关内容;
- chunk_overlap:相邻块首尾重叠一段,句子恰好跨块时两边都能保留完整语义;
- separators:递归尝试的分隔符优先级。默认表面向英文,中文文档必须补「。!?;,」等中文标点(本项目已内置);
- create_documents(list[str]) -> list[Document];Document 有 page_content(正文)与 metadata 两个字段。
中文文档必看:默认 separators 是 ["\\n\\n", "\\n", " ", ""],中文整段通常没有空格,会退化到按字符硬切,句子被随意截断。自己搭 RAG 时记得带上中文标点。
第 2 步 · 向量化 + 建库:OpenAIEmbeddings + FAISS.from_documents
嵌入模型把每个文本块变成一个高维向量;FAISS 在内存里为这些向量建立索引,支持毫秒级最近邻搜索。FAISS.from_documents(docs, embeddings) 一行完成"逐块调 embedding 接口 + 建索引"两件事。
# 库来源:pip install langchain-openai langchain-community faiss-cpu
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
embeddings = OpenAIEmbeddings(
model="text-embedding-3-small", # 本站前端 config.embedding_model
api_key="sk-…",
check_embedding_ctx_length=False, # 跳过 tiktoken 预分词,兼容非官方端点
)
# 一步完成「逐块调 embedding 接口 + 建立 FAISS 索引」,文档多时这里最耗时
vectorstore = FAISS.from_documents(docs, embeddings)
要点:
- 聊天模型和向量模型是两个不同的模型,本项目分别对应 config.model_name 与 config.embedding_model;
- check_embedding_ctx_length=False:跳过 tiktoken 预分词直接发原文,绝大多数非 OpenAI 官方的兼容接口都需要它;
- from_documents 内部逐块调 /embeddings 接口再建索引 —— 嵌入调用次数 = 块数,块越多越慢越贵;
- 换服务商时 embedding 模型命名不同(如 DashScope 的 text-embedding-v3),检索与建库必须用同一个模型。
第 3 步 · 检索:as_retriever + 带分数检索
as_retriever() 把向量库适配成一个检索器 Runnable:invoke(问题字符串) 返回 list[Document],search_kwargs={"k": top_k} 控制召回条数。因为它是 Runnable,后面既能交给官方链工厂,也能直接接进 LCEL 管道。
# 检索器:把向量库适配成 Runnable —— invoke(str) -> list[Document]
retriever = vectorstore.as_retriever(search_kwargs={"k": top_k})
# 带分数检索一次,用于前端「中间过程」展示
# ⚠️ FAISS 的 score 是 L2 距离:越小越相关,不是越大越好!
scored = vectorstore.similarity_search_with_score(question, k=top_k)
for doc, score in scored:
print(round(score, 4), doc.page_content[:30])
要点:
- search_kwargs.k:召回片段条数(LangChain 默认 4,本项目由 top_k 参数传入);
- retriever 实现 Runnable 协议:invoke(str) -> list[Document],因此可直接进 LCEL 管道(见示例 3);
- score 为 FAISS L2 距离,越小越相关;若换 Chroma 或归一化相似度,分数方向可能相反,写通用代码前先确认语义。
第 4 步 · 生成:create_stuff_documents_chain + create_retrieval_chain
两个链工厂各管一半:
- create_stuff_documents_chain(model, prompt):把命中片段格式化后填入提示词的 {context} 变量再调 LLM(stuff = 全部塞入);
- create_retrieval_chain(retriever, doc_chain):先拿 {"input": 问题} 去检索,再把结果交给前者。
from langchain_core.prompts import ChatPromptTemplate
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains.retrieval import create_retrieval_chain
# stuff 策略:把检索到的所有片段一次性「塞」进 {context} 占位符
RAG_PROMPT = ChatPromptTemplate.from_messages([
("system",
"你是一个严谨的问答助手。仅根据下面提供的上下文回答问题;"
"如果上下文中找不到答案,就直说「根据文档无法回答」,不要编造。\\n\\n"
"【上下文】\\n{context}"),
("human", "{input}"),
])
doc_chain = create_stuff_documents_chain(model, RAG_PROMPT) # 格式化片段 + 调 LLM
rag_chain = create_retrieval_chain(retriever, doc_chain) # 先检索,再交给 doc_chain
result = rag_chain.invoke({"input": question})
result["answer"] # 最终答案(str)
result["context"] # 本次命中的 Document 列表(可做溯源展示)
要点:
- 提示词必须包含 {context} 变量 —— create_stuff_documents_chain 默认把片段列表格式化后填进这个名字;
- 输入键固定叫 input(与 RAG_PROMPT 的 {input} 对应);
- 输出 dict 含 input / context / answer 三个键;
- "仅根据上下文回答,找不到答案就直说"这句约束,是抑制幻觉的关键。
三、四个进阶示例
示例 1 · 完整四步 RAG 流程(基础)
从原始文档到答案的最小完整实现:切分 → 建库 → 检索器 → 问答链。
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import FAISS
from langchain_core.prompts import ChatPromptTemplate
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains.retrieval import create_retrieval_chain
model = ChatOpenAI(model="deepseek-chat", api_key="sk-…",
base_url="https://api.deepseek.com/v1")
embeddings = OpenAIEmbeddings(model="text-embedding-3-small", api_key="sk-…",
check_embedding_ctx_length=False)
# ① 切分 → ② 向量化建库 → ③ 检索器 → ④ 问答链
docs = RecursiveCharacterTextSplitter(
chunk_size=500, chunk_overlap=50,
separators=["\\n\\n", "\\n", "。", "!", "?", ";", ",", " ", ""],
).create_documents([file_content])
vectorstore = FAISS.from_documents(docs, embeddings)
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
qa_prompt = ChatPromptTemplate.from_messages([
("system", "仅根据上下文回答;没有答案就说不知道。\\n\\n上下文:\\n{context}"),
("human", "{input}"),
])
rag_chain = create_retrieval_chain(retriever,
create_stuff_documents_chain(model, qa_prompt))
print(rag_chain.invoke({"input": "FAISS 的分数越大越相关吗?"})["answer"])
要点:
- 接非 OpenAI 官方的兼容 embedding 端点时,check_embedding_ctx_length=False 几乎是必开的;
- 文档多时 from_documents 最耗时 —— 每块都要调一次 embedding 接口;
- {context} 变量名固定,create_stuff_documents_chain 按这个名字填入片段;
- 输入键必须叫 input;返回 dict 里取 answer 是答案、取 context 是命中片段。
示例 2 · 切分参数对比实验(进阶)
chunk_size 是 RAG 调优的第一个旋钮。小块(200)定位精准、命中距离更小,但单块信息量少,论述可能被截断;大块(800)上下文完整,但容易夹带无关内容、稀释相关度。
# 同一份文档、两组切分参数,对比块数与最优命中距离
for size, overlap in [(200, 20), (800, 80)]:
splitter = RecursiveCharacterTextSplitter(
chunk_size=size, chunk_overlap=overlap,
separators=["\\n\\n", "\\n", "。", "!", "?", ";", ",", " ", ""],
)
docs = splitter.create_documents([file_content])
vs = FAISS.from_documents(docs, embeddings)
hits = vs.similarity_search_with_score(question, k=3)
print(f"chunk_size={size}:共 {len(docs)} 块,最优 L2 距离 {hits[0][1]:.4f}")
要点:
- overlap 跟着 chunk_size 联动(约 10%),单改一个变量才能归因;
- hits 是 [(Document, score), …],score 为 L2 距离;
- 对比距离时记住方向:L2 距离越小越相关;不同 chunk_size 下的距离只能定性比较,不能跨库精确比较。
经验值:FAQ / 条款类文档 200~300;连贯论述类 500~800;代码文档按函数/类边界切更好(可用语言专用切分器)。
示例 3 · 纯 LCEL 手写 RAG 链(进阶)
create_retrieval_chain 不是魔法 —— 用第二章的 LCEL 积木完全可以手写一条等价链。dict 字面量会自动升级为 RunnableParallel:context 分支走检索 + 格式化,question 分支用 RunnablePassthrough 原样透传,两路结果合成一个 dict 喂给提示词。
# 文档演示:用纯 LCEL 手写一条等价的 RAG 链
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
def format_docs(docs):
return "\\n\\n".join(d.page_content for d in docs)
prompt = ChatPromptTemplate.from_template(
"仅根据以下上下文回答问题。\\n\\n上下文:\\n{context}\\n\\n问题:{question}"
)
# dict 字面量自动升级为 RunnableParallel:两个分支并行计算,结果合成一个 dict
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| model
| StrOutputParser()
)
rag_chain.invoke("LangGraph 适合什么场景?") # 注意:入参是裸字符串,不是 dict
要点:
- retriever 本身是 Runnable,所以能直接写 retriever | format_docs;普通函数 format_docs 会被自动包成 RunnableLambda;
- RunnablePassthrough() 把整条输入(问题字符串)原样传给 question 键;
- 两种写法的输出不同:手写版返回裸 str;create_retrieval_chain 返回含 answer / context 的 dict,自带溯源信息。
怎么选:要快速搭建、需要 context 溯源 → 官方链工厂;要自由改造(如在 format_docs 里给每段加来源编号)→ 手写 LCEL。两者数据流完全一致。
示例 4 · MMR 与相似度阈值 —— 更聪明的检索策略(亮点)
as_retriever 的 search_type 还有两个进阶选项:
# 文档演示:两种更聪明的检索策略(本项目后端用的是默认 similarity)
# ① MMR 最大边际相关:先多召回、再去冗余,避免 k 条片段高度雷同
retriever = vectorstore.as_retriever(
search_type="mmr",
search_kwargs={"k": 3, "fetch_k": 12}, # 先取 12 条候选,再挑互补的 3 条
)
# ② 相似度阈值过滤:相关度不达标的片段一条也不要
retriever = vectorstore.as_retriever(
search_type="similarity_score_threshold",
search_kwargs={"score_threshold": 0.5, "k": 5},
)
要点:
- search_type='mmr':在相关度与多样性之间做权衡,适合文档里同一件事被反复表述的场景;
- fetch_k:MMR 的候选池大小,一般取 k 的 3~5 倍;
- score_threshold 用的是 0~1 归一化相似度(越大越相关)—— 方向与 similarity_search_with_score 的 L2 距离正好相反!
stuff 策略的极限:top_k × chunk_size 逼近模型上下文窗口时答案会退化甚至报错。长文档场景可借鉴 map_reduce 思想 —— 先对每个片段分别提炼(map),再汇总多个提炼结果作答(reduce),代价是成倍的 LLM 调用。
四、参数速查表
切分与检索关键参数
| chunk_size | int | 500(本站) | 每块目标字符数,决定片段粒度 | FAQ/条款 200~300;长论述 500~800;本站后端校验范围 50~2000 |
| chunk_overlap | int | 50(本站) | 相邻块重叠字符数,防止语义在边界被切断 | 常用 chunk_size 的 10%~20%;必须 ≥0 且小于 chunk_size |
| separators | list[str] | ["\\n\\n", "\\n", " ", ""] | 递归切分的分隔符优先级 | 中文文档务必补「。!?;,」等中文标点(本站已内置) |
| k(top_k) | int | 4(LangChain)/ 3(本站) | 检索召回的片段条数 | 答案分散在多处 → 调大;提示词超长 / 噪音多 → 调小;本站校验范围 1~10 |
| search_type | str | "similarity" | 检索策略:similarity / mmr / similarity_score_threshold | 片段互相雷同 → mmr;想拒绝弱相关 → 阈值过滤 |
| embedding_model | str | text-embedding-3-small | 向量化模型,决定"语义相近"的判断质量 | 换服务商时必改,如 DashScope 的 text-embedding-v3;检索与建库必须用同一个模型 |
rag_chain(create_retrieval_chain)输入 / 输出契约
| input | 输入 | str | 用户问题;键名固定,与提示词的 {input} 一一对应 |
| input | 输出 | str | 原样回传的问题,方便一并展示 |
| context | 输出 | list[Document] | 本次检索命中的片段 —— 做"答案溯源"展示就靠它 |
| answer | 输出 | str | doc_chain 依据 context 生成的最终答案 |
五、常见坑 & 最佳实践
⚠️ 坑 1 · score 越小越相关 —— 别把排序做反了
FAISS 的 similarity_search_with_score 返回的是 L2 距离:0 表示几乎相同,越大越不相关。本项目响应里 hits[].score 就是这个值,后端还专门在顶层放了 score_note(“FAISS 返回 L2 距离,数值越小越相关”)提醒方向。
而 search_type="similarity_score_threshold" 和不少其他向量库(如部分 Chroma 配置)用的是归一化相似度,越大越相关 —— 方向完全相反。写通用代码前,先打印一条已知最相关的样本确认分数语义。
⚠️ 坑 2 · chunk_overlap=0:语义在块边界被拦腰切断
句子恰好跨越两块时,两块各拿半句,哪一块单独看都残缺,向量都"不像"原句,检索双双落空。建议 overlap 取 chunk_size 的 10%~20%。
反向的坑也有:overlap ≥ chunk_size 会让切分陷入无意义重复,本项目后端对此直接返回 400(bad_chunk_overlap)。
⚠️ 坑 3 · embedding 模型没配 / 服务商不支持
聊天模型和向量模型是两个模型:本模块除 config.model_name 外还需要 config.embedding_model(常见如 OpenAI 的 text-embedding-3-small、DashScope 的 text-embedding-v3)。
留空时本项目默认用 text-embedding-3-small —— 如果你的 base_url 服务商没有这个模型或根本不提供 /embeddings 端点,会在"向量化建库"一步报 404/400。换服务商时,请先确认它的 embedding 模型名。
💡 小贴士 · 中文文档切分:separators 必须补中文标点
RecursiveCharacterTextSplitter 的默认 separators 是 ["\\n\\n", "\\n", " ", ""] —— 完全面向英文。中文整段通常没有空格,退化到按字符硬切,句子被随意截断。
本项目后端内置了含「。!?;,」的中文分隔符表(见第 1 步代码),自己搭 RAG 时记得带上。
✅ 最佳实践 · top_k 与 chunk_size 联动着调
stuff 策略下,提示词里的上下文体积 ≈ top_k × chunk_size。把 chunk_size 拉到 800 又把 top_k 调到 8,上下文就有 6400 字,容易逼近小模型的窗口上限、稀释注意力。
调优顺序:先固定 top_k=3 调 chunk_size,再按答案覆盖度微调 top_k;真的很长再考虑 map_reduce 思想(见示例 4 note)。
六、配套后端源码
下面是本项目 backend/routers/rag.py 的核心实现
router = APIRouter(prefix="/api", tags=["模块四 · RAG 检索增强生成"])
class RagRequest(BaseModel):
file_content: DocumentText
question: NonEmptyText
chunk_size: int = Field(default=500, ge=50, le=2000) # 每块目标字符数
chunk_overlap: int = Field(default=50, ge=0, le=1999) # 相邻块重叠字符数
top_k: int = Field(default=3, ge=1, le=10) # 检索返回的片段数
config: LLMConfig
# stuff 策略:把检索到的所有片段一次性「塞」进 {context} 占位符
RAG_PROMPT = ChatPromptTemplate.from_messages([
("system",
"你是一个严谨的问答助手。仅根据下面提供的上下文回答问题;"
"如果上下文中找不到答案,就直说「根据文档无法回答」,不要编造。\\n\\n"
"【上下文】\\n{context}"),
("human", "{input}"),
])
@router.post("/rag")
def rag(req: RagRequest):
# ———- 参数校验 ———-
if len(req.file_content.encode("utf-8")) > MAX_DOC_BYTES:
raise ApiError(400, "doc_too_large", "文档过大,超过 1MB 限制。", "请截取需要提问的部分再上传。")
if not req.file_content.strip():
raise ApiError(400, "empty_doc", "文档内容为空。", "请先粘贴或上传一段文本。")
if not (50 <= req.chunk_size <= 2000):
raise ApiError(400, "bad_chunk_size", "chunk_size 需在 50~2000 之间。", "教学演示建议 200~800。")
if not (0 <= req.chunk_overlap < req.chunk_size):
raise ApiError(400, "bad_chunk_overlap", "chunk_overlap 必须小于 chunk_size 且不为负。",
"常用取值为 chunk_size 的 10%~20%。")
if not (1 <= req.top_k <= 10):
raise ApiError(400, "bad_top_k", "top_k 需在 1~10 之间。", "")
model = build_chat_model(req.config)
# ———- 第 1 步:切分 ———-
# separators 按优先级尝试:先按段落,再按行,再按中文句读,最后逐字符兜底
splitter = RecursiveCharacterTextSplitter(
chunk_size=req.chunk_size,
chunk_overlap=req.chunk_overlap,
separators=["\\n\\n", "\\n", "。", "!", "?", ";", ",", " ", ""],
)
docs = splitter.create_documents([req.file_content]) # List[Document]
# ———- 第 2 步:向量化 + 建库 ———-
embeddings = build_embeddings(req.config)
vectorstore = llm_step(lambda: FAISS.from_documents(docs, embeddings), "向量化(Embeddings)")
# ———- 第 3 步:一次检索同时提供文档和分数 ———-
# 检索结果只算一次,避免问答链和响应预览分别重复调用 embeddings。
retrieval_cache: dict[str, list] = {}
def retrieve_with_scores(value):
question = value["input"] if isinstance(value, dict) else value
scored = retrieval_cache.get(question)
if scored is None:
scored = llm_step(
lambda: vectorstore.similarity_search_with_score(question, k=req.top_k),
"相似度检索",
)
retrieval_cache[question] = scored
return [document for document, _ in scored]
retriever = RunnableLambda(retrieve_with_scores)
# ———- 第 4 步:问答链 ———-
doc_chain = create_stuff_documents_chain(model, RAG_PROMPT)
rag_chain = create_retrieval_chain(retriever, doc_chain)
result = llm_step(lambda: rag_chain.invoke({"input": req.question}), "RAG 链调用")
# ———- 命中片段(带分数,供「中间过程」视图) ———-
# FAISS 返回 L2 距离:数值越小越相关(注意不是越大越好)
scored = retrieval_cache.get(req.question, [])
hits = [
{"rank": i + 1, "content": d.page_content, "score": round(float(s), 4)}
for i, (d, s) in enumerate(scored)
]
return {
"answer": result["answer"],
"hits": hits,
"score_note": "FAISS 返回 L2 距离,数值越小越相关",
"chunk_previews": [d.page_content[:80] for d in docs[:8]],
"annotations": {
"chunk_count": len(docs),
"hit_count": len(hits),
"chunk_size": req.chunk_size,
"chunk_overlap": req.chunk_overlap,
"top_k": req.top_k,
"embedding_model": req.config.embedding_model,
},
}
实现亮点:后端用 retrieval_cache 把"问答链检索"和"带分数检索"合并成一次,避免重复调用 embeddings 接口。检索器用 RunnableLambda 包装自定义函数,既复用了缓存,又保留了 Runnable 协议可被 create_retrieval_chain 接受。
七、零基础面试常问题目(附参考答案)
Q1 · 什么是 RAG?和微调相比有什么优缺点?
参考答案:RAG(Retrieval-Augmented Generation,检索增强生成)= 先检索 + 再生成。提前把文档切成小块并向量化入库,提问时只把最相关的几个片段塞进提示词让 LLM 据此作答。
与微调相比:
| 成本 | 低,只做一次离线索引 | 高,需要训练算力 + 标注数据 |
| 知识更新 | 换文档、重建向量库即可,分钟级 | 重新微调,天级 |
| 出处溯源 | 能给出命中片段 | 无法溯源 |
| 适合 | 事实型问答、私有文档问答、知识频繁更新 | 风格学习、特定任务能力增强 |
简单说:知识更新用 RAG,能力/风格变化用微调,两者也能组合使用。
Q2 · RAG 的标准流水线分几步?每步用什么组件?
参考答案:四步:
前两步是离线索引(一份文档做一次),后两步是在线问答(每次提问都走)。
Q3 · chunk_size 和 chunk_overlap 怎么调?
参考答案:
- chunk_size:每块目标字符数,决定片段粒度。小块定位精准但信息量少,大块上下文完整但夹带噪音。经验值:FAQ/条款 200~300,连贯论述 500~800,代码按函数/类边界切;
- chunk_overlap:相邻块重叠字符数,防止句子跨块时被拦腰切断。常用 chunk_size 的 10%~20%;
- overlap=0 会让语义在边界断裂,overlap ≥ chunk_size 会让切分陷入无意义重复;
- 调优顺序:先固定 top_k=3 调 chunk_size,再按答案覆盖度微调 top_k。
Q4 · FAISS 的 score 是越大越相关还是越小越相关?
参考答案:越小越相关。FAISS 的 similarity_search_with_score 返回的是 L2 距离:0 表示几乎相同,越大越不相关。
这是一个非常容易踩的坑,因为:
- search_type="similarity_score_threshold" 用的是 0~1 归一化相似度,越大越相关;
- 不少其他向量库(如部分 Chroma 配置)也用相似度,越大越相关。
写通用代码前,先打印一条已知最相关的样本确认分数语义,别把排序做反了。
Q5 · create_retrieval_chain 和手写 LCEL 链有什么区别?
参考答案:数据流完全一致,差别在输出和便利性:
| 输出 | dict 含 answer / context / input | 裸 str(取决于最后一环) |
| 溯源 | 自带 context 命中片段 | 要自己保留 |
| 自定义 | 受链工厂约束 | 自由改造(如给片段加来源编号) |
| 入参 | {"input": question} | 裸字符串即可 |
怎么选:要快速搭建、需要溯源 → 官方链工厂;要自由改造 → 手写 LCEL。手写版的核心是 {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | model | parser,dict 字面量会自动升级为 RunnableParallel。
Q6 · stuff 策略是什么?有什么局限?
参考答案:stuff 策略 = 把检索到的所有片段一次性"塞"进提示词的 {context} 变量,一次 LLM 调用完成回答。简单直接,是默认策略。
局限:提示词里的上下文体积 ≈ top_k × chunk_size,逼近模型上下文窗口时答案会退化甚至报错。长文档场景可借鉴 map_reduce 思想 —— 先对每个片段分别提炼(map),再汇总多个提炼结果作答(reduce),代价是成倍的 LLM 调用。还有 refine 策略:逐个片段滚动 refine 答案,介于两者之间。
Q7 · 为什么聊天模型和向量模型要分开配置?
参考答案:它们是两个不同的模型,服务不同的目的:
- 聊天模型(config.model_name):生成自然语言回答,走 /chat/completions 接口;
- 向量模型(config.embedding_model):把文本映射成高维向量,走 /embeddings 接口。
换服务商时两者命名不同(如 DeepSeek 不提供 embedding,要用 DashScope 的 text-embedding-v3),且检索与建库必须用同一个向量模型 —— 否则查询向量和文档向量不在同一个空间,检索结果毫无意义。
Q8 · as_retriever 返回的检索器为什么能直接进 LCEL 管道?
参考答案:因为它实现了 Runnable 协议。as_retriever() 把向量库适配成一个 Runnable,invoke(str) -> list[Document],所以可以直接写 retriever | format_docs 把它接进管道。
这是 LangChain 的核心设计:所有组件(模板、模型、解析器、检索器、乃至整条链)都实现 Runnable 协议,所以能用同一套管道符 | 互相拼接,且组合结果仍是 Runnable,.invoke() / .stream() / .batch() 全套继承。第二章学的 LCEL 在这里直接派上用场。
Q9 · MMR 检索和普通 similarity 检索有什么区别?
参考答案:
- similarity:纯按相似度(或 L2 距离)取 top_k,可能返回高度雷同的片段;
- MMR(Maximal Marginal Relevance,最大边际相关):先多召回 fetch_k 条候选,再从中挑互补的 k 条,在"相关度"和"多样性"之间做权衡。
MMR 适合文档里同一件事被反复表述的场景(比如同一规则在多个章节出现),避免 k 条片段都在说同一件事、浪费上下文。fetch_k 一般取 k 的 3~5 倍。
Q10 · RAG 怎么抑制模型幻觉?
参考答案:三层防线:
另外,create_retrieval_chain 返回的 context 字段可以做溯源展示,让用户/审核者核对答案是否真的来自命中片段 —— 这是发现幻觉的事后手段。
八、本章小结 & 下一章预告
你应该掌握的
- 理解 RAG = 检索 + 生成,以及它相比微调的优势;
- 用 RecursiveCharacterTextSplitter 切分文档,知道 chunk_size / chunk_overlap / separators 的作用;
- 用 OpenAIEmbeddings + FAISS.from_documents 建向量库,知道聊天模型和向量模型是两个模型;
- 用 as_retriever 做检索,知道 FAISS 的 score 是 L2 距离(越小越相关);
- 用 create_stuff_documents_chain + create_retrieval_chain 搭问答链,知道输入键 input、输出键 answer / context;
- 用纯 LCEL 手写一条等价 RAG 链(RunnablePassthrough + dict 字面量自动升级为 RunnableParallel);
- 知道 search_type 的三种选项(similarity / mmr / similarity_score_threshold)及分数方向差异;
- 调优 top_k 与 chunk_size 联动,理解 stuff 策略的上下文体积上限;
- 能回答上面的 10 道面试题。
下一章预告
第五章 · 对话记忆 —— 单轮问答没有"上下文",但真实对话需要模型记得之前说过什么。下一章用 RunnableWithMessageHistory 把对话历史挂到链上,用 trim_messages 控制历史长度防止撑爆上下文,用 session_id 区分不同会话。其中历史如何注入提示词,正是本章 MessagesPlaceholder(第二章讲过)的真实用武之地。
⏭️ 下一章:第五章 · 对话记忆



