欢迎光临
我们一直在努力

LangChain入门④:从结构化输出到RAG实战

第四章 · 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微调
成本 低,只做一次离线索引 高,需要训练算力 + 标注数据
知识更新 换文档、重建向量库即可,分钟级 重新微调,天级
出处溯源 能给出命中片段 无法溯源
适合 事实型问答、私有文档问答、知识频繁更新 风格学习、特定任务能力增强

简单说:知识更新用 RAG,能力/风格变化用微调,两者也能组合使用。

Q2 · RAG 的标准流水线分几步?每步用什么组件?

参考答案:四步:

  • 切分:RecursiveCharacterTextSplitter,按 separators 优先级递归切分,尽量在语义边界断开;
  • 向量化建库:OpenAIEmbeddings + FAISS.from_documents,逐块调 embedding 接口 + 建索引;
  • 检索:vectorstore.as_retriever(search_kwargs={"k": top_k}),返回 list[Document];
  • 生成:create_stuff_documents_chain + create_retrieval_chain,把命中片段 stuff 进提示词调 LLM。
  • 前两步是离线索引(一份文档做一次),后两步是在线问答(每次提问都走)。

    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 链有什么区别?

    参考答案:数据流完全一致,差别在输出和便利性:

    维度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 怎么抑制模型幻觉?

    参考答案:三层防线:

  • 提示词约束:在 system 里写死"仅根据上下文回答;找不到答案就直说『根据文档无法回答』,不要编造"。这是第一道闸;
  • 检索质量:调好 chunk_size / top_k / embedding_model,确保命中的片段真的包含答案。检索不到,模型就只能靠猜;
  • temperature=0:抽取/问答任务固定低温度,减少模型"发挥"。本项目后端在结构化抽取模块硬编码 temperature=0,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(第二章讲过)的真实用武之地。


    ⏭️ 下一章:第五章 · 对话记忆

    赞(0)
    未经允许不得转载:171主机测评 » LangChain入门④:从结构化输出到RAG实战
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址