LangChain系列之RAG 实战:检索质量才是分水岭

2026-09-07 18:060 条评论10 次阅读

RAG 实战:检索质量才是分水岭

LangChain 系列 · 第 3 篇

一、一个上线即翻车的知识库

上周我把公众号已发的 30 篇文章喂进了一个 RAG 知识库,想让它回答关于我自己和产品的问题。

我问它:作者做的第一款小程序是干什么的?

它回答得流畅又自信:这是一款帮助用户分享美食菜谱、建立美食社区的微信小程序。

我回去翻了原文。第一款小程序叫「配料说」,拍一张食品配料表,把成分翻译成人话,告诉你这款食品值不值得买。它答错了,而且错得理直气壮。

检索日志更让我意外。召回的 top1 片段和问题相似度 0.87,向量库认为它是全库最相关的片段。相似度这么高,答案却和原文完全对不上。

RAG 的错误,往往藏在你看不见的检索环节里。

二、跑通了,答案却不可信

先把 RAG 全链路画出来。文档从加载到生成,一共 7 个环节:

公众号文章

Loader 加载

Splitter 切分

Embedding 向量化

Milvus 向量库

Retrieve 召回

LLM 生成回答

图上标橙的 Retrieve 和 Generate 是答案质量的闸门。

做了 11 年搜索,我对这套链路有个本能判断。生成环节做的是翻译,把检索到的片段改写成通顺的人话。回答的上限由检索质量决定,生成模型只是执行者。

top1 相似度 0.87 只说明向量空间里它离问题最近,说明不了它在语义上正确。

更要命的是,生成模型不会质疑检索结果。喂给它一个错误片段,它会把这个错误包装成一段逻辑自洽、语气笃定的回答。RAG 翻车的剧本都长这样:用户看到的是自信的错答案,问题出在没人检查的检索环节。

三、藏在管线里的三个断点

复盘那次翻车,问题落在三个断点上。

断点 1:chunk 切分破坏了语义

答案本来在一个完整句子里,切分器把句子从中间腰斩。主语留在上一个 chunk,谓语留在下一个,检索时哪个 chunk 都和问题对不上。

另一种错配更隐蔽。库里有大量讲"怎么做"的段落,问题问的是"是什么",粒度不匹配,召回的片段自然文不对题。

断点 2:单向量召回的精度上限

向量相似度衡量的是"长得像",语义正确是另一回事。top-k 里经常混进形状相似但内容无关的片段,这是向量召回的固有噪声。

召回只负责回答"有没有可能相关",精度要靠下一级环节保证。这个道理在搜索系统里是常识,到了 RAG 教程里却没人讲。

断点 3:没有评测闭环

改了 chunk 大小,换了 embedding 模型,效果好坏全凭感觉。没有一组固定问题做回归,你永远不知道改动是变好还是变坏,上线翻车只是时间问题。

市面上的 RAG 教程普遍停在这个水平:跑通 demo 就收工,检索质量三件事(切分策略、精排、评测)一字不提。

数据形态 常见坑 典型表现
长文档(几万字) chunk 切太小 一个主题被切散,回答缺上下文
FAQ / 一问一答 一条 chunk 跨多条问答 chunk 里混进无关问题
代码 / 配置 按字符硬切 语法被切断,语义全丢
中英混排 只用空格类分隔符 中文句子整段黏连切不动

四、三步把 RAG 从能跑升到能用

下面是我翻车后的完整升级路径。代码全部是 LangChain 1.x 新 API,旧 API 一条不出现。

Step 0:5 分钟跑通最小 RAG

先让链路转起来,再谈质量。环境准备两条:Docker 起 Milvus,Embedding 用 bge-m3。

docker run -d --name milvus -p 19530:19530 -p 9091:9091 milvusdb/milvus:latest

Embedding 走硅基流动的免费 bge-m3 接口,模型名 Pro/BAAI/bge-m3,输出 1024 维。

本地没有 Docker 的话,可以用 Chroma 平替跑通同一条链路:

维度 Milvus(本篇主线) Chroma(轻量平替)
部署 Docker 一键 pip install 即用,进程内运行
定位 产线级向量库,支持分布式 单机原型、学习验证
迁移成本 接口换成 langchain_chromaChroma 即可 代码其余部分不变

建库写数据:

import os
from dotenv import load_dotenv
from langchain.embeddings import init_embeddings
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from pymilvus import MilvusClient

load_dotenv()

COLLECTION = "jianghu_articles"
MILVUS_URI = "http://localhost:19530"

# 1. 加载公众号文章的 Markdown 源稿并切分
loader = TextLoader("articles.txt", encoding="utf-8")
docs = loader.load()

splitter = RecursiveCharacterTextSplitter(
    chunk_size=300,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", ";", ",", " ", ""],  # 中文优先按句号切
)
chunks = splitter.split_documents(docs)

# 2. 向量化:bge-m3 统一入口
embed_model = init_embeddings(
    model="openai:Pro/BAAI/bge-m3",
    api_key=os.getenv("SILICONFLOW_API_KEY"),
    base_url=os.getenv("SILICONFLOW_BASE_URL"),
)
vectors = embed_model.embed_documents([c.page_content for c in chunks])

# 3. 写库:主键 id,向量字段 vector,正文存 text 字段
client = MilvusClient(MILVUS_URI)
if client.has_collection(COLLECTION):
    client.drop_collection(COLLECTION)
client.create_collection(
    collection_name=COLLECTION,
    dimension=1024,
    metric_type="COSINE",
)
data = [
    {
        "id": i,
        "vector": vectors[i],
        "text": chunks[i].page_content,
        "source": chunks[i].metadata.get("source", "articles.txt"),
    }
    for i in range(len(chunks))
]
client.upsert(collection_name=COLLECTION, data=data)
client.flush(collection_name=COLLECTION)

检索加生成:

from langchain.agents import create_agent
from langchain.chat_models import init_chat_model

model = init_chat_model(
    model="qwen3-max",
    model_provider="openai",
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url=os.getenv("DASHSCOPE_BASE_URL"),
)
agent = create_agent(
    model=model,
    tools=[],
    system_prompt=(
        "你是问答助手。只根据检索到的上下文回答,"
        "上下文不足以回答时直接说不知道,"
        "回答末尾标注引用来源。"
    ),
)

def rag_answer(question: str, k: int = 5):
    query_vector = embed_model.embed_query(question)
    hits = client.search(
        collection_name=COLLECTION,
        data=[query_vector],
        limit=k,
        output_fields=["text", "source"],
    )[0]
    context = "\n---\n".join(h["entity"]["text"] for h in hits)
    return agent.invoke({"messages": [
        ("user", f"上下文:\n{context}\n\n问题:{question}"),
    ]})

这套代码已经能回答问题了,答案质量停留在上一节说的水平。接下来三步才是分水岭。

Step 1:选对 splitter,再谈参数

聊 chunk 的文章,90% 只讲 RecursiveCharacterTextSplitter 的参数怎么调。切分器家族远不止这一种,选错策略的损失比调错参数更大。

LangChain 1.x 把切分器拆成独立包 langchain_text_splitters,按切分依据可以分成 5 类:

策略 切分依据 代表实现 适合场景
固定长度 字符数 / token 数 CharacterTextSplitter 快速兜底、英文文本
递归切分 分隔符按优先级逐级试 RecursiveCharacterTextSplitter 通用默认,90% 场景够用
结构切分 Markdown / HTML 标题层级 MarkdownHeaderTextSplitter 文档自带清晰标题结构
代码切分 函数 / 类语法边界 RecursiveCharacterTextSplitter.from_language 代码库检索
语义切分 Embedding 相似度断点 SemanticChunker(实验性) 话题跳跃大的高价值长文

递归切分是默认选项,它沿分隔符优先级逐级下探,先段落、再句子、再标点,像顺着纹理切肉。多数文档到这一层就够,代码库换 from_language,高价值长文再考虑语义切分,后者每次切分都要调 Embedding,慢且贵,量级不同。

中文场景有一条实测结论(text2vec 社区用 1150 份真实文档验证过):ChineseRecursiveTextSplitter 内置中文分隔符,切分质量普遍好于默认的 RecursiveCharacterTextSplitter;CharacterTextSplitter 按字符硬切,经常把转折词腰斩成两段,遇到没有空行的中文文档甚至整篇都不切:

from langchain_text_splitters import ChineseRecursiveTextSplitter

splitter = ChineseRecursiveTextSplitter(
    chunk_size=300,
    chunk_overlap=50,
)
chunks = splitter.split_documents(docs)

chunk_size 的上界也有依据。embedding 模型对输入长度有上限,bge-m3 是 8192 token,早期的 BCE、bge-large-zh 只有 512。超过上限的 chunk 会被截断,丢的是尾部信息,检索时这段内容就缺了一半。

实测数据:chunk_size 用错能掉 10% 的指标,精细调参只换来 2%。先选对量级,再谈微调。

参数 起点值 调大 调小
chunk_size 300 答不全、缺上下文 答得泛、混入无关内容
chunk_overlap 50 跨 chunk 的语义衔接不上 库里有大量重复片段
中文 separators 加 。;, 中文句子黏连成块 单句被拦腰截断

公众号文章是 Markdown,自带标题层级,用结构切分更合适。先用 MarkdownHeaderTextSplitter 按标题分章,超长章节再递归精切,两刀组合:

from langchain_text_splitters import MarkdownHeaderTextSplitter
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 第一刀:按标题分章,章节名自动带进每个 chunk
md_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "H1"), ("##", "H2"), ("###", "H3")]
)
sections = md_splitter.split_text(md_text)   # md_text 是 Markdown 原文

# 第二刀:超长章节再递归精切
fine_splitter = RecursiveCharacterTextSplitter(
    chunk_size=300,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", ";", ",", " ", ""],
)
chunks = fine_splitter.split_documents(sections)

结构切分的隐藏收益在 metadata。每个 chunk 都知道自己属于哪一章,检索命中后把章节名拼进上下文,回答天然带出处。

metadata 同样重要。每条 chunk 记录文章来源,回答强制带引用。检索质量最终要让用户可验证:答案后面跟着来源,读者点开原文能对得上,信任就建立起来了。

我在 system prompt 里写了"回答末尾标注引用来源",配合每篇一个文件加载,source 字段天然就是引用,零额外成本。

Step 2:reranker 精排,本篇灵魂

向量召回负责广撒网,精排负责一锤定音。用交叉编码器给召回结果重新打分,10 行代码:

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

def rerank(question: str, hits, top_n: int = 5):
    pairs = [(question, h["entity"]["text"]) for h in hits]
    scores = reranker.predict(pairs)
    ranked = sorted(zip(hits, scores), key=lambda x: x[1], reverse=True)
    return [h for h, _ in ranked[:top_n]]

检索流程变成两段式:向量召回 top20,reranker 精排取 top5,再喂给模型。

问题

向量召回 top 20

bge-reranker 精排打分

取 top 5

LLM 生成

同一个翻车问题,升级前后的检索结果对比:

位置 纯向量召回 top3 reranker 精排后 top3
1 相似度 0.87,讲菜谱社区的无关段落 正确片段进 top1(配料说定义)
2 相似度 0.79,另一篇文章的简介 相关补充说明
3 相似度 0.71,公众号菜单介绍 相关背景段落

看出差别了吗。纯向量把长得像的排前面,reranker 把语义对的排前面。精排后 top1 的原始相似度反而更低,答案却对了。

相似度数字会骗人,reranker 不会。

做过电商搜索的读者到这里应该会心一笑。向量召回对应多路召回,reranker 对应二次精排,评测集对应离线评测。RAG 的检索链和搜索系统是同一个架构,只是把 query 换成问题,把商品换成文档片段。

这套广撒网、精排定生死的打法,我在搜索产线用了 11 年,搬到 RAG 上是直接平移。市面教程写不出这一层,它需要搜索系统的实战积累。

Step 3:评测闭环

没有评测的优化都是玄学。我建了一组 20 条的 golden QA,覆盖公众号文章里的关键事实:

问题 期望答案
作者做的第一款小程序是干什么的? 拍配料表解读食品成分,判断值不值得买(配料说)
LangChain 1.0 的四大支柱是什么? LangChain、LangGraph、Deep Agent、LangSmith
作者在搜索领域做了多少年? 11 年,五代架构
配料说用了什么 AI 模型? Qwen + RAG

每次改动跑一遍评测集,回答命中期望答案记 1 分,统计命中率。改动不再凭感觉:chunk 参数、embedding 模型、reranker 换不换,全部用数字说话。

我自己的结果:基线版本 20 条只对 11 条,那个翻车问题稳定答错。三步走完后 20 条对 18 条,翻车问题进了正确答案。剩下 2 条是知识库本身没覆盖的,属于边界问题,需要补文档而不是调参。

LangSmith 是这套评测的规模化版本,支持数据集、批量评测、回归对比,LC-09 展开讲。

五、RAG 与搜索架构是同构的

回头看那 30 篇文章的翻车,问题出在检索,解法也出在检索。召回、精排、评测这套老三样,从搜索系统平移到 RAG 几乎零成本,因为它们解决的是同一个问题:海量内容里怎么把对的片段捞出来。

跑通 RAG 是 1 小时的事,让检索质量可度量、可回归才是产线的事。

这套检索链还在往更远的地方走。我在产线里已经把它从"临时拼接上下文"升级成 Agent 的长期记忆:每次对话的结论、用户的偏好,向量化之后写回记忆库,跨会话可召回。这就是记忆模块要展开的内容,也是单 Agent 走向多 Agent 的核心资产。

另外我整理了一份 LangGraph 实战教程,覆盖 Agent 编排从入门到实战。想要的话,关注我的个人公众号后台回复「LangGraph」,我把下载链接和提取码发你。