你是不是也遇到过这种情况:RAG知识库明明建好了,向量也存进去了,可用户一问,检索出来的答案总是牛头不对马嘴?别急着怀疑Embedding模型,很可能是你的检索姿势不对。Milvus作为现在AI Agent和RAG的标配数据库,光会个基础的相似度搜索可远远不够。今天我就把实战中最高频的8种检索用法一次性讲透,帮你把知识库的准确率真正拉上来。
连接数据库和数据准备
python
from pymilvus import MilvusClient, DataType
import numpy as np
# 1. 连接
client = MilvusClient(uri="http://127.0.0.1:19530")
COLLECTION = "search_demo"
DIM = 128 # 向量维度。真实项目里这个值由你的 embedding 模型决定,不能乱填
# 重复跑脚本时先删掉旧集合,避免报"已存在"
if client.has_collection(COLLECTION):
client.drop_collection(COLLECTION)
# 2. 建索引参数
# 索引是向量检索的前提,没建索引只能全表扫
index_params = client.prepare_index_params()
index_params.add_index(
field_name="vector",
index_type="HNSW", # 图索引,数据量中等、要求低延迟时常用
metric_type="COSINE", # 余弦相似度,值越大越相似
params={"M": 16, "efConstruction": 100},
)
# 3. 建集合
# 这种写法是"快速建表":主键 + 一个向量字段 + 默认开启动态字段
# 动态字段的意思是不用提前声明,插入时多塞几个标量字段也能存进去
client.create_collection(
collection_name=COLLECTION,
dimension=DIM,
primary_field_name="id",
metric_type="COSINE",
auto_id=False, # 主键由我自己给,方便后面演示注
index_params=index_params,
)
# 4. 造一批数据
np.random.seed(42) # 固定随机种子,保证每次跑出来的结果一样,方便对照
data = []
for i in range(20):
data.append({
"id": i,
"vector": np.random.rand(DIM).astype(np.float32).tolist(),
"category": ["AI", "RAG", "Search"][i % 3], # 造三个分类,后面过滤和分组会用到
"score": float(i), # 再造一个数值字段,用来演示数值条件
})
result = client.insert(collection_name=COLLECTION, data=data)
client.flush(COLLECTION) // 连接服务器一定要加,不然会查不出数据
print(result, 'result')
# 5. 加载到内存
# 这一步最容易被漏掉。数据写入磁盘后必须 load 才能被检索
client.load_collection(COLLECTION)
# 随便造一个查询向量,后面所有例子都用它
# query_vec = np.random.rand(DIM).astype(np.float32).tolist()
query_vec = data[5].get("vector")
print("集合准备好了")
print(query_vec)
进一步检测数据是否真的插入成功,可查询
python
from database import client, COLLECTION, query_vec
# 1. 集合里到底有多少行
print(client.get_collection_stats(COLLECTION))
# {'row_count': 20} 就是对的;是 0 说明插入没成功,先查 insert 返回值
# 2. 索引建完了吗
print(client.describe_index(COLLECTION, "vector"))
# 重点看 pending_index_rows:不是 0 就说明还有行没进索引,需要等一会儿
# 3. 不走向量检索,直接用标量条件把行捞出来
rows = client.query(
collection_name=COLLECTION,
filter="id >= 0",
output_fields=["id"],
limit=100
)
print("能查到的行数:", len(rows))
# 能查到 20 行,说明数据是好的,问题就出在检索环节或可见性上
# 4. 强制用 Strong 一致性再查一次
res = client.search(
collection_name=COLLECTION, data=[query_vec], anns_field="vector",
limit=20,
search_params={
"metric_type": "COSINE",
"params": {"ef": 150}
},
output_fields=["category"],
consistency_level="Strong",
)
print("返回条数:", len(res[0]))
我碰到过的空结果,基本都落在这三种原因里:
-
原因一:刚插入就去查,没等 Milvus 同步。 连服务端时默认的一致性级别是 Bounded,它不保证立刻就能看到刚刚写进去的数据。本地 Lite 几乎没这个延迟,所以同一份代码在 Lite 上有结果、连服务端就是空。我最后就是在插入后补了一句
flush,立刻就查到了,这是改动最小的办法;想从根上避免,也可以在建集合和检索时都写上consistency_level="Strong"。这个原因卡了我最久,因为代码看起来完全没问题。 -
原因二:索引还没建完。
describe_index里pending_index_rows不为 0,说明还有数据在排队入索引,这时候查得到与否全看运气。等它变成 0,或者先调小数据量再试。 -
原因三:查询向量和库里的数据本来就没什么关系。 这条是我自己上一版示例的毛病:用
np.random.rand()现造一个查询向量,高维空间里两个独立的随机向量相似度普遍很低,HNSW 又是近似索引,很容易召回为空。现在改成直接拿库里第 6 条向量来查,它自己一定在库里,结果一定不为空,也方便对照。
如果三步都正常、还是要么空要么不对,就往更笨的地方查:uri 连的是哪个库、集合名有没有大小写差异、插数据时是不是插到了另一个 collection。这类问题不报错,但结果一定不对。
还有一点值得提前知道:FLAT 索引不会出现这种情况。 它不做近似,全量比对,小数据量下结果最稳,代价是慢。我做演示和排查时会临时换成 FLAT,先确认数据链路是通的,再切回 HNSW 调参数。
单向量ANN搜索(检索一)
ANN 是 Approximate Nearest Neighbor,近似最近邻。加"近似"两个字,是因为它不会精确比完所有数据,而是走索引图,牺牲一点点精度换毫秒级的响应。数据量小的时候感觉不出来,上了千万级就很明显。
我实际的用法: 内部知识库问答。同事问"怎么申请项目经费",先把问题过一遍 embedding 模型变成向量,然后检索最相似的几个文档切片,拼进 prompt 交给大模型。整条 RAG 链路里,这一步是最基础的,也是最先出问题的------召回不准,后面怎么调 prompt都没用。
python
from src.database import COLLECTION, client, query_vec
def ann_single_search():
"""最基础的 TopK 检索:一条向量,返回最相似的 limit 条"""
res = client.search(
collection_name=COLLECTION,
data=[query_vec], # 注意是列表。哪怕只查一条,也要包一层
anns_field="vector", # 用哪个向量字段检索
limit=5, # 返回几条
search_params={
"metric_type": "COSINE",
"params": {"ef": 50}, # 搜索时的探索范围,越大越准越慢
},
output_fields=["category", "score"], # 除主键和相似度外,还想带出哪些字段
)
# res 是二维的:res[0] 是第一个查询向量的结果
for hit in res[0]:
# hit["distance"] 在这一栏里其实是"相似度分数",不是距离
print(f"id={hit['id']} 相似度={hit['distance']:.4f} 分类={hit['entity']['category']}")
ann_single_search()
几个参数简单说一下我现在的理解:
-
limit返回条数。它不是"一定要凑够",数据不够就只能返回少的。 -
ef只在 HNSW 里有效,可以理解成搜索时候选池的大小。调大召回更高,延迟也更高。我一般从 50 开始试。 -
output_fields只写你真正要用的字段。全带出来会让返回体变大,白占带宽。
多向量搜索(检索二)
需求来自离线任务。我们有一批待审核的短视频封面,几十张图要一起去素材库里找相似项,判断有没有重复投放。如果写个 for 循环一条条查,网络往返次数会很难看,跑一批要等很久。
批量写法就是把 data 从一个向量变成一个向量列表,服务端一次算完,返回二维结果。顺带一提,离线给多个用户做候选商品召回也常用这种方式。
python
from src.database import data, client, COLLECTION
def ann_batch_search():
"""一次查多条向量,服务端会返回等长的二维结果"""
# 假装这是两张图片、或者两个用户的向量
q1 = data[0].get('vector')
q2 = data[10].get('vector')
res = client.search(
collection_name=COLLECTION,
data=[q1, q2], # 关键就在这:列表里放多条
anns_field="vector",
limit=3,
search_params={"metric_type": "COSINE", "params": {"ef": 50}},
output_fields=["category"],
)
# res[0] 对应 q1 的结果,res[1] 对应 q2 的结果,顺序和入参一致
for i, hits in enumerate(res):
print(f"---- 第 {i + 1} 条查询 ----")
for hit in hits:
print(f" id={hit['id']} 相似度={hit['distance']:.4f}")
ann_batch_search()
加标量条件,标准过滤(检索三)
这是业务里用得最多的一种。向量负责"像不像",标量条件负责"能不能用",两个一起才构成一个真实的检索需求。
电商那边是这样: 用户搜"轻便的登山背包",向量召回语义相近的商品,同时必须满足库存大于 0、价格在某区间、类目正确。这三条不满足,再像也不能推。知识库那边是这样: 只检索本部门可见、且发布时间在两年内的文档。
python
def filter_standard_search():
"""在相似检索上叠加标量条件,条件放在 filter 里"""
res = client.search(
collection_name=COLLECTION,
data=[query_vec],
anns_field="vector",
limit=5,
# filter 是 Milvus 自己的表达式语法,不是 SQL
# 字符串用双引号,数值直接写,多个条件用 and / or 连接
filter='category == "RAG" and score > 5',
search_params={"metric_type": "COSINE", "params": {"ef": 50}},
output_fields=["category", "score"],
)
for hit in res[0]:
print(f"id={hit['id']} 相似度={hit['distance']:.4f} 分类={hit['entity']['category']}")
filter_standard_search()
关于 filter,我第一次写错了好几次,这里提醒三点:
-
不是 SQL。 写
where category = 'RAG'一定报错,这里用==,字符串用双引号。 -
支持的写法有限。 比较、in、like、范围都支持,但别指望它像 SQL 一样什么函数都能用。复杂逻辑提前在业务层算好更省事。
-
过滤字段可以建标量索引。 数据量大、过滤又频繁时,这一步能省不少时间。我一开始完全没意识到,跑慢了才发现。
迭代过滤(检索四)
标准过滤有一个前提:满足条件的数据得有一定数量。如果条件特别苛刻,比如某家医院只查"糖尿病 + 临床指南 + 未归档"的文献,符合的可能就那么几十条,而向量索引先返回的那批候选里一条都不满足,结果就是 召回为空 ------接口没报错,但结果就是空的,最难查。
迭代过滤就是为这种情况准备的。它不再"先取一批再筛",而是顺着索引一条条往下走,逐条判断条件,攒够 limit 条合法结果才停。
开启方式只是多一个 hint,写法上没什么门槛:
python
def filter_iterative_search():
"""条件苛刻、命中稀疏时,用迭代过滤保证能取到结果"""
res = client.search(
collection_name=COLLECTION,
data=[query_vec],
anns_field="vector",
limit=3,
filter='category == "AI" and score > 3',
search_params={
"metric_type": "COSINE",
"params": {
"ef": 50,
"hints": "iterative_filter", # 就这一行,打开迭代模式
},
},
output_fields=["category", "score"],
)
for hit in res[0]:
print(f"id={hit['id']} 相似度={hit['distance']:.4f}")
filter_iterative_search()
但它不是万能的。我自己的使用边界是:先标准过滤,结果明显偏少或者空,再换迭代。 因为迭代是逐条推进的,命中率特别低的时候会一直往后扫,延迟反而上去。这个取舍,压测一下心里就有数了。
范围搜索(检索五)
TopK 有个天生的毛病:不管你像不像,它都硬给你 N 条。有些需求不能这样。
素材库那边我遇到的就是这个。 推荐相似素材时,相似度高过 0.95 的基本是同一个视频重复上传,推给用户没意义;低于 0.6 的相关性太差,也不行。真正想要的是中间那一段:风格接近但不重复。论文查重同理,只把 0.8 到 0.95 这段疑似抄袭的捞出来人工复核,两头都排除。
python
def range_search():
"""只返回相似度落在 [radius, range_filter] 之间的结果"""
res = client.search(
collection_name=COLLECTION,
data=[query_vec],
anns_field="vector",
limit=10, # 上限,不是承诺条数
search_params={
"metric_type": "COSINE",
"params": {
"radius": 0.6, # COSINE 下这是下界(要求大于等于它)
"range_filter": 0.9, # COSINE 下这是上界
},
},
output_fields=["category"],
)
for hit in res[0]:
print(f"id={hit['id']} 相似度={hit['distance']:.4f}")
range_search()
这里有个坑我踩得挺实在:两个参数的方向跟度量方式有关。 COSINE 和 IP 是越大越像,radius 当小边界、range_filter 当大边界;换成 L2 就是越小越像,两个参数的关系正好反过来。我第一次照着 L2 的例子写成 COSINE,结果一条都查不出来,查了半天文档才发现方向错了。
还有一点要习惯:返回条数是不固定的。 业务层如果按"一定有 10 条"来写,后面会很别扭,最好提前处理成"可能为空"的情况。
分组搜索(检索六)
这个需求是被 RAG 逼出来的。一篇长文档切成几十个 chunk,向量都长得差不多,检索 Top10 出来八条来自同一份文档,剩下两条来自另一篇。答案看起来挺完整,实际上信息来源单一,模型很容易被一段话带偏。
分组搜索就是按某个字段分一下,每组只留少量最优。**我现在的 RAG 检索基本都会加上 ** group_by_field="doc_id" 和 group_size=1,然后再把结果给模型,覆盖的资料明显丰富了一些。
电商那边是另一个用法: 用户搜"无线耳机",不希望前十屏全是同一个品牌。按品牌分组、每品牌留两条,相关性和多样性之间能有个平衡。
python
def group_search():
"""按 category 分组,每个分组只返回 1 条最优结果"""
res = client.search(
collection_name=COLLECTION,
data=[query_vec],
anns_field="vector",
limit=10,
group_by_field="category", # 按哪个字段分组
group_size=1, # 每组返回几条
strict_group_size=True, # 严格要求每组数量,不够时也不拿别的组补
search_params={"metric_type": "COSINE", "params": {"ef": 50}},
output_fields=["category"],
)
for hit in res[0]:
print(f"分组={hit['entity']['category']} id={hit['id']} 相似度={hit['distance']:.4f}")
group_search()
按主键精确取数:query 而不是 search(检索七)
检索出来的结果通常只有 id 和几个标量字段,原文、标题、链接、封面图往往在另外的地方。这时候要做的是"拿回来",不是"再找一遍"。
**知识库里的典型用法:**ANN 拿到 10 个 chunk 的 id,再用 query 按 id 一次性取回完整文本,拼进 prompt。比起在每个 chunk 里塞几千字,这个做法省存储也省带宽,还方便后面按需取。
python
def primary_key_search():
"""按主键精确取数据,不做向量相似度计算"""
res = client.query(
collection_name=COLLECTION,
ids=[1, 5, 9], # 主键列表,一次可以传很多个
output_fields=["category", "score"],
)
for item in res:
print(f"id={item['id']} 分类={item['category']} 分数={item['score']}")
primary_key_search()
两者的区别,用一句话区分就够了:
| 接口 | 它在回答什么问题 | 走不走向量索引 | 我什么时候用它 |
|---|---|---|---|
| search | 哪些数据和这条最像 | 走,结果按相似度排序 | 召回阶段 |
| query | 哪几条符合我指定的条件 | 不走,直接按主键或表达式筛 | 按 id 回填详情、按条件核对数据 |
稠密 + 稀疏一起上:多向量混合搜索(检索八)
单一稠密向量有个软肋:关键词容易被抹平。 用户搜"适合新手的自动挡轿车","自动挡"这三个字是硬条件,但语义向量只记住了"好开的车",可能召回一堆手动挡。反过来只用关键词检索,用户把"自动挡"写成"AT",字面对不上又漏了。
题库检索就是被这个坑过的: 学生搜"二次函数 顶点式",稠密向量能理解他想找什么,但"顶点式"这个术语必须命中;两路都查一遍再融合,效果才稳得住。汽车资讯、法律条文这类术语密集的领域,基本都是这个套路。
混合检索要多写几步,因为一个集合里得放两个向量字段。
python
import numpy as np
from pymilvus import MilvusClient, DataType, AnnSearchRequest, RRFRanker, WeightedRanker
from src.database import client, DIM
def hybrid_multi_vector_search():
"""一个集合两个向量字段,两路检索各自出候选,再融合排名"""
col = "hybrid_demo"
if client.has_collection(col):
client.drop_collection(col)
# 1. 显式声明 schema
# 要放稀疏向量,就不能再用快速建表了,字段得自己声明
schema = client.create_schema(auto_id=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("dense_vec", DataType.FLOAT_VECTOR, dim=DIM) # 稠密向量:语义
schema.add_field("sparse_vec", DataType.SPARSE_FLOAT_VECTOR) # 稀疏向量:关键词权重
schema.add_field("category", DataType.VARCHAR, max_length=64)
index_params = client.prepare_index_params()
index_params.add_index(
field_name="dense_vec", index_type="HNSW",
metric_type="COSINE", params={"M": 16, "efConstruction": 100},
)
index_params.add_index(
field_name="sparse_vec", index_type="SPARSE_INVERTED_INDEX",
metric_type="IP", # 稀疏向量一般用内积
)
client.create_collection(collection_name=col, schema=schema, index_params=index_params, consistency_level="Strong")
# 2. 造数据
rows = []
for i in range(10):
rows.append({
"id": i,
"dense_vec": np.random.rand(DIM).astype(np.float32).tolist(),
# 稀疏向量用字典表示:位置 -> 权重,没写的位置就是 0
"sparse_vec": {0: 0.5, 5: 0.8, i: 1.0},
"category": "RAG",
})
client.insert(collection_name=col, data=rows)
client.flush(col)
client.load_collection(col)
# 3. 两路查询
q_dense = np.random.rand(DIM).astype(np.float32).tolist()
q_sparse = {0: 0.6, 7: 0.9}
# 每一路都要声明:查哪个字段、用什么参数、先取多少
req_dense = AnnSearchRequest(
data=[q_dense], anns_field="dense_vec",
param={"metric_type": "COSINE", "params": {"ef": 30}},
limit=5,
)
req_sparse = AnnSearchRequest(
data=[q_sparse], anns_field="sparse_vec",
param={"metric_type": "IP"},
limit=5,
)
# 4.RRFRanker 就是RRF倒数排名融合,k默认60
ranker = RRFRanker(k=60)
# 如果想用加权融合,换成下面这句
# ranker = WeightedRanker(0.7, 0.3) # 稠密0.7,稀疏0.3
res = client.hybrid_search(
collection_name=col,
reqs=[req_dense, req_sparse],
ranker=ranker,
limit=5,
output_fields=["category"],
)
for hit in res[0]:
print(f"id={hit['id']} 融合分={hit['distance']:.4f}")
hybrid_multi_vector_search()
两个排序器的区别,说人话就是:
-
RRFRanker: 只看每一路里排第几,第一名的贡献固定,跟具体分数无关。所以不怕两路分数口径不同,很省心,我默认用它。
-
WeightedRanker: 你自己给每路一个权重,比如稠密 0.7、稀疏 0.3。可控性强,但权重得靠实验调,不是拍脑袋能定的。
另外两个细节:每个 AnnSearchRequest 一次只能带一个查询向量 (批量查多个问题要拆成多次调用),以及融合时,每路的 limit 和最终结果的 limit 是两回事------前面取得宽一些,融合才挑得出来。
八种检索的速查表
写到这,把上面这些收成一张表,方便你以后直接照着找。
| 能力 | 关键写法 | 我见过的真实场景 | 想提醒的一句 |
|---|---|---|---|
| 单向量 ANN | search(data=[v]) |
知识库问答的召回第一步 | limit 和 ef 要一起看 |
| 批量 ANN | search(data=[v1, v2]) |
批量素材查重、离线多用户召回 | 返回是二维的,别少写一层索引 |
| 标准过滤 | search + filter |
电商筛库存价格、文档按权限和时间筛 | 表达式不是 SQL |
| 迭代过滤 | params.hints="iterative_filter" |
医疗文献、租户隔离的私有库 | 命中少时才用,别默认开着 |
| 范围搜索 | radius + range_filter |
素材相似推荐、论文查重 | 方向随度量方式变,条数不固定 |
| 分组搜索 | group_by_field + group_size |
RAG 结果打散、电商品牌多样性 | 不做聚合统计,v2.4 起支持 |
| 主键查询 | query(ids=[...]) |
按 id 回填原文和详情 | 它是精确取数,不是相似检索 |
| 多向量混合 | hybrid_search + ranker |
题库、法规、汽车资讯这类术语密集场景 | 融合前每路都要多取一些 |
我踩过的坑
下面这些不按重要程度排,就是按我遇到的顺序。写完回头看,几乎每一个都能在十分钟内解决,前提是知道它存在。
-
忘了 load。 数据插进去了,search 就是查不到。原因是集合没加载到内存,加一句
load_collection就好。 -
连接服务端忘记了flush ,Bounded 一致性的同步延迟:数据已经写进去了,但检索还看不到。
flush相当于把这次写入正式提交并写入磁盘,可见性自然就跟上了。 -
range 的两个参数写反。 COSINE 和 L2 的上下界方向相反,写反了不会报错,只是安静地返回空列表。
-
filter 当成 SQL 写。
=要写成==,单引号要换成双引号,不然后面全是语法报错。 -
批量结果少取一层。
res是二维的,直接for hit in res拿到的是一整个列表,打印出来会一头雾水。 -
混合检索里每个请求想塞多条向量 。一个
AnnSearchRequest只能带一个向量,多问题得多次调用。 -
以为分组搜索能当统计用。 它只负责打散结果,不给你 count 和 sum。
-
输出字段带太多。 把整段原文也放进
output_fields,结果体一下子胖起来,网络成为瓶颈。按需取,再用 query 回填更合适。
总结
本文结合小案例介绍了,Milvus的八种检索能力,希望对你有所帮助,感谢收看