基于 LlamaIndex 与 Elasticsearch 8.x 的混合检索系统:20 种策略原理与实现

前言

RAG 系统的检索质量直接决定了大模型回答的准确性。单一检索方式(纯 BM25 或纯向量)都存在明显短板:BM25 抓不住语义同义(“请假”和“休假”匹配不上),向量检索抓不住精确关键词(专有名词、错误码)。混合检索通过多路召回 + 智能融合,把“语义”和“字面”两条路打通,是现代 RAG 系统的常见做法。

本文基于 LlamaIndex + DashScope 鉴权大模型 + Elasticsearch 8.x 运行,逐个拆解脚本中 20 种检索方式的算法原理与代码实现。其中 DashScope 仅作为运行支撑,本文不涉及其鉴权或接口细节。


1. 核心架构:LlamaIndex × Elasticsearch

1.1 三大组件的角色分工

组件 职责 关键类
LlamaIndex 编排层:Reader、Retriever、QueryEngine、PostProcessor VectorStoreIndexRetriever
Elasticsearch 存储层:倒排索引 + 向量索引 + 聚合统计 ElasticsearchStore
DashScope 模型层:Embedding(text-embedding-v3)+ LLM(qwen-plus) DashScopeEmbedding

LlamaIndex 通过 ElasticsearchStore 适配 ES,把"切分、嵌入、查询"全部用 LlamaIndex 抽象层包装。底层 ES 仍然用自身的 DSL(DSL 是 ES 的查询 JSON 结构),LlamaIndex 只负责"翻译"。

1.2 关键环境配置

from llama_index.core import Settings
from llama_index.embeddings.dashscope import DashScopeEmbedding, DashScopeTextEmbeddingModels
from llama_index.llms.dashscope import DashScope, DashScopeGenerationModels

# 全局配置:避免 query_engine 未指定 LLM 时回退到 OpenAI
Settings.llm = DashScope(model_name=DashScopeGenerationModels.QWEN_PLUS, api_key=os.getenv("DASHCOPE_KEY"))
Settings.embed_model = DashScopeEmbedding(
    model_name=DashScopeTextEmbeddingModels.TEXT_EMBEDDING_V3,
    embed_batch_size=6, embed_input_length=8192
)

说明:DashScope SDK 需要 DASHSCOPE_API_KEY 环境变量名,必须双重设置 dashscope.api_key = ...os.environ["DASHSCOPE_API_KEY"],否则底层 SDK 报 401。

1.3 索引入库

splitter = SentenceSplitter(chunk_size=256, chunk_overlap=50)
VectorStoreIndex.from_documents(
    docs, transformations=[splitter], embed_model=embed_model,
    storage_context=StorageContext.from_defaults(
        vector_store=ElasticsearchStore(index_name=INDEX_NAME, es_url=ES_URL)
    ),
)

SentenceSplitter 按句子切分,chunk_overlap=50 让相邻块有少量重叠避免关键信息被切断。LlamaIndex 内部会对每块调 Embedding 模型生成向量,连同文本一起写入 ES(content 字段存文本,embedding 字段存向量)。

接下来,我们逐个演示脚本中的 20 种检索方式。


2. 基础检索:BM25 与 Dense Vector

2.1 BM25 全文检索

原理

BM25(Best Matching 25)是经典的信息检索排序算法,核心思想是:

  • 词频饱和(TF Saturation):词出现越多越相关,但到一定次数后增益趋平(避免"长文档得高分")
  • 逆文档频率(IDF):常见词(“的”、“是”)权重低,稀有词(“事假”)权重高
  • 文档长度归一化:短文档中命中关键词的相对密度更高,给予补偿

BM25 公式(Robertson 1994):

score(q,d)=∑iIDF(qi)⋅f(qi,d)⋅(k1+1)f(qi,d)+k1⋅(1−b+b⋅∣d∣avgdl) \text{score}(q, d) = \sum_{i} \text{IDF}(q_i) \cdot \frac{f(q_i, d) \cdot (k_1 + 1)}{f(q_i, d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)} score(q,d)=iIDF(qi)f(qi,d)+k1(1b+bavgdld)f(qi,d)(k1+1)

其中:

  • f(qi,d)f(q_i, d)f(qi,d):词 qiq_iqi 在文档 ddd 中的频率
  • ∣d∣/avgdl|d|/\text{avgdl}d∣/avgdl:文档长度 / 平均文档长度
  • k1k_1k1:词频饱和参数(默认 1.2,范围 0∼∞0 \sim \infty0
  • bbb:长度归一化强度(默认 0.75,范围 0∼10 \sim 101,0=不归一化,1=完全归一化)
代码实现
from llama_index.vector_stores.elasticsearch import AsyncBM25Strategy

bm25_index = make_index(AsyncBM25Strategy())  # 默认 k1=1.2, b=0.75
bm25_results = get_retriever(bm25_index).retrieve(QUERY)

AsyncBM25Strategy() 是 LlamaIndex 提供的 BM25 检索策略包装。make_index() 内部把 strategy 注入到 ES 索引配置中。

优势与局限
优势 局限
精确关键词匹配(产品名、错误码) 无法匹配同义词(“请假"≠"休假”)
计算快、可解释(命中哪些词) 短查询召回率低
适合专有名词、缩写、版本号 依赖分词器质量

2.2 Dense Vector 语义检索

原理

把文本通过 Embedding 模型映射到高维空间(如 1024 维),用向量距离衡量语义相似度。核心假设:“意思相近的文本在向量空间也相近”。

余弦相似度(常用度量):

cos⁡(A,B)=A⋅B∥A∥⋅∥B∥ \cos(\mathbf{A}, \mathbf{B}) = \frac{\mathbf{A} \cdot \mathbf{B}}{\|\mathbf{A}\| \cdot \|\mathbf{B}\|} cos(A,B)=ABAB

取值范围 [−1,1][-1, 1][1,1],越接近 1 越相似。其他度量:点积(DOT_PRODUCT)、欧氏距离(EUCLIDEAN)。

代码实现
from llama_index.vector_stores.elasticsearch import AsyncDenseVectorStrategy
from llama_index.core.postprocessor import SimilarityPostprocessor

vector_index = make_index(AsyncDenseVectorStrategy())
vector_results = get_retriever(vector_index, top_k=5).retrieve(QUERY)

# 关键:过滤低分节点(防止退化向量污染结果)
vector_results = SimilarityPostprocessor(similarity_cutoff=0.3).postprocess_nodes(vector_results)

核心细节top_k=5 + similarity_cutoff=0.3 组合使用。先取 5 个候选,再用相似度阈值过滤。LLaMAIndex 向量检索可能返回 score=0.0\text{score}=0.0score=0.0 的退化向量(极少数情况),必须过滤。

优势与局限
优势 局限
同义、近义、上下文相关都能匹配 计算成本高(向量比对)
多语言能力(取决于 Embedding) 对专有名词不敏感
适合长查询、口语化表达 不可解释(黑盒)

3. 融合策略:把 BM25 和向量"合并"

基础检索各有所长,融合策略是混合检索的灵魂

3.1 倒数排名融合(RRF)

原理

RRF(Reciprocal Rank Fusion)由 Cormack 等人 2009 年提出,核心思想:不直接比较分数(不同检索器分数尺度差异大),而是按排名倒数加权求和

公式:

RRF_score(d)=∑i1k+ranki(d) \text{RRF\_score}(d) = \sum_{i} \frac{1}{k + \text{rank}_i(d)} RRF_score(d)=ik+ranki(d)1

其中:

  • ranki(d)\text{rank}_i(d)ranki(d):文档 ddd 在第 iii 路检索中的排名(从 0 或 1 开始)
  • kkk:常数(默认 60),控制"高排名 vs 低排名"的差距。kkk 越小,高排名权重越大;kkk 越大,融合越平滑

为什么有效

  1. 不依赖原始分数(BM25 分数可能是 0~10,向量分数可能是 0~1)
  2. 排名是单调变换(排名不因分数尺度变化而改变)
  3. 多路都在前列的文档自然得分高
代码实现
def rrf_fuse(results_dict, k=60, top_k=2):
    """手动 RRF 融合(ES 免费版不支持内置 RRF)"""
    scores, nodes = {}, {}
    for ns in results_dict.values():
        for rank, n in enumerate(sorted(ns, key=lambda x: x.score or 0, reverse=True)):
            t = n.node.get_content()
            nodes[t] = n
            scores[t] = scores.get(t, 0) + 1.0 / (rank + k)
    return [_set_score(nodes[t], s) for t, s in 
            sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]]

说明:ES 8.x 的 AsyncDenseVectorStrategy(hybrid=True) 内部用 RRF,但需要付费版。免费版(basic)只能手动实现,本文采用的就是手动版本。

应用场景
  • 跨语言检索(不同检索器分数尺度差异大)
  • 多模态融合(文本 + 图像检索)
  • 多索引查询(不同来源的异构数据)

3.2 加权平均融合

原理

把每路检索分数按预设权重加权求和。简单直接,但要求所有检索器的分数在同一尺度(或经过 min-max 归一化)。

公式:

final_score(d)=∑iwi⋅normalized_scorei(d) \text{final\_score}(d) = \sum_{i} w_i \cdot \text{normalized\_score}_i(d) final_score(d)=iwinormalized_scorei(d)

代码实现
def weighted_fuse(results_dict, weights, top_k=2):
    """加权平均融合"""
    scores, nodes = {}, {}
    for src, ns in results_dict.items():
        for n in ns:
            t = n.node.get_content()
            nodes[t] = n
            scores[t] = scores.get(t, 0) + (n.score or 0) * weights.get(src, 1.0)
    return [_set_score(nodes[t], s) for t, s in 
            sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]]

# 调用:让向量检索权重更高
weighted_fuse({"bm25": bm25_results, "vector": vector_results}, 
              {"bm25": 0.3, "vector": 0.7})
与 RRF 的对比
维度 RRF 加权平均
分数要求 不需要(只看排名) 需要同尺度
实现复杂度 简单 需要归一化
调参难度 低(k 固定 60 即可) 高(每路权重都要调)
业务可解释性 弱(“为什么这条排第一”) 强(“vector 权重 0.7”)
适用场景 多路异构检索 同尺度分数融合

3.3 QueryFusionRetriever(LlamaIndex 内置)

原理

LlamaIndex 提供的开箱即用融合器,底层支持三种模式:

  • reciprocal_rerank(默认):RRF
  • simple:简单拼接
  • llm:用 LLM 智能重排

num_queries > 1 时会先用 LLM 改写查询(Query Rewriting),生成多个变体查询再分别检索。

代码实现
from llama_index.core.retrievers import QueryFusionRetriever

fusion = QueryFusionRetriever(
    retrievers=[get_retriever(bm25_index), get_retriever(vector_index)],
    similarity_top_k=2,        # 最终返回 2 个
    num_queries=1,             # 不改写查询(=1 时直接用原查询)
    mode="reciprocal_rerank",  # RRF 模式
    llm=llm,                   # 必填!QueryFusion 内部需要 LLM
)
fusion.retrieve(QUERY)

注意QueryFusionRetriever 内部会调用 LLM 做查询改写(即使 num_queries=1 也会校验 LLM),必须显式传 llm 参数。如果用默认 OpenAI,国内必报 401。


4. ES 原生查询:DSL 七种武器

ES 自身的查询 DSL(Domain Specific Language)是检索领域的事实标准。LlamaIndex 帮你处理切分、嵌入,但精细化查询仍需直接调用 ES API。

4.1 字段加权(multi_match)

原理

同一查询在不同字段的重要性不同。比如搜索"Python 教程",title 字段命中比 content 字段命中更相关。ES 用 ^N 表示权重倍数。

代码
es_query({"query": {"multi_match": {
    "query": "请假",
    "fields": ["content^3"],        # content 字段权重 3
    "type": "best_fields"           # 取单字段最高分
}}, "size": 1})

best_fields vs most_fields

  • best_fields:取最高分(适合字段互斥)
  • most_fields:累加所有字段分数(适合字段互补)
调高/调低效果
  • 短查询(1~2 词)title^5, content^1 效果显著
  • 长查询(10+ 词)权重影响小,可不设
  • 别超过 ^10,会让分数爆炸

4.2 布尔查询(bool)

原理

组合多个子查询,支持 must(必须)、should(应该)、must_not(必须不)、filter(不过滤分数)。复杂查询场景中,绝大部分都基于 bool。

代码
es_query({"query": {"bool": {
    "must":     [{"match": {"content": "请假"}}],  # 必须命中"请假"
    "should":   [{"match": {"content": "提前"}}],  # 应该命中"提前"(加权)
    "must_not": [{"match": {"content": "婚"}}],    # 必须不包含"婚"
}}, "size": 1})

核心细节:ES 默认 standard analyzer 把"婚假"拆成 ["婚", "假"] 两个 token。must_not 必须用单字才能精准过滤。

filter vs must 的区别
维度 must filter
是否算分 ❌(只过滤)
缓存 不缓存 会缓存(性能更好)
适用 影响相关性的条件 时间范围、状态过滤

4.3 短语查询(match_phrase)

原理

要求查询的词按指定顺序连续出现,slop 参数允许中间有 N 个其他词。slop=0 是严格连续,slop=2 允许间隔 2 个词。

代码
es_query({"query": {"match_phrase": {
    "content": {"query": "提前", "slop": 0}  # 严格匹配"提前"两字相邻
}}, "size": 1})

注意:中文分词后是单字"提"+“前”。如果用"提前申请" + slop=1,会误命中"申…请"(因为"申"在"请"前 1 个位置)。严格场景用 slop=0 + 单字查询

4.4 通配符查询(wildcard)

原理

* 匹配任意字符串(0 或多个字符),? 匹配单个字符。底层是正则表达式,性能差,慎用。

代码
es_query({"query": {"wildcard": {
    "content.keyword": {"value": "*事假*", "boost": 1.5}  # 必须用 keyword 字段
}}, "size": 1})

核心细节text 字段被分词后没有"事假"这个 token。通配符只能在 keyword 子字段上(不分析、整词作为 token)。

性能警告

通配符查询无法使用倒排索引,要扫描所有文档:

  • *事假(前缀无 *):可以用索引
  • 事假*(后缀无 *):可以用索引
  • *事假*(两端有 *):必须全表扫描,性能较低,慎用

4.5 模糊查询(fuzzy)

原理

基于 Levenshtein 距离(编辑距离)匹配相似词。fuzziness=AUTO 自动根据词长度调整允许的编辑距离(短词 0,长词 2)。

代码
es_query({"query": {"fuzzy": {
    "content": {"value": "请", "fuzziness": 1, "prefix_length": 0}
}}, "size": 1})

核心细节:中文分词后是单字,模糊查询也只能针对单字。如果想匹配"请假如"(“假如"是"假"的错别字),实际应查询"假"而不是"请假如”。

适用场景
  • 拼写纠错(“苹果手要” → “苹果手机”)
  • OCR 错误文本检索
  • 口语化输入

4.6 高亮查询(highlight)

原理

返回匹配结果的前后片段并标记关键词。需要在查询时声明 highlight 块,ES 会从 _source 提取匹配片段并用 <em> 标签包裹。

代码
r = es_client.search(index=INDEX_NAME, body={
    "query": {"match": {"content": "请假"}},
    "highlight": {
        "fields": {"content": {"pre_tags": ["<em>"], "post_tags": ["</em>"]}}
    },
    "size": 1,
})
print(f"高亮: {r['hits']['hits'][0].get('highlight', {}).get('content', [])}")
在 RAG 中的应用
  • 前端展示"匹配了哪些关键词"
  • 调试检索质量(一眼看出为什么命中)
  • 生成"为什么推荐这条"的可解释性

4.7 函数评分(function_score)

原理

在原始查询分数基础上,叠加额外函数(权重、随机性、衰减)调整最终分数。适合"特定条件加权"(如新文章加权、热门文档加权)。

代码
es_query({"query": {"function_score": {
    "query": {"match": {"content": "请假"}},        # 基础查询
    "functions": [
        {"filter": {"match": {"content": "提前"}}, "weight": 2.0}  # 含"提前"权重 ×2
    ],
    "score_mode": "sum",          # 多函数分数求和
    "boost_mode": "multiply"      # 与基础查询分数相乘
}}, "size": 1})
常用函数
函数 用途 示例
weight 简单加权 特定标签文档 ×2
field_value_factor 用字段值计算 文档浏览数 ×0.1
random_score 引入随机性 实现多样化推荐
decay_function 时间/距离衰减 越新的文档分数越高

5. 中文分词:适配与查询绕路

5.1 问题的本质

ES 默认 standard analyzer 对英文友好(按空格分词),但对中文是按字拆分

POST /hybrid_demo/_analyze
{"analyzer": "standard", "text": "事假需提前申请"}

# 输出 tokens: ["事", "假", "需", "提", "前", "申", "请"]

这导致一系列问题:

查询 预期 实际(standard) 原因
通配符 *事假* 命中"事假"相关内容 ❌ 无结果 "事假"不是 token
must_not 婚假 排除婚假文档 ❌ 实际排除"婚"或"假" "婚假"被拆字
短语"提前申请" slop=1 命中"提前申请" ❌ 命中"申…请" "申"在"请"前 1 个位置
模糊 请假如 找错别字 ❌ 无结果 "请假如"不是 token

5.2 解决方案

方案 A:安装 IK 中文分词器

# ES 8.x 安装 IK
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v8.10.0/elasticsearch-analysis-ik-8.10.0.zip

# 创建索引时指定 analyzer
PUT /hybrid_demo
{
  "settings": {
    "analysis": {
      "analyzer": {"ik_max_word": {"tokenizer": "ik_max_word"}}
    }
  },
  "mappings": {
    "properties": {
      "content": {"type": "text", "analyzer": "ik_max_word"}
    }
  }
}

方案 B:用 content.keyword 字段(绕过分词)

适用场景:通配符、精确匹配

# 通配符查 keyword 字段(不分词,整词作为 token)
es_query({"query": {"wildcard": {"content.keyword": {"value": "*事假*"}}}})

方案 C:用单字查询(适配分词结果)

适用场景:布尔、短语、模糊

# must_not 用单字
{"must_not": [{"match": {"content": "婚"}}]}

# 短语用单字 + slop=0
{"match_phrase": {"content": {"query": "提前", "slop": 0}}}

# 模糊用单字
{"fuzzy": {"content": {"value": "请", "fuzziness": 1}}}

5.3 选型建议

  • 中文为主:可装 IK 分词器 + 同义词库
  • 脚本 demo:用 content.keyword + 单字查询绕过分词限制
  • 多语言:用 ik_smart(粗粒度分词)+ ik_max_word(细粒度索引)

6. 调参对比:让检索更精准

6.1 BM25 参数(k1k_1k1, bbb

参数语义
  • k1k_1k1(词频饱和参数,默认 1.2,范围 0∼30 \sim 303):

    • k1=0k_1=0k1=0:词频不影响分数(只看是否出现)
    • k1=∞k_1=\inftyk1=:词频线性影响(出现 10 次是 1 次的 10 倍)
    • k1=1.2k_1=1.2k1=1.2:经典推荐值,平衡词频影响
  • bbb(长度归一化,默认 0.75,范围 0∼10 \sim 101):

    • b=0b=0b=0:完全不做长度归一化(短文档没优势)
    • b=1b=1b=1:完全归一化(长文档没优势)
    • b=0.75b=0.75b=0.75:经典推荐值
代码
# 调高 k1 让高频词更相关,调低 b 让短文档得分更高
tuned_bm25 = make_index(AsyncBM25Strategy(k1=1.5, b=0.5))
调参经验
  • 短查询为主 → 调高 k1(1.5~2.0)
  • 文档长度差异大 → 调低 b(0.5)
  • 数据集特定(医学、法律)→ 在验证集上 grid search

6.2 距离度量对比(COSINE / DOT_PRODUCT / EUCLIDEAN)

原理

向量相似度有多种度量方式:

度量 公式 含义 适用
COSINE A⋅B∣A∣⋅∣B∣\frac{\mathbf{A} \cdot \mathbf{B}}{|\mathbf{A}| \cdot |\mathbf{B}|}ABAB 方向相似度,忽略向量长度 文本语义检索(最常用)
DOT_PRODUCT A⋅B\mathbf{A} \cdot \mathbf{B}AB 同时考虑方向和长度 已归一化的向量
EUCLIDEAN ∣A−B∣|\mathbf{A} - \mathbf{B}|AB 空间几何距离 图像检索、聚类
代码
from elasticsearch.helpers.vectorstore._utils import DistanceMetric

for name, metric in [
    ("COSINE", DistanceMetric.COSINE),
    ("DOT_PRODUCT", DistanceMetric.DOT_PRODUCT),
    ("EUCLIDEAN", DistanceMetric.EUCLIDEAN_DISTANCE),
]:
    with suppress_stderr():
        res = make_index(AsyncDenseVectorStrategy(distance=metric))\
                .as_retriever(similarity_top_k=1, embed_model=embed_model)\
                .retrieve(QUERY)
    if res: print(f"{name}: {res[0].score:.4f}")

核心细节:EUCLIDEAN 在 ES 8.x 中名为 EUCLIDEAN_DISTANCE(不是 L2_NORM),调用错会抛 AttributeError

选择策略
  • 文本检索:COSINE(默认推荐)
  • Embedding 已 L2 归一化:DOT_PRODUCT(计算更快,等价于 COSINE)
  • 不确定:用 COSINE 不会错

7. 过滤增强:元数据筛选

原理

在向量检索前先用结构化字段(分类、标签、时间)过滤,缩小搜索空间。常用于"按权限过滤"(多租户系统)、“按时间过滤”(新闻系统)、“按分类过滤”(电商系统)。

代码
from llama_index.core.vector_stores import MetadataFilters, ExactMatchFilter

meta_index = VectorStoreIndex.from_documents([
    Document(text="事假需提前1个工作日申请", metadata={"category": "leave", "level": "员工"}),
    Document(text="年休假需提前7个工作日申请", metadata={"category": "leave", "level": "经理"}),
    Document(text="婚假需提前7个工作日申请", metadata={"category": "wedding"}),
], embed_model=embed_model)

# 过滤:只检索 category=leave 的文档
filtered = meta_index.as_retriever(
    similarity_top_k=2,
    filters=MetadataFilters(filters=[ExactMatchFilter(key="category", value="leave")])
).retrieve("请假")
与 ES 映射配合

ES 中元数据字段建议用 keyword 类型(不分析、精确匹配):

  • 数字/枚举:整数直接匹配
  • 字符串:必须 keyword 字段(不能用 text 字段,text 会被分词)
在 RAG 中的应用
  • 权限控制:根据用户身份加 ExactMatchFilter(key="department", value=user.dept)
  • 时间窗口RangeFilter(key="publish_date", gte="2024-01-01")
  • 多条件组合MetadataFilters(filters=[...], condition="and")"or"

8. 重排序:Cross-Encoder 精排

原理

向量检索是"粗排"(快但不够准),Rerank 是"精排"(慢但准)。原理是 Cross-Encoder:把"查询+文档"拼在一起送入 BERT 类模型,输出 0~1 的相关性分数。比向量检索的双塔模型(bi-encoder)准确率高 10~20%。

双塔 vs 交叉编码器

  • Bi-Encoder:query 和 doc 分别编码 → 向量比对(快)
  • Cross-Encoder:query+doc 联合编码 → 直接打分(准)
本地实现(bge-reranker-base)
from llama_index.core.postprocessor import SentenceTransformerRerank

LOCAL_RERANK_PATH = "/Users/chenweifeng/modelscope_cache/Xorbits/bge-reranker-base"

rerank = SentenceTransformerRerank(model=LOCAL_RERANK_PATH, top_n=2)
query_engine = vector_index.as_query_engine(
    similarity_top_k=5,                  # 粗排取 5 个
    node_postprocessors=[rerank]         # 精排保留 2 个
)
response = query_engine.query(QUERY)
选型建议
模型 大小 速度 准确度 适用
bge-reranker-base 1.1GB 中文场景常用
bge-reranker-large 2.5GB 更高 准确度优先
gte-rerank (DashScope) API 不想本地部署

说明:脚本中用 bge-reranker-base 本地模型(路径见 LOCAL_RERANK_PATH),该模型可从 ModelScope 镜像下载。


9. 智能检索:自动化与可解释

9.1 AutoRetriever:自动路由

原理

让 LLM 自动决定"应该用什么过滤条件"+“检索什么内容”。用户提供 VectorStoreInfo(含 metadata 字段描述),LLM 根据查询自动生成过滤表达式。

代码
from llama_index.core.retrievers import VectorIndexAutoRetriever
from llama_index.core.vector_stores.types import VectorStoreInfo, MetadataInfo

auto = VectorIndexAutoRetriever(
    index=vector_index,
    vector_store_info=VectorStoreInfo(
        content_info="员工手册",
        metadata_info=[
            MetadataInfo(name="file_name", type="str", description="文件名"),
            # 可继续加 author, date 等
        ],
    ),
    llm=llm,
    similarity_top_k=2,
)
result = auto.retrieve("经理可以请什么假?")
工作流程
  1. LLM 看到查询"经理可以请什么假?" + metadata 描述
  2. LLM 生成:filter={"level": "经理"} + 改写查询
  3. 内部调用 vector_index 并应用过滤
  4. 返回过滤后的结果
适用场景
  • 多租户权限过滤
  • 时间范围 + 分类组合
  • 用户不需要知道 metadata schema

9.2 CitationQueryEngine:带引用的问答

原理

在生成的答案中标注引用来源(如"事假需提前申请[1]"),让用户能追溯答案依据。LLM 在生成时会插入 [1][2] 标记,对应 source_nodes 列表。

代码
from llama_index.core.query_engine import CitationQueryEngine
from llama_index.core import get_response_synthesizer

engine = CitationQueryEngine(
    retriever=get_retriever(vector_index),
    response_synthesizer=get_response_synthesizer(response_mode="compact"),
    citation_chunk_size=256,
)
resp = engine.query(QUERY)
print(f"回答: {resp.response}")  # 含 [1][2] 引用标记
print(f"来源: {len(resp.source_nodes)} 个节点")
实际输出示例
回答: 员工因私事必须本人处理时,可申请事假[1]。需提前申请并获得直属主管批准[1];
如遇紧急情况,可事后补办手续[2]。事假为无薪假[1]。

来源:
  [1] "事假需提前1个工作日申请,按日扣除工资..."
  [2] "紧急情况可事后补办手续..."
适用场景
  • 医疗、法律、金融等可解释性要求高的领域
  • 减少 LLM 幻觉(用户可核查来源)
  • 学术研究、报告生成

10. 统计分析:索引聚合

原理

ES 的聚合(Aggregation)类似 SQL 的 GROUP BY,可以统计每个分类有多少文档、每个时间段有多少条记录等。常用于:

  • 数据分析(“哪个文件最多”)
  • 监控(“索引增长趋势”)
  • 检索辅助(先聚合后检索)
代码
r = es_client.search(index=INDEX_NAME, body={
    "size": 0,                    # 不返回文档,只要聚合
    "aggs": {
        "by_file": {              # 聚合名
            "terms": {            # terms 聚合(类似 GROUP BY)
                "field": "metadata.file_name.keyword",  # 按 file_name 分组
                "size": 10         # 取前 10
            }
        }
    }
})
print(f"总文档数: {r['hits']['total']['value']}")
for bucket in r["aggregations"]["by_file"]["buckets"]:
    print(f"  {bucket['key']}: {bucket['doc_count']} 个")
常用聚合类型
类型 用途 示例
terms 分组统计 每个文件多少文档
date_histogram 时间分布 每天/每月多少条
avg / sum / min / max 数值统计 平均文档长度
nested 嵌套字段聚合 评论的回复数

11. 总结:20 种检索方式原理速查

编号 方式 原理 适用场景
2.1 BM25 词频 + 逆文档频率 + 长度归一化 关键词检索、专有名词
2.2 Dense Vector Embedding + 余弦相似度 语义检索、同义匹配
3.1 手动 RRF 排名倒数加权求和 多路异构融合
3.2 加权平均 分数 ×\times× 权重求和 同尺度分数融合
3.3 QueryFusion LlamaIndex 内置 RRF 快速上手混合检索
4.1 字段加权 ^N 提升字段权重 title vs body 区分
4.2 布尔查询 must + should + must_not 复杂条件组合
4.3 短语查询 严格词序 + slop 容错 固定短语、成语
4.4 通配符 * / ? 正则匹配 前缀搜索、模糊模式
4.5 模糊查询 编辑距离 拼写纠错、OCR 文本
4.6 高亮查询 标记匹配片段 可解释性展示
4.7 函数评分 filter + weight / decay 时效性、热门加权
5.1 BM25 调参 k1 / b 参数 数据集适配
5.2 距离度量 COSINE / DOT / L2 向量模型适配
6 元数据过滤 keyword 字段精确匹配 权限、分类、时间
7.1 bge-reranker Cross-Encoder 精排 准确度优先
8.1 AutoRetriever LLM 自动选过滤 多租户、动态 schema
8.2 CitationQuery 答案带 [1][2] 引用 医疗、法律、报告
9 索引聚合 terms / histogram / avg 监控、统计

总结:脚本中演示的 20 种检索方式覆盖了单路检索、多路融合、ES 原生 DSL、调参、过滤、重排序、智能检索、统计等全场景。理解每种检索的算法原理,才能根据具体业务选择合适的方案。

更多推荐