Milvus 8种检索能力实践

0 阅读17分钟

你是不是也遇到过这种情况:RAG知识库明明建好了,向量也存进去了,可用户一问,检索出来的答案总是牛头不对马嘴?别急着怀疑Embedding模型,很可能是你的检索姿势不对。Milvus作为现在AI Agent和RAG的标配数据库,光会个基础的相似度搜索可远远不够。今天我就把实战中最高频的8种检索用法一次性讲透,帮你把知识库的准确率真正拉上来。

连接数据库和数据准备

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)

进一步检测数据是否真的插入成功,可查询


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都没用。


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 从一个向量变成一个向量列表,服务端一次算完,返回二维结果。顺带一提,离线给多个用户做候选商品召回也常用这种方式。

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、价格在某区间、类目正确。这三条不满足,再像也不能推。知识库那边是这样: 只检索本部门可见、且发布时间在两年内的文档。

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,写法上没什么门槛:

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 这段疑似抄袭的捞出来人工复核,两头都排除。

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,然后再把结果给模型,覆盖的资料明显丰富了一些。

电商那边是另一个用法: 用户搜“无线耳机”,不希望前十屏全是同一个品牌。按品牌分组、每品牌留两条,相关性和多样性之间能有个平衡。

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 里塞几千字,这个做法省存储也省带宽,还方便后面按需取。

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”,字面对不上又漏了。

题库检索就是被这个坑过的: 学生搜“二次函数 顶点式”,稠密向量能理解他想找什么,但“顶点式”这个术语必须命中;两路都查一遍再融合,效果才稳得住。汽车资讯、法律条文这类术语密集的领域,基本都是这个套路。

混合检索要多写几步,因为一个集合里得放两个向量字段。


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 是两回事——前面取得宽一些,融合才挑得出来。

八种检索的速查表

写到这,把上面这些收成一张表,方便你以后直接照着找。

能力关键写法我见过的真实场景想提醒的一句
单向量 ANNsearch(data=[v])知识库问答的召回第一步limit 和 ef 要一起看
批量 ANNsearch(data=[v1, v2])批量素材查重、离线多用户召回返回是二维的,别少写一层索引
标准过滤search + filter电商筛库存价格、文档按权限和时间筛表达式不是 SQL
迭代过滤params.hints="iterative_filter"医疗文献、租户隔离的私有库命中少时才用,别默认开着
范围搜索radius + range_filter素材相似推荐、论文查重方向随度量方式变,条数不固定
分组搜索group_by_field + group_sizeRAG 结果打散、电商品牌多样性不做聚合统计,v2.4 起支持
主键查询query(ids=[...])按 id 回填原文和详情它是精确取数,不是相似检索
多向量混合hybrid_search + ranker题库、法规、汽车资讯这类术语密集场景融合前每路都要多取一些

我踩过的坑

下面这些不按重要程度排,就是按我遇到的顺序。写完回头看,几乎每一个都能在十分钟内解决,前提是知道它存在。

  1. 忘了 load。 数据插进去了,search 就是查不到。原因是集合没加载到内存,加一句 load_collection 就好。

  2. 连接服务端忘记了flush ,Bounded 一致性的同步延迟:数据已经写进去了,但检索还看不到。flush 相当于把这次写入正式提交并写入磁盘,可见性自然就跟上了。

  3. range 的两个参数写反。 COSINE 和 L2 的上下界方向相反,写反了不会报错,只是安静地返回空列表。

  4. filter 当成 SQL 写。= 要写成 ==,单引号要换成双引号,不然后面全是语法报错。

  5. 批量结果少取一层。res 是二维的,直接 for hit in res 拿到的是一整个列表,打印出来会一头雾水。

  6. 混合检索里每个请求想塞多条向量。一个 AnnSearchRequest 只能带一个向量,多问题得多次调用。

  7. 以为分组搜索能当统计用。 它只负责打散结果,不给你 count 和 sum。

  8. 输出字段带太多。 把整段原文也放进 output_fields,结果体一下子胖起来,网络成为瓶颈。按需取,再用 query 回填更合适。

总结

本文结合小案例介绍了,Milvus的八种检索能力,希望对你有所帮助,感谢收看