RAG学习(四)——使用混合检索进行检索优化

检索优化

一、混合检索

混合检索(Hybrid Search)是一种结合了 稀疏向量(Sparse Vectors)密集向量(Dense Vectors) 优势的先进搜索技术。旨在同时利用稀疏向量的关键词精确匹配能力和密集向量的语义理解能力,以克服单一向量检索的局限性,从而在各种搜索场景下提供更准确、更鲁棒的检索结果。

在本节中,我们将首先分析这两种核心向量的特性,然后探讨它们如何融合,最后通过milvus实现混合检索。

1.1 什么是稀疏向量,什么是密集向量

稀疏向量(Sparse Vector)

  • 定义 :基于词表(Vocabulary)的高维向量表示。
    • 每个维度对应一个词。
    • 如果词没出现 → 该维度就是 0。
    • 出现了 → 该维度是一个权重(TF、TF-IDF、BM25 权重等)。
  • 特点
    1. 高维:维度 = 词表大小,可能是几十万甚至几百万。
    2. 稀疏:大多数维度是 0,只有少数非零。
    3. 可解释:非零的位置直接对应文本里的词。
    4. 计算方式:点积(TF-IDF)或 BM25 公式。

密集向量(Dense Vector)

  • 定义 :通过神经网络(如 BERT、Sentence-BERT、OpenAI Embeddings)把文本映射到一个 固定维度的连续向量空间

    • 每个维度没有直接的词义解释,而是语义特征的组合。
  • 特点

    1. 低维:通常是 128, 512, 768, 1536 维等。
    2. 稠密:几乎所有维度都有非零值。
    3. 不可解释:维度不能直接解释成"apple"或"banana",而是抽象语义特征。
    4. 计算方式:余弦相似度 / 内积 / L2 距离。

接下来我们举一个有关稀疏向量的例子:

设想有一个词表(Vocabulary),比如包含 10,000 个单词。那每个文本(query 或文档)就能表示为一个 10,000 维的向量

  • 大部分词在文本里不会出现 → 对应维度是 0
  • 出现过的词会有一个权重(频率、TF-IDF、BM25 权重等)。

假设词表只有 6 个词:

词语 索引位置
apple 0
orange 1
banana 2
fruit 3
car 4
house 5

现在用户输入 query:

arduino 复制代码
"apple banana fruit"

(1) One-hot(最简单理解版)

只表示词出现(出现=1,不出现=0):

复制代码
q = [1, 0, 1, 1, 0, 0]

解释:

  • apple → 出现 → 1
  • orange → 没出现 → 0
  • banana → 出现 → 1
  • fruit → 出现 → 1
  • car/house → 没出现 → 0

(2)TF-IDF

假设算出来的权重是:

  • apple: 1.2
  • banana: 0.9
  • fruit: 1.5

那么向量就是:

复制代码
q = [1.2, 0, 0.9, 1.5, 0, 0]

这就是典型的 稀疏向量 ------虽然是 6 维小向量,但在实际系统里可能是 100k 维甚至 1M 维,不过仍然只有几个非零值。


密集向量就非常简单了,例如我们的word2vec方法,以及其他的embedding方法,都可以将文本映射到固定维度的向量,例如Query = "apple banana",经过 Embedding 模型,得到一个 768 维向量(所有维度几乎都非零,没有直观词义):

复制代码
q_dense = [0.12, -0.08, 0.33, ..., -0.27]

1.2 混合检索方法概述

在上文提到,混合检索是稀疏向量与稠密向量的混合运算,具体可以被以下几点概括:

  1. 输入 query → embedding 模型

    • 得到 q d e n s e q_{dense} qdense。

    • 同时也生成 q s p a r s e q_{sparse} qsparse(如 TF-IDF)。

  2. 找到候选集合

    • 对密集向量部分:通过 IVF / HNSW 等 ANN 索引找到候选集合(相似向量)。

    • 对稀疏向量部分:通过倒排表找到匹配文档。

  3. 计算相似度分数

    • 稀疏部分:计算 BM25 或向量点积。

    • 密集部分:计算余弦 / 内积 / L2 距离。

    • 然后融合:
      f i n a l s c o r e ( q , v ) = α ⋅ s c o r e s p a r s e ( q , v ) + ( 1 − α ) ⋅ s c o r e d e n s e ( q , v ) final_{score}(q,v)=\alpha⋅score_{sparse}(q,v)+(1−\alpha)⋅score_{dense}(q,v) finalscore(q,v)=α⋅scoresparse(q,v)+(1−α)⋅scoredense(q,v)

  4. 排序 & 返回结果

    • 按照 final_score 排序,返回 top-k。

1.3 稀疏向量------BM25打分公式

BM25(Best Matching 25)是一种改进的 TF-IDF 排序算法,本质是给每个 (query, document) 对打一个相关性分数。注意:严格来说,我们不需要先把稀疏向量显式地构造出来,再去算 BM25。

我们来简单理解以下这个分数的作用或者说它达到的效果:

  • 如果 query 中的词在文档里出现得多 → 分数上升。
  • 但如果某个词在文档里无限重复,也不能无限加分 → TF 饱和。
  • 罕见词(idf 高)更重要 → 稀有词的权重大。
  • 短文档和长文档要公平比较 → 文档长度归一化。

BM25基本公式如下:

给定一个 query q = { t_1,t_2,...,t_m } ,文档 ,文档 ,文档d的BM25分数是:
B M 25 ( q , d ) = ∑ t ∈ q I D F ( t ) ⋅ f ( t , d ) ⋅ ( k 1 + 1 ) f ( t , d ) + k 1 ⋅ ( 1 − b + b ⋅ ∣ d ∣ a v g d l ) \mathrm{BM25}(q,d)=\sum_{t\in q}IDF(t)\cdot\frac{f(t,d)\cdot(k_1+1)}{f(t,d)+k_1\cdot\left(1-b+b\cdot\frac{|d|}{avgdl}\right)} BM25(q,d)=t∈q∑IDF(t)⋅f(t,d)+k1⋅(1−b+b⋅avgdl∣d∣)f(t,d)⋅(k1+1)

其中:

  • f ( t , d ) f(t,d) f(t,d):词 t t t在文档 d d d中的出现次数 (Term Frequency) 。
  • ∣ d ∣ |d| ∣d∣:文档长度(词数)。
  • a v g d l avgdl avgdl:所有文档的平均长度。
  • k 1 k_1 k1:调节TF 饱和(常取 1.2-2.0)。
  • b b b:调节长度归一化(常取 0.75)。
  • I D F ( t ) IDF(t) IDF(t):逆文档频率 (Inverse Document Frequency) 。

经典 BM25 里的 IDF 定义是:
I D F ( t ) = log ⁡ N − n t + 0.5 n t + 0.5 IDF(t)=\log\frac{N-n_t+0.5}{n_t+0.5} IDF(t)=lognt+0.5N−nt+0.5

其中:

  • N : N: N:文档总数。
  • n t n_t nt:包含词 t t t的文档数量。

稀有词 → n t \to n_t →nt 小 → I D F \to\mathsf{IDF} →IDF大 → I D F \to\mathsf{IDF} →IDF贡献更大。


举一个例子,假设语料库有 1,000 篇文档:

  • 词表中有 "apple", "banana", "fruit"。

  • query = "apple banana"

现在我们有一个文档 d:

复制代码
doc = "apple apple banana banana banana fruit"

则, ∣ d ∣ = 6 |d|=6 ∣d∣=6, a v g d l = 5 avgdl=5 avgdl=5,我们可以得到:

  • f ( a p p l e , d ) = 2 f(\mathrm{apple},d)=2 f(apple,d)=2
  • f ( b a n a n a , d ) = 3 f(\mathrm{banana},d)=3 f(banana,d)=3
  • f ( f r u i t , d ) = 1 f(\mathrm{fruit},d)=1 f(fruit,d)=1

假设:

  • n a p p l e = 100 , I D F ( a p p l e ) = log ⁡ ( ( 1000 − 100 + 0.5 ) / ( 100 + 0.5 ) ) ≈ 2.20 n_{\mathrm{apple}}=100\mathrm{,IDF}(apple)=\log((1000-100+0.5)/(100+0.5))\approx2.20 napple=100,IDF(apple)=log((1000−100+0.5)/(100+0.5))≈2.20

  • n b a n a n a = 200 , I D F ( b a n a n a ) ≈ 1.70 n_\mathrm{banana}=200\mathrm{,}IDF(banana)\approx1.70 nbanana=200,IDF(banana)≈1.70

  • n f r u i t = 500 , I D F ( f r u i t ) ≈ 0.69 n_\mathrm{fruit}=500\mathrm{,}IDF(fruit)\approx0.69 nfruit=500,IDF(fruit)≈0.69

取参数 k 1 = 1.5 k_1=1.5 k1=1.5, b = 0.75 b=0.75 b=0.75。我们可以计算得分:

  • apple的贡献:
    2 ⋅ ( 1.5 + 1 ) 2 + 1.5 ⋅ ( 1 − 0.75 + 0.75 ⋅ 6 / 5 ) = 5 2 + 1.5 ⋅ ( 1 − 0.75 + 0.9 ) = 5 2 + 1.5 ⋅ 1.15 = 5 3.725 ≈ 1.34 则 2.20 ⋅ 1.34 ≈ 2.95 \frac{2\cdot(1.5+1)}{2+1.5\cdot(1-0.75+0.75\cdot6/5)}=\frac{5}{2+1.5\cdot(1-0.75+0.9)}=\frac{5}{2+1.5\cdot1.15}=\frac{5}{3.725}\approx1.34 \\ \text{则 } 2.20\cdot1.34\approx2.95 2+1.5⋅(1−0.75+0.75⋅6/5)2⋅(1.5+1)=2+1.5⋅(1−0.75+0.9)5=2+1.5⋅1.155=3.7255≈1.34则 2.20⋅1.34≈2.95

  • banana的贡献:
    3 ⋅ ( 2.5 ) 3 + 1.5 ⋅ ( 1 − 0.75 + 0.9 ) = 7.5 3 + 1.5 ⋅ 1.15 = 7.5 4.725 ≈ 1.59 则 1.70 ⋅ 1.59 ≈ 2.70 \frac{3\cdot(2.5)}{3+1.5\cdot(1-0.75+0.9)}=\frac{7.5}{3+1.5\cdot1.15}=\frac{7.5}{4.725}\approx1.59 \\ \text{则 }1.70\cdot1.59\approx2.70 3+1.5⋅(1−0.75+0.9)3⋅(2.5)=3+1.5⋅1.157.5=4.7257.5≈1.59则 1.70⋅1.59≈2.70

  • fruit的贡献:由于query中没有提到,分子部分的频率就是0,故不需要计算

最后,我们计算得到最终的BM25分数:
B M 25 ( q , d ) = 2.95 + 2.70 = 5.65 \mathrm{BM25}(q,d)=2.95+2.70=5.65 BM25(q,d)=2.95+2.70=5.65

其实BM25 本质就是 带有 TF 饱和、长度归一化的稀疏向量点积 。计算时就是:query 每个词的 IDF × 文档里该词的 TF 权重,再累加。

1.4 混合检索的融合策略

混合检索通常并行执行两种检索算法,然后将两组异构的结果集融合成一个统一的排序列表。以下是两种主流的融合策略:

1.4.1 倒数排序融合 (Reciprocal Rank Fusion, RRF)

RRF 不关心不同检索系统的原始得分,只关心每个文档在各自结果集中的排名。其思想是:一个文档在不同检索系统中的排名越靠前,它的最终得分就越高。

其计分公式为:
R R F s c o r e ( d ) = ∑ i = 1 k 1 r a n k i ( d ) + c RRF_{score}(d) = \sum_{i=1}^{k} \frac{1}{rank_{i}(d) + c} RRFscore(d)=i=1∑kranki(d)+c1

其中:

  • d d d 是待评分的文档。
  • k k k 是检索系统的数量(这里是2,即稀疏和密集)。
  • r a n k i ( d ) rank_{i}(d) ranki(d) 是文档 d d d 在第 i i i 个检索系统中的排名。
  • c c c 是一个常数(通常设为60),用于降低排名靠后文档的权重,避免它们对结果产生过大影响。
1.4.2 加权线性组合

这个方法就是我们之前提到过的
f i n a l s c o r e ( q , v ) = α ⋅ s c o r e s p a r s e ( q , v ) + ( 1 − α ) ⋅ s c o r e d e n s e ( q , v ) final_{score}(q,v)=\alpha⋅score_{sparse}(q,v)+(1−\alpha)⋅score_{dense}(q,v) finalscore(q,v)=α⋅scoresparse(q,v)+(1−α)⋅scoredense(q,v)

这种方法需要先将不同检索系统的得分进行归一化(例如,统一到 0-1 区间),然后通过一个权重参数 α \alpha α 来进行线性组合。通过调整 α \alpha α 的值,可以灵活地控制语义相似性与关键词匹配在最终排序中的贡献比例。例如,在电商搜索中,可以调高关键词的权重;而在智能问答中,则可以侧重于语义。

1.5 代码

关于密集向量部分,就是我们之前讲过的使用索引+相似度的计算流程,这里就不再赘述了。我们直接来看代码:

python 复制代码
import json
import os
import numpy as np
from pymilvus import connections, MilvusClient, FieldSchema, CollectionSchema, DataType, Collection, AnnSearchRequest, RRFRanker
from pymilvus.model.hybrid import BGEM3EmbeddingFunction

# 1. 初始化设置
COLLECTION_NAME = "dragon_hybrid_demo"
MILVUS_URI = "http://localhost:19530"  # 服务器模式
DATA_PATH = r"all-in-rag\data\C4\metadata\dragon.json"  # 相对路径
BATCH_SIZE = 50

# 2. 连接 Milvus 并初始化嵌入模型
print(f"--> 正在连接到 Milvus: {MILVUS_URI}")
connections.connect(uri=MILVUS_URI)

print("--> 正在初始化 BGE-M3 嵌入模型...")
ef = BGEM3EmbeddingFunction(use_fp16=False, device="cpu")
print(f"--> 嵌入模型初始化完成。密集向量维度: {ef.dim['dense']}")
  • connections.connect:建立与 Milvus 的连接(2.4+ 支持 HTTP/REST 直连)。
  • BGEM3EmbeddingFunction:一行拿到密集 (dense)+ 稀疏 (sparse)双模态向量。
    • ef.dim['dense'] 给出密集向量维度(常见为 1024 或 768,具体跟模型版本有关)。

    • 稀疏向量是scipy CSR稀疏矩阵(非常稀疏,内存省)。

      ##结果:
      --> 正在连接到 Milvus: http://localhost:19530
      --> 正在初始化 BGE-M3 嵌入模型...
      Fetching 30 files: 100%|██████████| 30/30 [00:00<?, ?it/s]
      --> 嵌入模型初始化完成。密集向量维度: 1024


python 复制代码
# 3. 创建 Collection
milvus_client = MilvusClient(uri=MILVUS_URI)
if milvus_client.has_collection(COLLECTION_NAME):
    print(f"--> 正在删除已存在的 Collection '{COLLECTION_NAME}'...")
    milvus_client.drop_collection(COLLECTION_NAME)

fields = [
    FieldSchema(name="pk", dtype=DataType.VARCHAR, is_primary=True, auto_id=True, max_length=100),
    FieldSchema(name="img_id", dtype=DataType.VARCHAR, max_length=100),
    FieldSchema(name="path", dtype=DataType.VARCHAR, max_length=256),
    FieldSchema(name="title", dtype=DataType.VARCHAR, max_length=256),
    FieldSchema(name="description", dtype=DataType.VARCHAR, max_length=4096),
    FieldSchema(name="category", dtype=DataType.VARCHAR, max_length=64),
    FieldSchema(name="location", dtype=DataType.VARCHAR, max_length=128),
    FieldSchema(name="environment", dtype=DataType.VARCHAR, max_length=64),
    FieldSchema(name="sparse_vector", dtype=DataType.SPARSE_FLOAT_VECTOR),
    FieldSchema(name="dense_vector", dtype=DataType.FLOAT_VECTOR, dim=ef.dim["dense"])
]

# 如果集合不存在,则创建它及索引
if not milvus_client.has_collection(COLLECTION_NAME):
    print(f"--> 正在创建 Collection '{COLLECTION_NAME}'...")
    schema = CollectionSchema(fields, description="关于龙的混合检索示例")
    # 创建集合
    collection = Collection(name=COLLECTION_NAME, schema=schema, consistency_level="Strong")
    print("--> Collection 创建成功。")

    # 4. 创建索引
    print("--> 正在为新集合创建索引...")
    sparse_index = {"index_type": "SPARSE_INVERTED_INDEX", "metric_type": "IP"}
    collection.create_index("sparse_vector", sparse_index)
    print("稀疏向量索引创建成功。")

    dense_index = {"index_type": "AUTOINDEX", "metric_type": "IP"}
    collection.create_index("dense_vector", dense_index)
    print("密集向量索引创建成功。")
    

collection = Collection(COLLECTION_NAME)
print(collection)
  • 主键 pkVARCHAR + auto_id=True,你不需要在插入时提供主键列。

  • 两类向量字段

    • SPARSE_FLOAT_VECTOR:给 Splade/BGE-M3 的稀疏输出用。
    • FLOAT_VECTOR(dim=ef.dim['dense']):给密集向量。
  • 索引

    • 稀疏:SPARSE_INVERTED_INDEX + IP(点积)------稀疏检索场景常用。
    • 密集:AUTOINDEX + IP------Milvus 自动挑合适的 ANN 索引。
  • 一致性consistency_level="Strong" 保证插入后更快可见(适合 demo/测试)。

    ##结果
    --> 正在删除已存在的 Collection 'dragon_hybrid_demo'...
    --> 正在创建 Collection 'dragon_hybrid_demo'...
    --> Collection 创建成功。
    --> 正在为新集合创建索引...
    稀疏向量索引创建成功。
    密集向量索引创建成功。
    <Collection>:

    <name>: dragon_hybrid_demo
    <description>: 关于龙的混合检索示例
    <schema>: {'auto_id': True, 'description': '关于龙的混合检索示例', 'fields': [{'name': 'pk', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 100}, 'is_primary': True, 'auto_id': True}, {'name': 'img_id', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 100}}, {'name': 'path', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 256}}, {'name': 'title', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 256}}, {'name': 'description', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 4096}}, {'name': 'category', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 64}}, {'name': 'location', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 128}}, {'name': 'environment', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 64}}, {'name': 'sparse_vector', 'description': '', 'type': <DataType.SPARSE_FLOAT_VECTOR: 104>}, {'name': 'dense_vector', 'description': '', 'type': <DataType.FLOAT_VECTOR: 101>, 'params': {'dim': 1024}}], 'enable_dynamic_field': False}


python 复制代码
# 5. 加载数据并插入
collection.load()
print(f"--> Collection '{COLLECTION_NAME}' 已加载到内存。")

if collection.is_empty:
    print(f"--> Collection 为空,开始插入数据...")
    if not os.path.exists(DATA_PATH):
        raise FileNotFoundError(f"数据文件未找到: {DATA_PATH}")
    with open(DATA_PATH, 'r', encoding='utf-8') as f:
        dataset = json.load(f)

    docs, metadata = [], []
    for item in dataset:
        parts = [
            item.get('title', ''), item.get('description', ''),
            *item.get('combat_details', {}).get('combat_style', []),
            *item.get('combat_details', {}).get('abilities_used', []),
            item.get('scene_info', {}).get('location', ''),
            item.get('scene_info', {}).get('environment', ''),
            item.get('scene_info', {}).get('time_of_day', '')
        ]
        docs.append(' '.join(filter(None, parts)))
        metadata.append(item)
    print(f"--> 数据加载完成,共 {len(docs)} 条。")

    print("--> 正在生成向量嵌入...")
    embeddings = ef(docs)
    print("--> 向量生成完成。")

    print("--> 正在分批插入数据...")
    # 为每个字段准备批量数据
    img_ids = [doc["img_id"] for doc in metadata]
    paths = [doc["path"] for doc in metadata]
    titles = [doc["title"] for doc in metadata]
    descriptions = [doc["description"] for doc in metadata]
    categories = [doc["category"] for doc in metadata]
    locations = [doc["location"] for doc in metadata]
    environments = [doc["environment"] for doc in metadata]
    
    # 获取向量
    sparse_vectors = embeddings["sparse"]
    dense_vectors = embeddings["dense"]
    
    # 插入数据
    collection.insert([
        img_ids,
        paths,
        titles,
        descriptions,
        categories,
        locations,
        environments,
        sparse_vectors,
        dense_vectors
    ])
    
    collection.flush()
    print(f"--> 数据插入完成,总数: {collection.num_entities}")
    print(collection)
else:
    print(f"--> Collection 中已有 {collection.num_entities} 条数据,跳过插入。")
  • docs 构造 :把 title/description/战斗风格/能力/场景等拼成文本,作为 embedding 输入。其中ef<pymilvus.model.hybrid.bge_m3.BGEM3EmbeddingFunction object at 0x00000230F6D1C440>

  • ef(docs) :一次性产出两种向量:

    • dense:Python 列表/ndarray;
    • sparse:CSR 稀疏矩阵(列数=词表大小,极稀疏)。
  • 插入顺序:与 schema 字段顺序一致(去掉主键)。你这里完全匹配。

  • flush():将内存中的新增数据落到存储并"封存分段",为可检索做准备。

    ##embedding结果(部分)
    {'dense': [array([ 0.00635399, 0.01074068, -0.05170297, ..., -0.03949431,
    0.0200727 , 0.01819432], shape=(1024,), dtype=float32), array([ 0.00892401, 0.03093382, -0.06604258, ..., -0.01160803,
    0.03871303, -0.0276277 ], shape=(1024,), dtype=float32), array([-0.03859169, 0.00085912, -0.04422687, ..., -0.00565427,
    0.01129178, -0.03978558], shape=(1024,), dtype=float32), array([-0.01972841, 0.03504109, -0.05370914, ..., -0.01511711,
    0.02531379, -0.00274632], shape=(1024,), dtype=float32), array([ 0.00630738, 0.037926 , -0.09000324, ..., 0.02298327,
    0.02663264, 0.00539887], shape=(1024,), dtype=float32), array([-0.02313624, 0.03181471, -0.0242848 , ..., -0.0157985 ,
    -0.01950706, -0.01426405], shape=(1024,), dtype=float32)], 'sparse': <Compressed Sparse Row sparse array of dtype 'float64'
    with 213 stored elements and shape (6, 250002)>}


python 复制代码
# 6. 执行搜索
search_query = "悬崖上的巨龙"
search_filter = 'category in ["western_dragon", "chinese_dragon", "movie_character"]'
top_k = 5

print(f"\n{'='*20} 开始混合搜索 {'='*20}")
print(f"查询: '{search_query}'")
print(f"过滤器: '{search_filter}'")

query_embeddings = ef([search_query])
dense_vec = query_embeddings["dense"][0]
sparse_vec = query_embeddings["sparse"]._getrow(0)

# 打印向量信息
print("\n=== 向量信息 ===")
print(f"密集向量维度: {len(dense_vec)}")
print(f"密集向量前5个元素: {dense_vec[:5]}")
print(f"密集向量范数: {np.linalg.norm(dense_vec):.4f}")

print(f"\n稀疏向量维度: {sparse_vec.shape[1]}")
print(f"稀疏向量非零元素数量: {sparse_vec.nnz}")
print("稀疏向量前5个非零元素:")
for i in range(min(5, sparse_vec.nnz)):
    print(f"  - 索引: {sparse_vec.indices[i]}, 值: {sparse_vec.data[i]:.4f}")
density = (sparse_vec.nnz / sparse_vec.shape[1] * 100)
print(f"\n稀疏向量密度: {density:.8f}%")
  • 密集向量:用于语义检索(余弦/内积/L2)。

  • 稀疏向量:用于关键词/扩展术语的精确匹配(BM25/Splade-style)。

  • 形态打印 :非常好,能感受到"密集 vs 稀疏"的差异。

    ##结果
    ==================== 开始混合搜索 ====================
    查询: '悬崖上的巨龙'
    过滤器: 'category in ["western_dragon", "chinese_dragon", "movie_character"]'

    === 向量信息 ===
    密集向量维度: 1024
    密集向量前5个元素: [-0.00353051 0.02043398 -0.04192596 -0.03036701 -0.02098156]
    密集向量范数: 1.0000

    稀疏向量维度: 250002
    稀疏向量非零元素数量: 6
    稀疏向量前5个非零元素:
    - 索引: 6, 值: 0.0659
    - 索引: 7977, 值: 0.1459
    - 索引: 14732, 值: 0.2959
    - 索引: 31433, 值: 0.1463
    - 索引: 141121, 值: 0.1587

    稀疏向量密度: 0.00239998%


python 复制代码
# 定义搜索参数
search_params = {"metric_type": "IP", "params": {}}

# 先执行单独的搜索
print("\n--- [单独] 密集向量搜索结果 ---")
dense_results = collection.search(
    [dense_vec],
    anns_field="dense_vector",
    param=search_params,
    limit=top_k,
    expr=search_filter,
    output_fields=["title", "path", "description", "category", "location", "environment"]
)[0]

for i, hit in enumerate(dense_results):
    print(f"{i+1}. {hit.entity.get('title')} (Score: {hit.distance:.4f})")
    print(f"    路径: {hit.entity.get('path')}")
    print(f"    描述: {hit.entity.get('description')[:100]}...")

print("\n--- [单独] 稀疏向量搜索结果 ---")
sparse_results = collection.search(
    [sparse_vec],
    anns_field="sparse_vector",
    param=search_params,
    limit=top_k,
    expr=search_filter,
    output_fields=["title", "path", "description", "category", "location", "environment"]
)[0]

for i, hit in enumerate(sparse_results):
    print(f"{i+1}. {hit.entity.get('title')} (Score: {hit.distance:.4f})")
    print(f"    路径: {hit.entity.get('path')}")
    print(f"    描述: {hit.entity.get('description')[:100]}...")
  • anns_field 指定检索所用的向量字段:一个用 dense,一个用 sparse。

  • expr 是结构化过滤:只在指定 category 的子集合上做向量检索(省时、精准)。

  • metric_type 与建索引时保持一致(都是 IP)。

  • 返回结构resultsHitshit.distance 即相似度/距离(IP 越大越相似)。

    ##结果
    --- [单独] 密集向量搜索结果 ---

    1. 悬崖上的白龙 (Score: 0.7219)
      路径: ../../data/C3/dragon/dragon02.png
      描述: 一头雄伟的白色巨龙栖息在悬崖边缘,背景是金色的云霞和远方的海岸。它拥有巨大的翅膀和优雅的身姿,是典型的西方奇幻生物。...
    2. 中华金龙 (Score: 0.5131)
      路径: ../../data/C3/dragon/dragon06.png
      描述: 一条金色的中华龙在祥云间盘旋,它身形矫健,龙须飘逸,展现了东方神话中龙的威严与神圣。...
    3. 驯龙高手:无牙仔 (Score: 0.5119)
      路径: ../../data/C3/dragon/dragon05.png
      描述: 在电影《驯龙高手》中,主角小嗝嗝骑着他的龙伙伴无牙仔在高空飞翔。他们飞向灿烂的太阳,下方是岛屿和海洋,画面充满了冒险与友谊。...

    --- [单独] 稀疏向量搜索结果 ---

    1. 悬崖上的白龙 (Score: 0.2319)
      路径: ../../data/C3/dragon/dragon02.png
      描述: 一头雄伟的白色巨龙栖息在悬崖边缘,背景是金色的云霞和远方的海岸。它拥有巨大的翅膀和优雅的身姿,是典型的西方奇幻生物。...
    2. 中华金龙 (Score: 0.0923)
      路径: ../../data/C3/dragon/dragon06.png
      描述: 一条金色的中华龙在祥云间盘旋,它身形矫健,龙须飘逸,展现了东方神话中龙的威严与神圣。...
    3. 驯龙高手:无牙仔 (Score: 0.0691)
      路径: ../../data/C3/dragon/dragon05.png
      描述: 在电影《驯龙高手》中,主角小嗝嗝骑着他的龙伙伴无牙仔在高空飞翔。他们飞向灿烂的太阳,下方是岛屿和海洋,画面充满了冒险与友谊。...

python 复制代码
# 创建 RRF 融合器
rerank = RRFRanker(k=60)

# 创建搜索请求
dense_req = AnnSearchRequest([dense_vec], "dense_vector", search_params, limit=top_k)
sparse_req = AnnSearchRequest([sparse_vec], "sparse_vector", search_params, limit=top_k)

# 执行混合搜索
results = collection.hybrid_search(
    [sparse_req, dense_req],
    rerank=rerank,
    limit=top_k,
    output_fields=["title", "path", "description", "category", "location", "environment"]
)[0]

# 打印最终结果
for i, hit in enumerate(results):
    print(f"{i+1}. {hit.entity.get('title')} (Score: {hit.distance:.4f})")
    print(f"    路径: {hit.entity.get('path')}")
    print(f"    描述: {hit.entity.get('description')[:100]}...")

# 7. 清理资源
milvus_client.release_collection(collection_name=COLLECTION_NAME)
print(f"已从内存中释放 Collection: '{COLLECTION_NAME}'")
milvus_client.drop_collection(COLLECTION_NAME)
print(f"已删除 Collection: '{COLLECTION_NAME}'")
复制代码
##结果
1. 悬崖上的白龙 (Score: 0.0328)
    路径: ../../data/C3/dragon/dragon02.png
    描述: 一头雄伟的白色巨龙栖息在悬崖边缘,背景是金色的云霞和远方的海岸。它拥有巨大的翅膀和优雅的身姿,是典型的西方奇幻生物。...
2. 中华金龙 (Score: 0.0320)
    路径: ../../data/C3/dragon/dragon06.png
    描述: 一条金色的中华龙在祥云间盘旋,它身形矫健,龙须飘逸,展现了东方神话中龙的威严与神圣。...
3. 霸王龙的怒吼 (Score: 0.0318)
    路径: ../../data/C3/dragon/dragon03.png
    描述: 史前时代的霸王龙张开血盆大口,发出震天的怒吼。在它身后,几只翼龙在阴沉的天空中盘旋,展现了白垩纪的原始力量。...
4. 奔跑的奶龙 (Score: 0.0313)
    路径: ../../data/C3/dragon/dragon04.png
    描述: 一只Q版的黄色小恐龙,有着大大的绿色眼睛和友善的微笑。是一部动画中的角色,非常可爱。...
5. 驯龙高手:无牙仔 (Score: 0.0310)
    路径: ../../data/C3/dragon/dragon05.png
    描述: 在电影《驯龙高手》中,主角小嗝嗝骑着他的龙伙伴无牙仔在高空飞翔。他们飞向灿烂的太阳,下方是岛屿和海洋,画面充满了冒险与友谊。...
已从内存中释放 Collection: 'dragon_hybrid_demo'
已删除 Collection: 'dragon_hybrid_demo'

我们来计算一下这个score是如何得到的:

  • [单独] 密集向量搜索结果中,悬崖上的白龙的rank是1;同理,在[单独] 稀疏向量搜索结果,悬崖上的白龙的rank也是1,那么根据公式:
    R R F s c o r e ( d ) = ∑ i = 1 k 1 r a n k i ( d ) + c RRF_{score}(d) = \sum_{i=1}^{k} \frac{1}{rank_{i}(d) + c} RRFscore(d)=i=1∑kranki(d)+c1

  • 代入两个1,我们就会得到:
    R R F s c o r e ( d ) = ∑ i = 1 k 1 1 + 60 = 2 61 ≈ 0.03278688... RRF_{score}(d) = \sum_{i=1}^{k} \frac{1}{1 + 60}=\frac{2}{61}\approx 0.03278688... RRFscore(d)=i=1∑k1+601=612≈0.03278688...

参考文献
1.混合检索