跳到正文

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.text

Agentic 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

把 AI 工程讲到既懂也会用