主题
RAG 与文档检索
一句话:LangChain 在 RAG 里的价值不是“帮你调用向量库”,而是把文档、切分、embedding、检索器统一成可替换的组件,让你能快速换方案并做评测。
适用版本:LangChain v1.x,langchain-text-splitters,具体向量库适配包按需安装。
为什么需要
一个 RAG 链路至少有六七个可替换环节:文档解析、切分、embedding、向量库、检索策略、重排、生成。每个环节都有多种实现,直接写死任何一环,后续对比和替换都要重写。
LangChain 用几个小抽象把边界定住:Document(文本 + 元数据)、TextSplitter(文档 → 块)、Embeddings(文本 → 向量)、VectorStore(写入、相似度查询)、Retriever(统一的 invoke(query) -> list[Document])。这样切分策略、向量库、检索器可以独立替换,评测时只改一个变量。
核心机制
Document:文本与元数据
python
from langchain_core.documents import Document
doc = Document(
page_content="退款需在签收后七天内申请。",
metadata={"source": "policy.md", "section": "售后", "updated_at": "2026-09-01"},
)元数据不是装饰:过滤、引用、去重、权限都靠它。入库时就要写全,事后补很麻烦。
切分:先结构,后长度
python
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=800, # 中文可先用 500–800 字
chunk_overlap=120, # 约 15%
add_start_index=True, # 保留在原文中的位置,便于引用
)
chunks = splitter.split_documents(docs)RecursiveCharacterTextSplitter 会优先按段落、换行、句子切,尽量不把一个语义单元劈开。对代码、表格、Markdown,优先用对应的结构化切分器。切分决定召回上限:块太大,相似度被稀释;块太小,答案缺上下文。常见做法是“小块检索、父块生成”。
向量化与入库
python
from langchain_openai import OpenAIEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
store = InMemoryVectorStore(embeddings)
store.add_documents(chunks) # 生产换成 Milvus/Qdrant 等,接口不变,索引常用 HNSW + COSINE写入和查询必须用同一个 embedding 模型与版本,否则向量空间不一致,召回会莫名变差。
Retriever:统一查询接口
python
retriever = store.as_retriever(
search_type="similarity",
search_kwargs={"k": 4, "filter": {"tenant": "acme"}},
)
docs = retriever.invoke("退款多久到账")search_type 还支持 mmr(兼顾相关性与多样性)等;不同向量库支持的过滤语法不同,跨库迁移时这部分最容易出问题。
两种 RAG 组织方式
固定链路:检索 → 拼上下文 → 生成。可控、便宜、延迟低。
python
def answer(question: str) -> str:
docs = retriever.invoke(question)
context = "\n\n".join(f"[{i}] {d.page_content}" for i, d in enumerate(docs, 1))
msg = model.invoke([
("system", "只依据给出的资料回答;资料不足就说不知道,并给出引用编号。"),
("human", f"资料:\n{context}\n\n问题:{question}"),
])
return msg.textAgentic RAG:把检索做成工具,让 Agent 自己决定查什么、查几次、要不要改写查询。灵活,但延迟和成本不可预测,需要设调用上限。选择标准:问题类型固定、文档结构稳定 → 固定链路;问题开放、需要多跳推理 → Agentic RAG。
两条路的差别只在检索由谁发起,图里共用同一个 retriever。
固定链路把检索次数固定为一次,延迟和成本可预估,代价是查询不会根据中间结果改写。Agentic 分支把 retriever 包成工具,由模型决定是否再查,换来多跳能力;每次决策都可能多一轮模型调用,所以必须设调用上限。两条路共用同一套 Document/Retriever 抽象,换编排方式时不需要重建索引。
实战场景
场景一:多租户政策问答的完整检索封装
痛点:SaaS 知识库第一次跨租户泄漏,复盘几乎总是同一个原因——某个新代码路径忘了写 filter={"tenant": ...}。租户过滤散落在每个调用点,就迟早漏一处;隔离必须是"绕不过去"的设施,而不是"记得写"的约定。正确位置是检索器封装层:
python
class KBSearch(BaseModel):
"""带租户与权限过滤的知识检索,所有业务查询只能从这里走。"""
def search(self, query: str, principal: Principal) -> list[Document]:
flt = {"$and": [
{"tenant": principal.tenant_id}, # 身份从 JWT 来,不从 question 来
{"valid_until": {"$gt": today()}}, # 过期政策不参与召回
{"audience": {"$in": principal.roles}}, # 权限进向量过滤,不是召回后删
]}
cands = store.similarity_search_with_score(query, k=40, filter=flt)
kept = rerank(query, cands, threshold=0.35)[:6] # 宽召回、精截断
return [collapse_parent(d) for d in kept] # 小块命中、父块生成principal 由调用链从认证中间件传入,工具签名里没有让模型填 tenant 的位置——这是与 02 篇退款工具同一条原则:模型只填它该知道的。
场景二:政策更新后的增量重灌(删除比新增更容易出事)
痛点:知识库最尴尬的故障不是"查不到",而是查到了旧的——政策改版后用户仍被告知已作废的条款,且引用看起来完全合规。新增只错一次,删除不清会一直错。《退款政策》改版的实战流程固定四步:按 source 删旧块 → 新文档切块入库(新 metadata 版本号)→ 跑该文档相关的 20 条评测用例 → 更新引用映射。直接 upsert 向量是危险写法:块边界变了,旧切分的残留块和新块混在同一 source 下,模型引用到旧政策。更隐蔽的是删除传播——摘要索引、缓存、重排器的候选池各有副本,漏一处就是"明明删了还答得上来"。派生链清理的完整语义见 记忆系统 遗忘一节,RAG 库和记忆库在这件事上是同一套账。
排障:四类现象的四条路径(上线后最常收到的四种反馈)
| 现象 | 第一查 | 典型根因 | 修法 |
|---|---|---|---|
| “找不到 / 说不知道” | 检索日志里该 query 的召回列表 | 块把答案和限定条件切开了;query 与文档表述不匹配 | 父块生成;Multi-Query 改写(见 08 篇) |
| “答案错但引用看着对” | 引用块原文与答案逐句对照 | 块里同时有新旧两版政策;限定条件"除外"被截掉 | 版本元数据过滤;切分按条款边界 |
| “版本号/错误码查不到” | 该串是否被 embedding 糊掉 | 纯向量对精确串天然弱 | 加 BM25 双路 + RRF 融合 |
| “延迟从 1s 变 4s” | trace 分段:向量 / 过滤 / 重排 / 生成 | cross-encoder 重排在批量下退化 | 重排放缓存层;k 数与阈值降档 |
这四条路径的共同前提就是生产动作第一条:没有检索日志和评测集,四条都走不了。先记 query / 召回 ID / 分数 / 最终引用,再谈调优。
最小上手
python
from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
raw = [Document(page_content="退款需在签收后七天内申请。", metadata={"source": "policy"}),
Document(page_content="物流一般三到五天送达。", metadata={"source": "faq"})]
chunks = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=30).split_documents(raw)
store = InMemoryVectorStore(OpenAIEmbeddings(model="text-embedding-3-small"))
store.add_documents(chunks)
for d in store.as_retriever(search_kwargs={"k": 2}).invoke("多久能退款"):
print(d.metadata["source"], d.page_content)生产动作
- 先建评测集:100–300 条“问题 + 应命中文档 ID”,每次改切分、embedding、检索参数都跑一遍 Recall@k 与 MRR。
- 元数据写入要完整:来源、章节、时间、租户、权限;过滤和引用都依赖它。
- 增量更新用主键 upsert:文档变更要能覆盖旧块,删除要显式传播;否则知识库越用越脏。
- embedding 版本固定:模型升级 = 全量重建,用新 collection + 别名切换,不要原地混写。
- 重排通常比换向量库更值:先召回 30–50 条,再用 cross-encoder 重排到 5–10 条。
- 控制上下文预算:按 token 截断,保留来源编号,要求模型引用;不要让检索结果挤掉系统指令。
- 记录检索日志:query、召回文档 ID、分数、最终引用,用于分析“为什么没召回”。
- 多租户隔离:collection/partition 或强制 filter 三选一,在检索器封装层统一注入,避免漏写。
边界
- LangChain 不提升检索质量:它只是把组件接起来;效果取决于切分、embedding、数据质量。
- 通用切分器不懂业务结构:合同、代码、表格要用专用解析与切分,否则关键信息会被切断。
as_retriever的能力由后端决定:混合检索、过滤、分页、一致性级别,不同向量库差异很大。- InMemoryVectorStore 只适合验证:没有持久化、没有并发能力。
- Agentic RAG 不是默认更好:多轮检索会增加延迟与不确定性,能用固定链路解决就不要上 Agent。
讲清楚
LangChain 的 RAG 抽象很小:Document 是文本加元数据,splitter 把文档切块,Embeddings 转向量,VectorStore 负责写入和相似度查询,Retriever 把查询统一成
invoke。真正决定效果的是切分和数据:块太大语义被稀释,块太小上下文不全;元数据不全,过滤和引用都做不了。embedding 一旦换版本,旧向量全部失效,必须全量重建。组织方式有两种:固定链路可控、便宜,适合问题类型稳定的场景;Agentic RAG 把检索当工具,适合开放、多跳的问题,但要设调用上限。无论哪种,先建评测集,否则所有优化都只是感觉。
适用:LangChain v1.x / langchain-text-splitters | 初稿 2026-10