上一篇把文档加载、切分、向量化并存进了 Milvus,知识库已经就位。这一篇讲 RAG 的后半场——检索与生成:用户的问题进来之后,如何从库里又快又准地捞出相关内容,如何把稠密和稀疏两路检索的结果融合排序,最后如何把检索结果喂给模型生成答案。走完这一篇,一个完整的 RAG 应用就闭环了。
一、检索流程总览
先回忆上篇结尾的分工:索引流程是离线的,把文档变成向量入库;检索流程是在线的,每次用户提问都要跑一遍:
- 把用户的问题用同一个嵌入模型转成向量(和入库时保持一致,否则不在同一个向量空间里,比对没有意义);
- 拿问题向量和库里的 chunk 向量做相似度比对,选出 Top-K;
- 把命中的原文拼进提示词,交给模型生成答案。
第一步的代码和上篇入库时如出一辙——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 上篇:工具调用与构建智能体》从工具调用的底层机制讲起。
老规矩,以官方文档为准,把每段代码亲手跑一遍。







还没有公开留言,成为第一个写下回声的人。