RAG 进阶:切分、检索、重排序,以及它和 Agent、微调、MCP 都是什么关系

RAG 进阶:切分、检索、重排序,以及它和 Agent、微调、MCP 都是什么关系

上一篇聊了 RAG 是什么、为什么已经有大模型了还需要它。这一篇往深了讲一层,把几个经常和 RAG 一起出现、也经常被搞混的概念一次性理清楚:文档到底该怎么切分、向量数据库解决的是什么问题、检索效果不好的时候该怎么办、RAG 和 Agent 是什么关系、RAG 和微调到底怎么选、以及最近同样很火的 MCP 又是什么。

篇幅会比较长,建议一节一节看,每一节尽量做到读完就能直接用上。

一、文档怎么切分:切得好不好,直接决定 RAG 的上限

上一篇提到过,RAG 的第一步是把文档切成一段一段的片段(chunk),再对每段做检索。这一步听起来简单,但其实是整个 RAG 系统里最容易被低估、也最容易出问题的环节。原因很直接:如果切分切得不好,哪怕后面的检索和生成再完美,模型拿到的资料本身就是残缺的,回答自然也会跟着错。

先看一个最容易踩的坑:按固定字数硬切。比如规定"每30个字切一段",不管这句话说到一半还是说完了,到了30个字就切一刀。我们拿一段真实的规章制度文字来试一下:

python 复制代码
text = (
    "公司差旅报销规定如下。员工因公出差,可报销经济舱机票或高铁二等座车票。"
    "住宿费用每日不超过300元,超出部分由员工自行承担。"
    "报销需在出差结束后15个工作日内提交,逾期未提交的,视为自动放弃报销权利。"
    "如遇特殊情况需要延期提交,需提前向财务部门说明并获得书面批准。"
)

def fixed_length_chunks(text, size=30):
    return [text[i:i+size] for i in range(0, len(text), size)]

for c in fixed_length_chunks(text):
    print(f"[{len(c)}字] {c}")

实际跑一下,会切出这样的结果(第三段和第四段的分界处最能说明问题):

复制代码
[30字] 公司差旅报销规定如下。员工因公出差,可报销经济舱机票或高铁二
[30字] 等座车票。住宿费用每日不超过300元,超出部分由员工自行承担
[30字] 。报销需在出差结束后15个工作日内提交,逾期未提交的,视为自
[30字] 动放弃报销权利。如遇特殊情况需要延期提交,需提前向财务部门说
[9字]  明并获得书面批准。

看第三段和第四段:一句完整的话"逾期未提交的,视为自动放弃报销权利"被硬生生切成了"视为自"和"动放弃报销权利"两半,分别落在两个不同的 chunk 里。如果用户问"逾期不交会怎么样",检索系统很可能只召回其中一段,模型拿到的信息是不完整的,甚至可能因为看不到"视为自动放弃"这几个字而完全答不上来。

解决办法之一是重叠切分(overlap):切的时候让相邻两段有一部分内容重复,这样即使切分点落在句子中间,完整的句子也至少会完整地出现在其中一个 chunk 里。

python 复制代码
def overlap_chunks(text, size=30, overlap=10):
    chunks = []
    step = size - overlap
    for i in range(0, len(text), step):
        chunk = text[i:i+size]
        if chunk:
            chunks.append(chunk)
        if i + size >= len(text):
            break
    return chunks

但重叠切分本质上是在"打补丁",更靠谱的做法是按语义边界切分,也就是尽量顺着句子、段落这些自然的语义单元来切,而不是死板地数字数。最简单的版本可以直接按句号、问号、感叹号切:

python 复制代码
import re

def sentence_chunks(text):
    parts = re.split(r'(?<=[。!?])', text)
    return [p for p in parts if p.strip()]

for c in sentence_chunks(text):
    print(f"[{len(c)}字] {c}")

跑出来的结果,每一段都是一句完整的话,没有任何句子被拦腰截断:

复制代码
[11字] 公司差旅报销规定如下。
[24字] 员工因公出差,可报销经济舱机票或高铁二等座车票。
[26字] 住宿费用每日不超过300元,超出部分由员工自行承担。
[37字] 报销需在出差结束后15个工作日内提交,逾期未提交的,视为自动放弃报销权利。
[31字] 如遇特殊情况需要延期提交,需提前向财务部门说明并获得书面批准。

实际生产环境中,切分策略通常还会更讲究一些:比如优先按段落切,段落太长了再往下按句子切;给每个 chunk 附带一些"元数据"(这段话来自哪份文档、第几章、更新时间是什么),方便后续检索和溯源;还有一种做法是"父子分块"------检索时用切得很细的小段去匹配,但真正拼给模型的时候,把这个小段所在的整段上下文都带上,兼顾检索精度和信息完整性。这些都是切分这一步可以持续打磨的地方,没有一个万能的"标准答案",需要根据文档类型去调整。

二、向量数据库到底是什么,解决了什么问题

上一篇的 demo 里,我们用的是 TF-IDF------本质上是在统计"哪些字词比较有区分度",说到底还是在比较字面上有没有重复的字词。这种方法有一个明显的短板:它认不出"意思相近但用词不同"的句子

举个例子,假如用户问"我的电脑坏了怎么办",而知识库里存的是"笔记本无法开机的处理流程",这两句话字面上几乎没有重复的词,TF-IDF 大概率会认为它们不相关,但从意思上看,这俩问题其实是同一件事。

真正的 embedding 模型解决的就是这个问题:它是在海量语料上训练出来的,能学到"电脑"和"笔记本"这类词在语义上是接近的,转换出来的向量在空间里的"距离"也会更近;反过来"电脑"和"水果"这种毫不相关的词,向量距离就会很远。用一个简化的小例子直观感受一下(注意这里的向量是我们手工写出来做演示的,只是为了说明"语义相近、向量也相近"这个原理,真实的 embedding 向量是模型自动学出来的,并不是人工设计的):

python 复制代码
import numpy as np

word_vectors = {
    "电脑": np.array([0.9, 0.1]),
    "笔记本": np.array([0.85, 0.2]),
    "手机": np.array([0.6, 0.3]),
    "水果": np.array([0.1, 0.9]),
}

def cos(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

print("电脑 vs 笔记本:", round(cos(word_vectors["电脑"], word_vectors["笔记本"]), 3))
print("电脑 vs 水果:", round(cos(word_vectors["电脑"], word_vectors["水果"]), 3))

输出结果是:

复制代码
电脑 vs 笔记本: 0.993
电脑 vs 水果: 0.22

"电脑"和"笔记本"的相似度接近1(也就是几乎同一个意思),而"电脑"和"水果"的相似度只有0.22(基本不相关)。真实的 embedding 模型做的是同样的事情,只不过向量的维度通常是几百到上千维,而且是自动从数据里学出来的,能捕捉到远比这个玩具例子丰富得多的语义关系。

那"向量数据库"又是什么呢?把知识库里成千上万甚至几百万段文本都转换成向量之后,你面临一个新问题:用户提问时,怎么从这么多向量里,快速找出和问题向量最接近的那几个?如果文档只有几十条,暴力挨个计算相似度完全没问题(我们上一篇的 demo 就是这么干的);但如果是几百万条,逐一计算的开销会大到无法接受。

向量数据库(比如 Milvus、Pinecone、Chroma、Qdrant 这些)存在的意义,就是专门解决"在海量向量里快速找出最相似的几个"这个工程问题。它们内部通常用的是一类叫"近似最近邻(ANN)"的算法,用空间换时间,牺牲一点点精确度,换来检索速度的大幅提升,让你在百万级、千万级的知识库里也能做到毫秒级响应。可以把它简单理解成:TF-IDF/embedding 解决的是"怎么把文字变成能比较的数字",向量数据库解决的是"数字多到一定程度之后,怎么快速找到最像的那几个"。

三、检索效果不好怎么办:混合检索和重排序

实际做 RAG 系统时,纯语义检索(向量检索)并不是永远最优的。它有一个反直觉的短板:对于一些必须精确匹配的专有名词、编号、型号,语义检索反而不如最朴素的关键词匹配好使。比如用户问"报销单编号 BX-2024-0037 的状态",这种带有具体编号的查询,语义再"聪明"的向量检索也很难准确捕捉到这个编号本身的重要性,因为向量比较的是"整体意思接近",而不是"某个具体字符串完全命中"。

这时候一个常见的做法是混合检索(Hybrid Search):把关键词检索(常见的算法叫 BM25,可以理解成比 TF-IDF 更成熟一些的关键词匹配算法)和向量检索的结果按一定权重加起来,取长补短。下面用一个从零手写的简化版 BM25,跟前面的 TF-IDF 向量检索结合起来试一下:

python 复制代码
import math
from collections import Counter
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

def bigrams(text):
    return [text[i:i+2] for i in range(len(text)-1)]

class SimpleBM25:
    def __init__(self, docs, k1=1.5, b=0.75):
        self.tokenized = [bigrams(d) for d in docs]
        self.doc_lens = [len(t) for t in self.tokenized]
        self.avgdl = sum(self.doc_lens) / len(self.doc_lens)
        self.k1, self.b = k1, b
        self.N = len(docs)
        self.df = Counter()
        for toks in self.tokenized:
            for t in set(toks):
                self.df[t] += 1

    def idf(self, term):
        df = self.df.get(term, 0)
        return math.log((self.N - df + 0.5) / (df + 0.5) + 1)

    def get_scores(self, query):
        q_tokens = bigrams(query)
        scores = []
        for i, toks in enumerate(self.tokenized):
            freq = Counter(toks)
            dl = self.doc_lens[i]
            s = 0.0
            for t in q_tokens:
                if t not in freq:
                    continue
                f = freq[t]
                s += self.idf(t) * (f * (self.k1 + 1)) / (
                    f + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
                )
            scores.append(s)
        return scores

def normalize(scores):
    lo, hi = min(scores), max(scores)
    if hi - lo < 1e-9:
        return [0.0 for _ in scores]
    return [(s - lo) / (hi - lo) for s in scores]

def hybrid_search(query, documents, bm25, vectorizer, doc_vectors, alpha=0.5, top_k=2):
    bm25_scores = normalize(bm25.get_scores(query))
    tfidf_scores = normalize(cosine_similarity(vectorizer.transform([query]), doc_vectors)[0].tolist())
    combined = [alpha * b + (1 - alpha) * v for b, v in zip(bm25_scores, tfidf_scores)]
    ranked = sorted(range(len(documents)), key=lambda i: combined[i], reverse=True)[:top_k]
    return [(documents[i], combined[i], bm25_scores[i], tfidf_scores[i]) for i in ranked]

用上一篇的那份"公司规章制度"知识库,问一句"报销机票需要多久之内交":

复制代码
综合分1.000 (BM25 1.000 / 向量 1.000) -> 公司差旅报销规定:员工出差可报销经济舱机票、高铁二等座车票,以及每日不超过300元的住宿费用,报销需在出差结束后15个工作日内提交。
综合分0.238 (BM25 0.275 / 向量 0.202) -> 公司餐补规定:加班到晚上8点以后,可申请30元餐补,需要在加班当天提交申请,逾期不再受理。

可以看到两种打分方式在这个例子里的排序是一致的,最相关的结果排在最前面,alpha 这个参数就是用来调节"关键词匹配"和"语义匹配"各占多大权重的旋钮,实际项目中通常需要根据自己的数据做一些调参。

除了混合检索,另一个常见的优化手段是重排序(Rerank):先用速度快但精度一般的方法(比如向量检索)粗筛出几十条候选结果,再用一个更精细、但计算量也更大的模型,对这几十条候选重新精确打分排序,只把排名最靠前的几条真正交给大模型去生成答案。这么做的逻辑是"先快速海选,再精细决赛"------如果一开始就对全部文档做高精度打分,开销会大到无法接受;但如果只对向量检索已经筛出来的一小撮候选做精细排序,成本就完全可以接受了。生产环境中的 rerank 通常会用专门训练过的模型(业内叫 cross-encoder),效果比单纯的向量相似度更准,但需要额外的模型和算力支持,这里就不展开写具体实现了。

四、RAG 和 Agent 是什么关系

这是另一个经常被搞混的地方:"RAG"和"Agent"经常在同一篇文章里出现,很多人会误以为它们是同一个东西,或者是竞争关系。其实它们解决的是完全不同的问题,而且经常是搭配使用的。

RAG 解决的是"模型知道什么"的问题 ------ 让模型在回答之前先查资料,补齐它本来不知道的知识。

Agent 解决的是"模型能做什么"的问题 ------ 让模型不只是回答问题,还能自己去调用工具、执行操作、根据结果决定下一步做什么,形成一个"思考 - 行动 - 观察结果 - 再思考"的循环。

举个例子最容易说清楚区别。假设有个用户问客服机器人:"我上周下的订单到哪了?"

  • 纯 RAG 的做法:检索出"物流查询说明"这类文档,模型照着文档回答"你可以登录官网 - 我的订单 - 查看物流",但它并不会真的帮你去查。
  • Agent 的做法:模型会判断"这个问题需要调用查订单的工具",于是它调用一个叫 get_order_status 的工具接口,传入用户的订单号,拿到"已发货,预计明天到达"这个结果,再把这个结果组织成自然语言回复给用户。

用简化的伪代码对比一下两种模式的核心区别:

python 复制代码
# RAG 模式:检索资料,再回答
def rag_answer(question):
    docs = retrieve(question)
    return generate(question, context=docs)

# Agent 模式:可能需要循环多轮"思考-行动"
def agent_answer(question):
    while True:
        decision = model_think(question)          # 模型决定下一步做什么
        if decision.action == "查订单":
            result = call_tool("get_order_status", decision.params)
            question = update_with_observation(question, result)
        elif decision.action == "回答":
            return decision.final_answer

实际项目中,这两者经常是叠加使用的:一个 Agent 在执行任务的过程中,某一步很可能就是"调用 RAG 检索一下相关资料",检索到的资料又会作为 Agent 决策的依据之一。可以理解成 RAG 是 Agent 众多可调用的"能力"之一,而不是两个互相替代的方案。

五、RAG 和微调到底怎么选

上一篇简单对比过,这里给一个更具体的判断标准。核心问题只有一个:你想让模型"多懂一些事实",还是想让模型"换一种说话方式或行为习惯"?

倾向于用 RAG 的情况:

  • 知识本身会频繁变化(价格、库存、最新政策、公司制度)
  • 需要让回答能标明出处、可核实、可追溯
  • 知识库很大,或者知识来源分散在很多文档里
  • 团队没有太多机器学习资源,希望改动成本尽量低

倾向于用微调的情况:

  • 你希望模型稳定地使用某种特定的语气、格式、专业术语(比如让模型固定用某个行业的标准话术回复)
  • 你需要模型学会一种新的"任务模式",比如把一段病历自动整理成规定格式的诊断摘要,这种"怎么做"的能力比"知道什么事实"更重要
  • 知识本身相对稳定,不会经常变

而实际生产中很常见的做法是两者一起用:用微调让模型学会公司要求的说话风格、输出格式,同时用 RAG 给模型提供实时、准确的事实性内容。这两者不是二选一的关系,而是分别解决"怎么说"和"说什么"这两个不同的问题。

六、MCP 又是什么,跟 RAG 有关系吗

最后聊聊最近同样很火、也经常被人和 RAG 搞混的一个词:MCP(Model Context Protocol)

先说清楚它不是什么:MCP 不是一个新模型,也不是 RAG 的替代品。它是一套协议,也就是"标准接口规范",用来规定大模型和外部工具、外部数据源之间该怎么"对话"。

在 MCP 出现之前,如果你想让一个大模型应用同时接入公司的知识库、日历系统、工单系统这三个外部数据源,通常需要给每一个系统单独写一套对接代码,格式各不相同,接一个新的数据源就要重新写一遍适配逻辑。MCP 想解决的就是这个"重复造轮子"的问题:它定义了一套统一的协议,只要一个工具或数据源实现了这套协议,任何支持 MCP 的大模型应用都可以直接接上去用,不需要为每一个数据源单独定制对接代码。

它和 RAG 的关系是这样的:RAG 里"检索知识库"这一步,本身就可以通过 MCP 提供的一个标准化接口来完成------你可以把"检索向量数据库"包装成一个符合 MCP 规范的工具,模型需要查资料的时候,就按照统一的协议去调用它。也就是说,MCP 更像是"连接层"或者说"接口标准",而 RAG 是这个连接层之上要实现的其中一种具体能力("去查知识库")。两者不冲突,一个是"怎么接"的规范,一个是"接完之后具体做什么"的方案。

小结

这一篇把上一篇里"埋下的坑"基本都填上了:切分要尽量顺着语义边界来,避免把完整的句子拦腰截断;向量数据库解决的是"海量向量里怎么快速找最相似的"这个工程问题;检索效果不理想时,可以试试混合检索(关键词+语义)和重排序(先粗筛再精排)这两个组合拳;RAG 解决"模型知道什么",Agent 解决"模型能做什么",两者经常搭配而不是互相替代;RAG 和微调分别对应"说什么"和"怎么说",很多生产系统里两者是一起用的;而 MCP 是标准化"模型怎么接外部工具和数据"的协议层,RAG 可以是通过 MCP 暴露出来的众多能力之一。

把这些概念串起来看会发现,它们其实都是在回答同一类问题的不同侧面:怎么让一个语言能力很强的模型,真正在实际系统里可靠地干活。RAG 负责补知识,Agent 负责干具体的事,微调负责调整行为习惯,MCP 负责把这些拼在一起的接口标准化。理解了这几块各自的边界在哪,再看各种技术文章、产品介绍的时候,就不容易被一堆新名词绕晕了。

相关推荐
冰芒芒1 小时前
构建你的第一个 Agent 项目
python
gf13211111 小时前
【python_发送飞书卡片消息_直接组装JSON格式发送卡片】
python·json·飞书
弘毅 失败的 mian1 小时前
数组和指针基础
c语言·开发语言·经验分享·笔记
lzqrzpt2 小时前
临沂LED驱动电源制造工艺解析与工程选型避坑指南
python·制造
ai小陈2 小时前
Python 3.12与CUDA 12.8镜像实战:启动GPU实例后的五项自检
开发语言·人工智能·python·深度学习·ai·gpu算力
Thomas.Sir2 小时前
第48课:TensorFlow|TF模型线上部署入门【本地服务封装、接口快速开发】
人工智能·python·tensorflow
Bruce_Liuxiaowei2 小时前
录屏片段无损合并:从 ffmpeg concat 原理到 Python 自动化脚本
python·ffmpeg·自动化
AZaLEan__2 小时前
ts接口梳理
开发语言