上一篇把文档加载、切分、向量化并存进了 Milvus,知识库已经就位。这一篇讲 RAG 的后半场——检索与生成:用户的问题进来之后,如何从库里又快又准地捞出相关内容,如何把稠密和稀疏两路检索的结果融合排序,最后如何把检索结果喂给模型生成答案。走完这一篇,一个完整的 RAG 应用就闭环了。

一、检索流程总览

先回忆上篇结尾的分工:索引流程是离线的,把文档变成向量入库;检索流程是在线的,每次用户提问都要跑一遍:

  1. 把用户的问题用同一个嵌入模型转成向量(和入库时保持一致,否则不在同一个向量空间里,比对没有意义);
  2. 拿问题向量和库里的 chunk 向量做相似度比对,选出 Top-K;
  3. 把命中的原文拼进提示词,交给模型生成答案。

第一步的代码和上篇入库时如出一辙——BGE-M3 一次编码,同时拿到稠密和稀疏两种向量:

from FlagEmbedding import BGEM3FlagModel
from pymilvus import MilvusClient

client = MilvusClient(uri="http://localhost:19530")

def query_to_vector(query: str):
    model = BGEM3FlagModel(model_name_or_path="BAAI/bge-m3")
    result = model.encode(
        sentences=query,
        return_dense=True,   # 稠密向量:管语义
        return_sparse=True,  # 稀疏向量:管关键词
    )
    return result["dense_vecs"], result["lexical_weights"]

接下来的问题是:"比对"这一步具体怎么做。

二、KNN 与 ANN:精确检索和近似检索

比对相似度,最朴素的做法是 KNN(K 近邻搜索):把问题向量和库里每一个向量逐一算距离,返回最近的 K 个。它对应 Milvus 的 FLAT 索引(暴力检索),结果 100% 精确、零召回损失——代价是数据量一大,每次查询都要全库遍历,性能顶不住。

工程上的主流选择是 ANN(近似最近邻搜索):借助索引结构跳过大部分向量,不做全库遍历,用少量召回精度换来数量级的速度提升。上篇建索引时选的两种就是 ANN 家族的成员:

  • HNSW:稠密向量的多层图索引,从顶层粗略导航、逐层精确逼近;
  • SPARSE_INVERTED_INDEX:稀疏向量的倒排索引,先快速筛出包含查询词的候选文档再算分;
  • 另外常见的 IVF_FLAT 可以理解为"先聚类分桶,再在最近的几个桶里暴力搜索"——书架式管理,先找对书架,再在架内仔细翻。

一句话选型:小数据集或对召回有严格要求用 FLAT,常规场景用 HNSW 这类 ANN 索引。

三、单路检索:稠密与稀疏

3.1 稠密向量检索

client.search() 对稠密向量字段做检索:

def dense_vector_search(client: MilvusClient, query: str):
    dense_vector, _ = query_to_vector(query)
    result = client.search(
        collection_name="knowledge_base",
        anns_field="vector",                  # 检索哪个向量字段
        data=[dense_vector],                  # 问题对应的稠密向量
        limit=5,                              # Top-K
        search_params={"metric_type": "L2"},  # 与建索引时保持一致
        output_fields=["id", "text", "metadata"],
    )
    print(result)

有两个点值得注意。一是 metric_type 必须和建索引时一致——上篇稠密索引用的 L2(欧几里得距离),这里也得用 L2。二是读结果时别搞反方向:L2 是距离,值越小越相似,返回结果按距离从小到大排。

3.2 稀疏向量检索

换个字段、换个度量方式,代码几乎一样:

def sparse_vector_search(client: MilvusClient, query: str):
    _, sparse_vector = query_to_vector(query)
    result = client.search(
        collection_name="knowledge_base",
        anns_field="sparse_vector",
        data=[sparse_vector],
        limit=5,
        search_params={"metric_type": "IP"},  # 内积,与建索引时一致
        output_fields=["id", "text", "metadata"],
    )
    print(result)

稀疏检索用 **IP(内积)**度量,方向和 L2 相反:分数越高越匹配,结果从高到低排。

这个分数怎么理解?可以把稀疏检索看成一种带智能权重的关键词匹配。BGE-M3 给查询里的每个词打了"重要性分数"——比如查"监护人应该履行什么职责",模型可能给出 职责(0.4)、监护人(0.3)、履行(0.25)、什么(0.05)。内积做的事就是:把查询中每个词的权重乘以文档中对应词的权重,再加起来。文档里出现了"职责""监护人"这些高权重词,分数就高;只命中"什么""应该"这类虚词,分数就低。

而这个权重既不是单纯的词频,也不是 TF-IDF,是模型学出来的——综合了词频、区分度("的""是"这种到处都有的词权重天然低)和语义重要性。所以它比传统的"数词频"聪明得多。

两路检索的分工到这里就很清晰了:稠密看整体语义像不像,稀疏看关键词重不重叠、重不重要。各有擅长,谁也替代不了谁——那就都用上。

四、混合检索与 RRF 融合

4.1 hybrid_search:两路并发

Milvus 的 hybrid_search() 接受多个 AnnSearchRequest(每路一个),并发执行后把结果融合成一份统一排名:

from pymilvus import AnnSearchRequest, RRFRanker

def hybrid_search(client: MilvusClient, query: str):
    dense_vector, sparse_vector = query_to_vector(query)

    # 稠密一路
    dense_req = AnnSearchRequest(
        data=[dense_vector],
        anns_field="vector",
        param={"metric_type": "L2"},
        limit=5,
    )
    # 稀疏一路
    sparse_req = AnnSearchRequest(
        data=[sparse_vector],
        anns_field="sparse_vector",
        param={"metric_type": "IP"},
        limit=5,
    )

    result = client.hybrid_search(
        collection_name="knowledge_base",
        reqs=[dense_req, sparse_req],   # 多路检索请求
        limit=5,                        # 融合后最终返回的条数
        output_fields=["id", "text", "metadata"],
        ranker=RRFRanker(),             # 融合排序策略
    )
    return result

这里有个绕不过去的问题:稠密那路的得分是 L2 距离(越小越好),稀疏那路是内积(越大越好),量纲和方向都不一样,直接把分数加权平均是没有意义的。ranker=RRFRanker() 解决的就是这个问题。

4.2 RRF:只看排名,不看分数

RRF(Reciprocal Rank Fusion,倒数排名融合)的思路是彻底放弃原始分数,只看每个文档在各路检索中的排名。文档 d 的融合得分为:

score(d) = Σ  1 / (k + rank_i(d))     # 对每一路 i 求和

其中 rank_i(d) 是文档 d 在第 i 路中的名次(从 1 开始),k 是平滑常数(经典取值 60)。举个例子,文档 A 在稠密路排第 1、稀疏路排第 3:

稠密路贡献:1/(60+1) = 0.0164
稀疏路贡献:1/(60+3) = 0.0159
总分:0.0323

常数 k 的作用像一块"缓冲垫"。如果没有 k(即 k=0),排名第 1 得 1 分、第 2 只得 0.5 分、第 10 名只剩 0.1 分——头部差距被急剧放大,只要某文档在任意一路拿了第 1,就几乎"赢家通吃",融合失去意义。加上 k=60 之后,第 1 名和第 2 名的得分只差 1.8%,排到第 10 名也只降 13%,各路结果得以相对公平地竞争。k 越小头部差异影响越大(适合高精度场景),k 越大越平滑(适合多路融合),实践中 60 是经过验证的经典选择。

RRF 的整体性格可以概括为四条:无需归一化(只看排名,天然回避量纲问题)、抗噪声(k 防止头部过度主导)、鼓励共识(在多路都命中的文档得分高)、惩罚离散(只在一路露脸的文档得分低)。当多路得分不在一个维度上、且你希望"共识结果"胜出时,RRF 就是默认答案。Milvus 也提供 WeightedRanker(0.7, 0.3) 这类加权融合器,适合你明确想让某一路(比如语义)占更大话语权的场景。

顺带展开一下视野:多路检索并不限于稠密+稀疏两路,实践中还可以并发假设性问题检索、网络搜索、知识图谱查询等更多路,统一用 RRF 融合。更讲究的系统会做两阶段筛选——第一阶段用向量检索加 RRF 快速粗排,第二阶段再用专门的 Reranker 模型对候选集做慢而准的精排。本篇先把两路融合走通,这个骨架是通用的。

五、标量检索与数据管理

向量库不只会"比像不像",传统的"是不是"式查询同样支持。client.query() 走标量过滤,不涉及向量:

# 按文本内容模糊匹配
rows = client.query(
    collection_name="knowledge_base",
    filter='text like "%监护人%"',
    output_fields=["id", "text"],
    limit=5,
)

# 按 JSON 元数据字段过滤
rows = client.query(
    collection_name="knowledge_base",
    filter='metadata["source"] like "%sample%"',
    output_fields=["id", "metadata"],
    limit=5,
)

这类过滤条件也可以作为 filter 参数传给 search(),实现"先按元数据圈定范围、再做向量检索"——比如只在某个部门的文档里搜。这就是上篇坚持把 metadata 一起入库的原因之一。

知识库是要长期维护的,文档下线时对应的 chunk 也要删掉。delete() 按过滤条件删除实体:

client.delete(
    collection_name="knowledge_base",
    filter="id in [467901374367220711, 467901374367220712]",  # 也可以用其他字段的条件
)

六、最后一步:生成答案

检索结果到手,剩下的就回到了本系列第一、二篇的老本行——拼提示词、调模型。把命中 chunk 的原文拼接成上下文,连同用户问题一起交给模型:

from langchain.chat_models import init_chat_model

def rag_answer(client: MilvusClient, query: str):
    # 1. 混合检索
    rag_results = hybrid_search(client, query)

    # 2. 把命中的文本拼接成上下文
    context = "\n".join(hit["entity"]["text"] for hit in rag_results[0])

    # 3. 上下文 + 问题一起交给模型
    model = init_chat_model("openai:gpt-4o-mini")
    result = model.invoke([
        ("system", "你是一个专业的法律顾问"),
        ("user", f"请根据上下文回答用户问题。\n上下文:{context}\n用户的问题:{query}"),
    ])
    print(result.content)

rag_answer(client, "监护人应该履行什么职责")

模型不再凭记忆作答,而是"看着资料回答"——知识滞后和幻觉问题都被这一步化解了。想做得更完善,可以在提示词里加一句"如果上下文中没有相关信息,请直接说不知道",把幻觉的口子扎得更紧;也可以利用 metadata 在答案里标注出处,让回答可溯源。

七、小结与下一步

把上下两篇连起来,完整的 RAG 链路是:

  • 索引(离线):加载 → 切分 → 双向量编码 → 入库建索引;
  • 检索(在线):问题编码 → 稠密/稀疏两路 ANN 检索 → RRF 融合排序 → Top-K;
  • 生成:命中原文拼进提示词 → 模型基于上下文作答。

本篇的关键决策点:metric_type 必须与建索引时一致(L2 越小越像、IP 越大越像,方向别搞反);多路得分量纲不同用 RRF 融合,k=60 是经典默认;标量过滤与向量检索可以组合;知识库要配套数据管理。

至此,四大模块已经走完三个:Model I/O、Chains、RAG。但目前为止,所有流程都是开发者预先编排好的——哪一步检索、哪一步生成,代码写死了。如果任务本身是动态的呢?"查一下明天的天气,如果下雨就帮我取消户外日程"——该不该调用取消日程的接口,写代码时根本无法预知。这就轮到最后一个模块出场了:Agents,让模型自己决定下一步做什么。下一篇《Agents 上篇:工具调用与构建智能体》从工具调用的底层机制讲起。

老规矩,以官方文档为准,把每段代码亲手跑一遍。