Initial commit

This commit is contained in:
2026-05-12 09:41:56 +08:00
commit 572283e101
936 changed files with 133949 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
# 第一节 向量嵌入
## 一、向量嵌入基础
### 1.1 基础概念
#### 1.1.1 什么是 Embedding
向量嵌入(Embedding)是一种将真实世界中复杂、高维的数据对象(如文本、图像、音频、视频等)转换为数学上易于处理的、低维、稠密的连续数值向量的技术。
想象一下,我们将每一个词、每一段话、每一张图片都放在一个巨大的多维空间里,并给它一个独一无二的坐标。这个坐标就是一个向量,它“嵌入”了原始数据的所有关键信息。这个过程,就是 Embedding。
- **数据对象**:任何信息,如文本“你好世界”,或一张猫的图片。
- **Embedding 模型**:一个深度学习模型,负责接收数据对象并进行转换。
- **输出向量**:一个固定长度的一维数组,例如 `[0.16, 0.29, -0.88, ...]`。这个向量的维度(长度)通常在几百到几千之间。
![Embedding 过程示意图](./images/3_1_1.webp)
#### 1.1.2 向量空间的语义表示
Embedding 的真正意义在于,它产生的向量不是随机数值的堆砌,而是对数据**语义**的数学编码。
- **核心原则**:在 Embedding 构建的向量空间中,语义上相似的对象,其对应的向量在空间中的距离会更近;而语义上不相关的对象,它们的向量距离会更远。
- **关键度量**:我们通常使用以下数学方法来衡量向量间的“距离”或“相似度”:
- **余弦相似度 (Cosine Similarity)** :计算两个向量夹角的余弦值。值越接近 1,代表方向越一致,语义越相似。这是最常用的度量方式。
- **点积 (Dot Product)** :计算两个向量的乘积和。在向量归一化后,点积等价于余弦相似度。
- **欧氏距离 (Euclidean Distance)** :计算两个向量在空间中的直线距离。距离越小,语义越相似。
### 1.2 Embedding 在 RAG 中的作用
在RAG流程中,Embedding 扮演着无可替代的重要角色。
#### 1.2.1 语义检索的基础
RAG 的“检索”环节通常以基于 Embedding 的语义搜索为核心。通用流程如下:
(1)**离线索引构建**:将知识库内文档切分后,使用 Embedding 模型将每个文档块(Chunk)转换为向量,存入专门的向量数据库中。
(2)**在线查询检索**:当用户提出问题时,使用**同一个** Embedding 模型将用户的问题也转换为一个向量。
(3)**相似度计算**:在向量数据库中,计算“问题向量”与所有“文档块向量”的相似度。
(4)**召回上下文**:选取相似度最高的 Top-K 个文档块,作为补充的上下文信息,与原始问题一同送给大语言模型(LLM)生成最终答案。
#### 1.2.2 决定检索质量的关键
Embedding 的质量直接决定了 RAG 检索召回内容的准确性与相关性。一个优秀的 Embedding 模型能够精准捕捉问题和文档之间的深层语义联系,即使用户的提问和原文的表述不完全一致。反之,一个劣质的 Embedding 模型可能会因为无法理解语义而召回不相关或错误的信息,从而“污染”提供给 LLM 的上下文,导致最终生成的答案质量低下。
## 二、Embedding 技术发展
Embedding 技术的发展与自然语言处理(NLP)的进步紧密相连,尤其是在 RAG 框架出现后,对嵌入技术提出了新的要求。其演进路径大致可分为以下几个关键阶段。
### 2.1 静态词嵌入:上下文无关的表示
- **代表模型**Word2Vec (2013), GloVe (2014)
- **主要原理**:为词汇表中的每个单词生成一个固定的、与上下文无关的向量。例如,`Word2Vec` 通过 Skip-gram 和 CBOW 架构,利用局部上下文窗口学习词向量,并验证了向量运算的语义能力(如 `国王 - 男人 + 女人 ≈ 王后`)。`GloVe` 则融合了全局词-词共现矩阵的统计信息。
- **局限性**:无法处理一词多义问题。在“苹果公司发布了新手机”和“我吃了一个苹果”中,“苹果”的词向量是完全相同的,这限制了其在复杂语境下的语义表达能力。
### 2.2 动态上下文嵌入
2017年,`Transformer` 架构的诞生带来了自注意力机制(Self-Attention),它允许模型在生成一个词的向量时,动态地考虑句子中所有其他词的影响。基于此,2018年 `BERT` 模型利用 `Transformer` 的编码器,通过掩码语言模型(MLM)等自监督任务进行预训练,生成了深度上下文相关的嵌入。同一个词在不同语境中会生成不同的向量,这有效解决了静态嵌入的一词多义难题。
### 2.3 RAG 对嵌入技术的新要求
在开篇我们就提到了 RAG 框架的提出[^1],是为了解决大型语言模型 **知识固化**(内部知识难以更新)和 **幻觉**(生成的内容可能不符合事实且无法溯源)的问题。它通过“检索-生成”范式,动态地为 LLM 注入外部知识。这一过程的核心是 **语义检索**,很大程度上依赖于高质量的向量嵌入。
后续 RAG 的兴起对嵌入技术提出了更高、更具体的要求:
- **领域自适应能力**:通用的嵌入模型在专业领域(如法律、医疗)往往表现不佳,这就要求嵌入模型具备领域自适应的能力,能够通过微调或使用指令(如 INSTRUCTOR 模型)来适应特定领域的术语和语义。
- **多粒度与多模态支持**:RAG 系统需要处理的不仅仅是短句,还可能包括长文档、代码,甚至是图像和表格。这就要求嵌入模型能够处理不同长度和类型的输入数据。
- **检索效率与混合检索**:嵌入向量的维度和模型大小直接影响存储成本和检索速度。同时,为了结合语义相似性(密集检索)和关键词匹配(稀疏检索)的优点,支持混合检索的嵌入模型(如 BGE-M3)应运而生,在某些任务中成为提升召回率的关键。
## 三、嵌入模型训练原理
了解了嵌入模型的发展,我们来简单探究一下当前主流的嵌入模型(通常是基于 `BERT` 的变体)是如何通过训练获得强大的语义理解能力的。
现代嵌入模型的核心通常是 Transformer 的编码器(Encoder)部分,`BERT` 就是其中的典型代表。它通过堆叠多个 `Transformer Encoder` 层来构建一个深度的双向表示学习网络。
### 3.1 主要训练任务
BERT 的成功很大程度上归功于 **自监督学习** 策略,它允许模型从海量的、无标注的文本数据中学习知识。
#### 任务一:掩码语言模型 (Masked Language Model, MLM)
- **过程**
- 随机地将输入句子中 15% 的词元(Token)替换为一个特殊的 `[MASK]` 标记。
- 让模型去预测这些被遮盖住的原始词元是什么。
- **目标**:通过这个任务,模型被迫学习每个词元与其上下文之间的关系,从而掌握深层次的语境语义。
#### 任务二:下一句预测 (Next Sentence Prediction, NSP)
- **过程**
- 构造训练样本,每个样本包含两个句子 A 和 B。
- 其中 50% 的样本,B 是 A 的真实下一句(IsNext);另外 50% 的样本,B 是从语料库中随机抽取的句子(NotNext)。
- 让模型判断 B 是否是 A 的下一句。
- **目标**:这个任务让模型学习句子与句子之间的逻辑关系、连贯性和主题相关性。
- **重要说明**:后续的研究(如 RoBERTa)发现[^2],NSP 任务可能过于简单,甚至会损害模型性能。因此,许多现代的预训练模型(如 RoBERTa、SBERT)在预训练阶段移除了 NSP。
> 更多细节可查看 [BERT 架构及应用](https://github.com/datawhalechina/base-nlp/blob/main/docs/chapter5/13_Bert.md)
### 3.2 效果增强策略
虽然 MLM 和 NSP 赋予了模型强大的基础语义理解能力,但为了在检索任务中表现更佳,现代嵌入模型通常会引入更具针对性的训练策略。
- **度量学习 (Metric Learning)**
- **思想**:直接以“相似度”作为优化目标。
- **方法**:收集大量相关的文本对(例如,(问题,答案)、(新闻标题,正文))。训练的目标是优化向量空间中的**相对距离**:让“正例对”的向量表示在空间中被“拉近”,而“负例对”的向量表示被“推远”。关键在于优化排序关系,而非追求绝对的相似度值(如 1 或 0),因为过度追求极端值可能导致模型过拟合。
- **对比学习 (Contrastive Learning)**
- **思想**:在向量空间中,将相似的样本“拉近”,将不相似的样本“推远”。
- **方法**:构建一个三元组(Anchor, Positive, Negative)。其中,Anchor 和 Positive 是相关的(例如,同一个问题的两种不同问法),Anchor 和 Negative 是不相关的。训练的目标是让 `distance(Anchor, Positive)` 尽可能小,同时让 `distance(Anchor, Negative)` 尽可能大。
## 四、嵌入模型选型指南
理论已经了解,那么该如何选择最适合你项目的嵌入模型?
### 4.1 从 MTEB 排行榜开始
[**MTEB (Massive Text Embedding Benchmark)**](https://huggingface.co/spaces/mteb/leaderboard) 是一个由 Hugging Face 维护的、全面的文本嵌入模型评测基准。它涵盖了分类、聚类、检索、排序等多种任务,并提供了公开的排行榜,为评估和选择嵌入模型提供了重要的参考依据。
![MTEB 排行榜](./images/3_1_2.webp)
下面这张图是网站中的模型评估图像,直观地展示了在选择开源嵌入模型时需要权衡的四个核心维度:
- **横轴 - 模型参数量 (Number of Parameters)** :代表了模型的大小。通常,参数量越大的模型(越靠右),其潜在能力越强,但对计算资源的要求也越高。
- **纵轴 - 平均任务得分 (Mean Task Score)** :代表了模型的综合性能。这个分数是模型在分类、聚类、检索等一系列标准 NLP 任务上的平均表现。分数越高(越靠上),说明模型的通用语义理解能力越强。
- **气泡大小 - 嵌入维度 (Embedding Size)** :代表了模型输出向量的维度。气泡越大,维度越高,理论上能编码更丰富的语义细节,但同时也会占用更多的存储和计算资源。
- **气泡颜色 - 最大处理长度 (Max Tokens)** :代表了模型能处理的文本长度上限。颜色越深,表示模型能处理的 Token 数量越多,对长文本的适应性越好。
![MTEB 排行榜多维度评估示意图](./images/3_1_3.webp)
MTEB 榜单可以帮助我们快速筛选掉大量不合适的模型。但需要注意,榜单上的得分是在通用数据集上评测的,可能无法完全反映模型在你特定业务场景下的表现。
### 4.2 关键评估维度
在查看榜单时,除了分数,还需要关注以下几个关键维度:
- **任务 (Task)** :对于 RAG 应用,需要重点关注模型在 `Retrieval` (检索) 任务下的排名。
- **语言 (Language)** :模型是否支持你的业务数据所使用的语言?对于中文 RAG,应选择明确支持中文或多语言的模型。
- **模型大小 (Size)** :模型越大,通常性能越好,但对硬件(显存)的要求也越高,推理速度也越慢。需要根据你的部署环境和性能要求来权衡。
- **维度 (Dimensions)** :向量维度越高,能编码的信息越丰富,但也会占用更多的存储空间和计算资源。
- **最大 Token 数 (Max Tokens)** :这决定了模型能处理的文本长度上限。这个参数是你设计文本分块(Chunking)策略时必须考虑的重要依据,块大小不应超过此限制。
- **得分与机构 (Score & Publisher)** :结合模型的得分排名和其发布机构的声誉进行初步筛选。知名机构发布的模型通常质量更有保障。
- **成本 (Cost)** :如果是使用 API 服务的模型,需要考虑其调用成本;如果是自部署开源模型,则需要评估其对硬件资源的消耗(如显存、内存)以及带来的运维成本。
### 4.3 迭代测试与优化
> 不要只依赖公开榜单做最终决定。
1**确定基线 (Baseline)** :根据上述维度,选择几个符合要求的模型作为你的初始基准模型。
(2)**构建私有评测集** :根据真实业务数据,手动创建一批高质量的评测样本,每个样本包含一个典型用户问题和它对应的标准答案(或最相关的文档块)。
3**迭代优化**
- 使用基线模型在你的私有评测集上运行,评估其召回的准确率和相关性。
- 如果效果不理想,可以尝试更换模型,或者调整 RAG 流程的其他环节(如文本分块策略)。
- 通过几轮的对比测试和迭代优化,最终选出在你的特定场景下表现最佳的那个“心仪”模型。
## 参考文献
[^1]: [Lewis et al. (2020). *Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks*](https://arxiv.org/abs/2005.11401)
[^2]: [*RoBERTa: A Modified BERT Model for NLP*](https://www.comet.com/site/blog/roberta-a-modified-bert-model-for-nlp/)
+124
View File
@@ -0,0 +1,124 @@
# 第二节 多模态嵌入
现代 AI 的一项重要突破,是将简单的词向量发展成了能统一理解图文、音视频的复杂系统。这一发展建立在**注意力机制、Transformer 架构和对比学习**等关键技术之上,它们解决了在共享向量空间中对齐不同数据模态的核心挑战。其发展环环相扣:Word2Vec 为 BERT 的上下文理解铺路,而 BERT 又为 CLIP 等模型的跨模态能力奠定了基础。
## 一、为什么需要多模态嵌入?
前面的章节介绍了如何为文本创建向量嵌入。然而,仅有文本的世界是不完整的。现实世界的信息是多模态的,包含图像、音频、视频等。传统的文本嵌入无法理解“那张有红色汽车的图片”这样的查询,因为文本向量和图像向量处于相互隔离的空间,存在一堵“模态墙”。
**多模态嵌入 (Multimodal Embedding)** 的目标正是为了打破这堵墙。其目的是将不同类型的数据(如图像和文本)映射到**同一个共享的向量空间**。在这个统一的空间里,一段描述“一只奔跑的狗”的文字,其向量会非常接近一张真实小狗奔跑的图片向量。
实现这一目标的关键,在于解决 **跨模态对齐 (Cross-modal Alignment)** 的挑战。以对比学习、视觉 Transformer (ViT) 等技术为代表的突破,让模型能够学习到不同模态数据之间的语义关联,最终催生了像 CLIP 这样的模型。
## 二、CLIP 模型浅析
在图文多模态领域,OpenAI 的 **CLIP (Contrastive Language-Image Pre-training)** 是一个很有影响力的模型,它为多模态嵌入定义了一个有效的范式。
CLIP 的架构清晰简洁。它采用**双编码器架构 (Dual-Encoder Architecture)**,包含一个图像编码器和一个文本编码器,分别将图像和文本映射到同一个共享的向量空间中。
![CLIP Architecture](./images/3_2_1.webp)
*图:CLIP 的工作流程。(1) 通过对比学习训练双编码器,对齐图文向量空间。(2)和(3) 展示了如何利用该空间,通过图文相似度匹配实现零样本预测。*
为了让这两个编码器学会“对齐”不同模态的语义,CLIP 在训练时采用了**对比学习 (Contrastive Learning)** 策略。在处理一批图文数据时,模型的目标是:最大化正确图文对的向量相似度,同时最小化所有错误配对的相似度。通过这种“拉近正例,推远负例”的方式,模型从海量数据中学会了将语义相关的图像和文本在向量空间中拉近。
这种大规模的对比学习赋予了 CLIP 有效的**零样本(Zero-shot)识别能力**。它能将一个传统的分类任务,转化为一个“图文检索”问题——例如,要判断一张图片是不是猫,只需计算图片向量与“a photo of a cat”文本向量的相似度即可。这使得 CLIP 无需针对特定任务进行微调,就能实现对视觉概念的泛化理解。
## 三、常用多模态嵌入模型(以bge-visualized-m3为例)
虽然 CLIP 为图文预训练提供了重要基础,但多模态领域的研究迅速发展,涌现了许多针对不同目标和场景进行优化的模型。例如,BLIP 系列专注于提升细粒度的图文理解与生成能力,而 ALIGN 则证明了利用海量噪声数据进行大规模训练的有效性。
在众多优秀的模型中,由北京智源人工智能研究院(BAAI)开发的 **bge-visualized-m3Visualized-BGE 的 M3 版本)** 是一个很有代表性的现代多模态嵌入模型。它是在 **BGE-M3**(文本嵌入底座)的基础上引入图像能力而来,体现了当前技术向“更统一、更全面”发展的趋势。
bge-visualized-m3 的核心特性也可以概括为“M3”(主要继承自其文本底座 BGE-M3):
- **多语言性 (Multi-Linguality)**:支持超过 100 种语言的文本表示,可用于跨语言的图文检索(文本侧)。
- **多功能性 (Multi-Functionality)**:在文本检索场景下,可按需求使用密集检索(Dense Retrieval)、多向量检索(Multi-Vector Retrieval)等不同范式。
- **多粒度性 (Multi-Granularity)**:文本侧可处理从短句到长达 8192 个 token 的长文档,覆盖更广泛的应用需求。
在技术架构上,bge-visualized-m3 会先用视觉编码器提取图像的 **patch token**,再将其映射到与文本同维度的“图像 token”,与文本 token 一起送入 BGE 的 Transformer 编码器进行联合建模,最终得到可用于图文检索的统一向量表示。
## 四、代码示例
### 4.1 环境准备
**步骤1:安装 visual_bge 模块**
```bash
# 进入 visual_bge 目录
cd code/C3/visual_bge
# 安装 visual_bge 模块及其依赖
pip install -e .
# 返回上级目录
cd ..
```
**步骤2:下载模型权重**
```bash
# 运行模型下载脚本
python download_model.py
```
模型下载脚本会自动检查 `../../models/bge/` 目录下是否存在模型文件,如果不存在则从 Hugging Face 镜像站下载。
### 4.2 基础示例
```python
import os
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
import torch
from visual_bge.visual_bge.modeling import Visualized_BGE
model = Visualized_BGE(model_name_bge="BAAI/bge-base-en-v1.5",
model_weight="../../models/bge/Visualized_base_en_v1.5.pth")
model.eval()
with torch.no_grad():
text_emb = model.encode(text="datawhale开源组织的logo")
img_emb_1 = model.encode(image="../../data/C3/imgs/datawhale01.png")
multi_emb_1 = model.encode(image="../../data/C3/imgs/datawhale01.png", text="datawhale开源组织的logo")
img_emb_2 = model.encode(image="../../data/C3/imgs/datawhale02.png")
multi_emb_2 = model.encode(image="../../data/C3/imgs/datawhale02.png", text="datawhale开源组织的logo")
# 计算相似度
sim_1 = img_emb_1 @ img_emb_2.T
sim_2 = img_emb_1 @ multi_emb_1.T
sim_3 = text_emb @ multi_emb_1.T
sim_4 = multi_emb_1 @ multi_emb_2.T
print("=== 相似度计算结果 ===")
print(f"纯图像 vs 纯图像: {sim_1}")
print(f"图文结合1 vs 纯图像: {sim_2}")
print(f"图文结合1 vs 纯文本: {sim_3}")
print(f"图文结合1 vs 图文结合2: {sim_4}")
```
**代码解读:**
- **模型架构**: `Visualized_BGE` 是通过将图像token嵌入集成到BGE文本嵌入框架中构建的通用多模态嵌入模型,具备处理超越纯文本的多模态数据的灵活性。
- **模型参数**:
- `model_name_bge`: 指定底层BGE文本嵌入模型,继承其强大的文本表示能力。
- `model_weight`: Visual BGE的预训练权重文件,包含视觉编码器参数。
- **多模态编码能力**: Visual BGE提供了编码多模态数据的多样性,支持纯文本、纯图像或图文组合的格式:
- **纯文本编码**: 保持原始BGE模型的强大文本嵌入能力。
- **纯图像编码**: 使用基于EVA-CLIP的视觉编码器处理图像。
- **图文联合编码**: 将图像和文本特征融合到统一的向量空间。
- **应用场景**: 主要用于混合模态检索任务,包括多模态知识检索、组合图像检索、多模态查询的知识检索等。
- **相似度计算**: 使用矩阵乘法计算余弦相似度,所有嵌入向量都被标准化到单位长度,确保相似度值在合理范围内。
**运行结果:**
```bash
=== 相似度计算结果 ===
纯图像 vs 纯图像: tensor([[0.8318]])
图文结合1 vs 纯图像: tensor([[0.8291]])
图文结合1 vs 纯文本: tensor([[0.7627]])
图文结合1 vs 图文结合2: tensor([[0.9058]])
```
> [完整代码](https://github.com/datawhalechina/all-in-rag/blob/main/code/C3/01_bge_visualized.py)
## 练习
尝试把代码中的部分文本替换一下,比如将`datawhale开源组织的logo`替换为`blue whale`看看结果有什么不同。
+162
View File
@@ -0,0 +1,162 @@
# 第三节 向量数据库
## 一、向量数据库的作用
在前面我们学习了如何使用嵌入模型将文本、图像等非结构化数据转换为高维向量。这些向量是 RAG 系统能够进行语义理解的基础。然而,当向量数量从几百个增长到数百万甚至数十亿时,一个核心问题随之而来:**如何快速、准确地从海量向量中找到与用户查询最相似的那几个?**
### 1.1 向量数据库主要功能
向量数据库的核心价值在于其高效处理海量高维向量的能力。其主要功能可以概括为以下几点:
- **高效的相似性搜索**:这是向量数据库最重要的功能。它利用专门的索引技术(如 HNSW, IVF),能够在数十亿级别的向量中实现毫秒级的近似最近邻(ANN)查询,快速找到与给定查询最相似的数据。
- **高维数据存储与管理**:专门为存储高维向量(通常维度成百上千)而优化,支持对向量数据进行增、删、改、查等基本操作。
- **丰富的查询能力**:除了基本的相似性搜索,还支持按标量字段过滤查询(例如,在搜索相似图片的同时,指定`年份 > 2023`)、范围查询和聚类分析等,满足复杂业务需求。
- **可扩展与高可用**:现代向量数据库通常采用分布式架构,具备良好的水平扩展能力和容错性,能够通过增加节点来应对数据量的增长,并确保服务的稳定可靠。
- **数据与模型生态集成**:与主流的 AI 框架(如 LangChain, LlamaIndex)和机器学习工作流无缝集成,简化了从模型训练到向量检索的应用开发流程。
### 1.2 向量数据库 vs 传统数据库
传统的数据库(如 MySQL)擅长处理结构化数据的精确匹配查询(例如,`WHERE age = 25`),但它们并非为处理高维向量的相似性搜索而设计的。在庞大的向量集合中进行暴力、线性的相似度计算,其计算成本和时间延迟无法接受。**向量数据库 (Vector Database)** 很好的解决了这一问题,它是一种专门设计用于高效存储、管理和查询高维向量的数据库系统。在 RAG 流程中,它扮演着“知识库”的角色,是连接数据与大语言模型的关键桥梁。
向量数据库与传统数据库的主要差异如下:
| **维度** | **向量数据库** | **传统数据库 (RDBMS)** |
| :--- | :--- | :--- |
| **核心数据类型** | 高维向量 (Embeddings) | 结构化数据 (文本、数字、日期) |
| **查询方式** | **相似性搜索** (ANN) | **精确匹配** |
| **索引机制** | HNSW, IVF, LSH 等 ANN 索引 | B-Tree, Hash Index |
| **主要应用场景** | AI 应用、RAG、推荐系统、图像/语音识别 | 业务系统 (ERP, CRM)、金融交易、数据报表 |
| **数据规模** | 轻松应对千亿级向量 | 通常在千万到亿级行数据,更大规模需复杂分库分表 |
| **性能特点** | 高维数据检索性能极高,计算密集型 | 结构化数据查询快,高维数据查询性能呈指数级下降 |
| **一致性** | 通常为最终一致性 | 强一致性 (ACID 事务) |
向量数据库和传统数据库并非相互替代的关系,而是**互补关系**。在构建现代 AI 应用时,通常会将两者结合使用:利用传统数据库存储业务元数据和结构化信息,而向量数据库则专门负责处理和检索由 AI 模型产生的海量向量数据。
## 二、工作原理
向量数据库的核心是高效处理高维向量的相似性搜索。向量是一组有序的数值,可以表示文本、图像、音频等复杂数据的特征或属性。在 RAG 系统中,向量一般通过嵌入模型将原始数据转换为高维向量表示,比如上一节的图文示例。向量数据库通常采用四层架构,通过存储层、索引层、查询层和服务层的协同工作来实现高效相似性搜索,其中存储层负责存储向量数据和元数据,优化存储效率并支持分布式存储;索引层维护索引算法(HNSW、LSH、PQ等),负责索引的创建与优化,并支持索引调整;查询层处理查询请求,支持混合查询并实现查询优化;服务层管理客户端连接,提供监控和日志能力,并实现安全管理。
主要技术手段包括:
- **基于树的方法**:如 Annoy 使用的随机投影树,通过树形结构实现对数复杂度的搜索
- **基于哈希的方法**:如 LSH(局部敏感哈希),通过哈希函数将相似向量映射到同一“桶”
- **基于图的方法**:如 HNSW(分层可导航小世界图),通过多层邻近图结构实现快速搜索
- **基于量化的方法**:如 Faiss 的 IVF 和 PQ,通过聚类和量化压缩向量
## 三、主流向量数据库介绍
![向量数据库分类图](./images/3_3_1.webp)
当前主流的向量数据库产品包括:
[ **Pinecone** ](https://www.pinecone.io/)是一款完全托管的向量数据库服务,采用Serverless架构设计。它提供存储计算分离、自动扩展和负载均衡等企业级特性,并保证99.95%的SLA。Pinecone支持多种语言SDK,提供极高可用性和低延迟搜索(<100ms),特别适合企业级生产环境、高并发场景和大规模部署。
[ **Milvus** ](https://github.com/milvus-io/milvus)是一款开源的分布式向量数据库,采用分布式架构设计,支持GPU加速和多种索引算法。它能够处理亿级向量检索,提供高性能GPU加速和完善的生态系统。Milvus特别适合大规模部署、高性能要求的场景,以及需要自定义开发的开源项目。
[ **Qdrant** ](https://github.com/qdrant/qdrant)是一款高性能的开源向量数据库,采用Rust开发,支持二进制量化技术。它提供多种索引策略和向量混合搜索功能,能够实现极高的性能(RPS>4000)和低延迟搜索。Qdrant特别适合性能敏感应用、高并发场景以及中小规模部署。
[ **Weaviate** ](https://github.com/weaviate/weaviate)是一款支持GraphQL的AI集成向量数据库,提供20+AI模块和多模态支持。它采用GraphQL API设计,支持RAG优化,特别适合AI开发、多模态处理和快速开发场景。Weaviate具有活跃的社区支持和易于集成的特点。
[ **Chroma** ](https://github.com/chroma-core/chroma)是一款轻量级的开源向量数据库,采用本地优先设计,无依赖。它提供零配置安装、本地运行和低资源消耗等特性,特别适合原型开发、教育培训和小规模应用。Chroma的部署简单,适合快速原型开发。
**选择建议**
- **新手入门/小型项目**:从 `ChromaDB``FAISS` 开始是最佳选择。它们与 LangChain/LlamaIndex 紧密集成,几行代码就能运行,且能满足基本的存储和检索需求。
- **生产环境/大规模应用**:当数据量超过百万级,或需要高并发、实时更新、复杂元数据过滤时,应考虑更专业的解决方案,如 `Milvus``Weaviate` 或云服务 `Pinecone`
## 四、本地向量存储:以 FAISS 为例
FAISS (Facebook AI Similarity Search) 是一个由 Facebook AI Research 开发的高性能库,专门用于高效的相似性搜索和密集向量聚类。当与 LangChain 结合使用时,它可以作为一个强大的本地向量存储方案,非常适合快速原型设计和中小型应用。
与 ChromaDB 等数据库不同,FAISS 本质上是一个算法库,它将索引直接保存为本地文件(一个 `.faiss` 索引文件和一个 `.pkl` 映射文件),而非运行一个数据库服务。这种方式轻量且高效。
### 4.1 环境准备
在开始之前,请确保已安装所有必需的库:
> 当前requirements.txt安装的 `faiss-cpu` 是 CPU 版本。如果你的机器有 GPU,可以安装 `faiss-gpu` 以获得更好的性能。
### 4.2 基础示例(FAISS)
下面的代码演示了使用 LangChain 和 FAISS 完成一个完整的“创建 -> 保存 -> 加载 -> 查询”流程。
```python
from langchain_community.vectorstores import FAISS
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_core.documents import Document
# 1. 示例文本和嵌入模型
texts = [
"张三是法外狂徒",
"FAISS是一个用于高效相似性搜索和密集向量聚类的库。",
"LangChain是一个用于开发由语言模型驱动的应用程序的框架。"
]
docs = [Document(page_content=t) for t in texts]
embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh-v1.5")
# 2. 创建向量存储并保存到本地
vectorstore = FAISS.from_documents(docs, embeddings)
local_faiss_path = "./faiss_index_store"
vectorstore.save_local(local_faiss_path)
print(f"FAISS index has been saved to {local_faiss_path}")
# 3. 加载索引并执行查询
# 加载时需指定相同的嵌入模型,并允许反序列化
loaded_vectorstore = FAISS.load_local(
local_faiss_path,
embeddings,
allow_dangerous_deserialization=True
)
# 相似性搜索
query = "FAISS是做什么的?"
results = loaded_vectorstore.similarity_search(query, k=1)
print(f"\n查询: '{query}'")
print("相似度最高的文档:")
for doc in results:
print(f"- {doc.page_content}")
```
**运行结果与解读**
当你运行上述脚本时,会看到类似以下的输出:
```bash
FAISS index has been saved to ./faiss_index_store
查询: 'FAISS是做什么的?'
相似度最高的文档:
- FAISS是一个用于高效相似性搜索和密集向量聚类的库。
```
**索引创建实现细节**
通过深入 LangChain 源码,可以发现索引创建是一个分层、解耦的过程,主要涉及以下几个方法的嵌套调用:
1. **`from_documents` (封装层)**:
* 这是我们直接调用的方法。它的职责很简单:从输入的 `Document` 对象列表中提取出纯文本内容 (`page_content`) 和元数据 (`metadata`)。
* 然后,它将这些提取出的信息传递给核心的 `from_texts` 方法。
2. **`from_texts` (向量化入口)**:
* 这个方法是面向用户的入口。它接收文本列表,并执行关键的第一步:调用 `embedding.embed_documents(texts)`,将所有文本批量转换为向量。
* 完成向量化后,它并不直接处理索引构建,而是将生成的向量和其他所有信息(文本、元数据等)传递给一个内部的辅助方法 `__from`
3. **`__from` (构建索引框架)**:
* 一个内部方法,负责搭建 FAISS 向量存储的“空框架”。
* 它会根据指定的距离策略(默认为 L2 欧氏距离)初始化一个空的 FAISS 索引结构(如 `faiss.IndexFlatL2`)。
* 同时,它也准备好了用于存储文档原文的 `docstore` 和用于连接 FAISS 索引与文档的 `index_to_docstore_id` 映射。
* 最后,它调用另一个内部方法 `__add` 来完成数据的填充。
4. **`__add` (填充数据)**:
* 真正执行数据添加操作的核心。它接收到向量、文本和元数据后,执行以下关键操作:
* **添加向量**: 将向量列表转换为 FAISS 需要的 `numpy` 数组,并调用 `self.index.add(vector)` 将其批量添加到 FAISS 索引中。
* **存储文档**: 将文本和元数据打包成 `Document` 对象,存入 `docstore`
* **建立映射**: 更新 `index_to_docstore_id` 字典,建立起 FAISS 内部的整数 ID(如 0, 1, 2...)到我们文档唯一 ID 的映射关系。
## 练习
1. LlamaIndex默认会将数据存储为透明可读的JSON格式,运行[03_llamaindex_vector.py](https://github.com/datawhalechina/all-in-rag/blob/main/code/C3/03_llamaindex_vector.py)文件,查看保存的json文件内容。
2. 新建一个代码文件实现对LlamaIndex存储数据的加载和相似性搜索。
+511
View File
@@ -0,0 +1,511 @@
# 第四节 Milvus介绍及多模态检索实践
## 一、简介
Milvus 是一个开源的、专为大规模向量相似性搜索和分析而设计的向量数据库。它诞生于 Zilliz 公司,并已成为 LF AI & Data 基金会的顶级项目,在AI领域拥有广泛的应用。
与 FAISS、ChromaDB 等轻量级本地存储方案不同,Milvus 从设计之初就瞄准了**生产环境**。其采用云原生架构,具备高可用、高性能、易扩展的特性,能够处理十亿、百亿甚至更大规模的向量数据。
**官网地址**: [https://milvus.io/](https://milvus.io/)
**GitHub**: [https://github.com/milvus-io/milvus](https://github.com/milvus-io/milvus)
## 二、 部署安装
Milvus 提供了多种部署方式,这里以 **Milvus Standalone (单机版)** 为例。
### 1. 环境准备
- **安装 Docker 与 Docker Compose**: 确保系统中已安装并正在运行 Docker 和 Docker Compose。如果你对 Docker 不熟悉,可以参考这篇详细的教程:[Docker 万字教程:从入门到掌握](https://mp.weixin.qq.com/s/u2es87JU5FNlGo3qDLY_ng)。
> codespace 环境自带Docker Compose无需安装
### 2. 下载并启动 Milvus
在你选定的工作目录下,打开终端(Terminal)或命令行工具(PowerShell),执行以下步骤:
**第一步:下载配置文件**
使用以下命令下载官方的 `docker-compose.yml` 文件。这个文件定义了 Milvus Standalone 及其运行所需的两个核心依赖服务:`etcd` 用于存储元数据,`MinIO` 用于对象存储(更多架构细节请参考[官方文档](https://milvus.io/docs/architecture_overview.md))。
```bash
# macOS / Linux (使用 wget)
wget https://github.com/milvus-io/milvus/releases/download/v2.5.14/milvus-standalone-docker-compose.yml -O docker-compose.yml
```
```powershell
# Windows (使用 PowerShell)
Invoke-WebRequest -Uri "https://github.com/milvus-io/milvus/releases/download/v2.5.14/milvus-standalone-docker-compose.yml" -OutFile "docker-compose.yml"
```
**第二步:启动 Milvus 服务**
`docker-compose.yml` 文件所在的目录中,运行以下命令以后台模式启动 Milvus:
```bash
docker compose up -d
```
Docker 将会自动拉取所需的镜像并启动三个容器:`milvus-standalone`, `milvus-minio`, 和 `milvus-etcd`。这个过程可能需要几分钟,具体取决于你的网络状况。
### 3. 验证安装
可以通过以下方式验证 Milvus 是否成功启动:
- **查看 Docker 容器**: 打开 Docker Desktop 的仪表盘 (Windows/macOS) 或在终端运行 `docker ps` 命令 (Linux),确认三个 Milvus 相关容器(`milvus-standalone`, `milvus-minio`, `milvus-etcd`)都处于 `running``up` 状态。
- **检查服务端口**: Milvus Standalone 默认通过 `19530` 端口提供服务,这是后续代码连接时需要用到的地址。
### 4. 常用管理命令
- **停止服务**:
```bash
docker compose down
```
此命令会停止并移除容器,但保留存储的数据卷。
- **彻底清理 (停止并删除数据)**:
如果想彻底删除所有数据(包括向量、元数据等),可以执行以下命令:
```bash
docker compose down -v
```
## 三、核心组件
### 3.1 Collection (集合)
可以用一个图书馆的比喻来理解 Collection
- **Collection (集合)**: 相当于一个**图书馆**,是所有数据的顶层容器。一个 Collection 可以包含多个 Partition,每个 Partition 可以包含多个 Entity。
- **Partition (分区)**: 相当于图书馆里的**不同区域**(如“小说区”、“科技区”),将数据物理隔离,让检索更高效。
- **Schema (模式)**: 相当于图书馆的**图书卡片规则**,定义了每本书(数据)必须登记哪些信息(字段)。
- **Entity (实体)**: 相当于**一本具体的书**,是数据本身。
- **Alias (别名)**: 相当于一个**动态的推荐书单**(如“本周精选”),它可以指向某个具体的 Collection,方便应用层调用,实现数据更新时的无缝切换。
**Collection** 是 Milvus 中最基本的数据组织单位,类似于关系型数据库中的一张**表 (Table)**。是我们存储、管理和查询向量及相关元数据的容器。所有的数据操作,如插入、删除、查询等,都是围绕 Collection 展开的。
一个 Collection 由其 **Schema** 定义,并包含以下重要的子概念和特性:
#### 3.1.1 Schema
在创建 Collection 之前,必须先定义它的 **Schema**。 `Schema` 规定了 Collection 的数据结构,定义了其中包含的所有**字段 (Field)** 及其属性。一个设计良好的 Schema 是能够保证数据一致性并提升查询性能。
Schema 通常包含以下几类字段:
- **主键字段 (Primary Key Field)**: 每个 Collection 必须有且仅有一个主键字段,用于唯一标识每一条数据(实体)。它的值必须是唯一的,通常是整数或字符串类型。
- **向量字段 (Vector Field)**: 用于存储核心的向量数据。一个 Collection 可以有一个或多个向量字段,以满足多模态等复杂场景的需求。
- **标量字段 (Scalar Field)**: 用于存储除向量之外的元数据,如字符串、数字、布尔值、JSON 等。这些字段可以用于过滤查询,实现更精确的检索。
![Schema 设计剖析](./images/3_4_1.webp)
上图以一篇新闻文章为例,展示了一个典型的多模态、混合向量 Schema 设计。它将一篇文章拆解为:唯一的 `Article (ID)`、文本元数据(如 `Title`、`Author Info`)、图像信息(`Image URL`),并为图像和摘要内容分别生成了密集向量(`Image Embedding`, `Summary Embedding`)和稀疏向量(`Summary Sparse Embedding`)。
#### 3.1.2 Partition (分区)
**Partition** 是 Collection 内部的一个逻辑划分。每个 Collection 在创建时都会有一个名为 `_default` 的默认分区。我们可以根据业务需求创建更多的分区,将数据按特定规则(如类别、日期等)存入不同分区。
**为什么使用分区?**
- **提升查询性能**: 在查询时,可以指定只在一个或几个分区内进行搜索,从而大幅减少需要扫描的数据量,显著提升检索速度。
- **数据管理**: 便于对部分数据进行批量操作,如加载/卸载特定分区到内存,或者删除整个分区的数据。
一个 Collection 最多可以有 1024 个分区。合理利用分区是 Milvus 性能优化的重要手段之一。
#### 3.1.3 Alias (别名)
**Alias** (别名) 是为 Collection 提供的一个“昵称”。通过为一个 Collection 设置别名,我们可以在应用程序中使用这个别名来执行所有操作,而不是直接使用真实的 Collection 名称。
**为什么使用别名?**
- **安全地更新数据**:想象一下,你需要对一个在线服务的 Collection 进行大规模的数据更新或重建索引。直接在原 Collection 上操作风险很高。正确的做法是:
1. 创建一个新的 Collection (`collection_v2`) 并导入、索引好所有新数据。
2. 将指向旧 Collection (`collection_v1`) 的别名(例如 `my_app_collection`)原子性地切换到新 Collection (`collection_v2`) 上。
- **代码解耦**:整个切换过程对上层应用完全透明,无需修改任何代码或重启服务,实现了数据的平滑无缝升级。
### 3.2 索引 (Index)
如果说 Collection 是 Milvus 的骨架,那么**索引 (Index)** 就是其加速检索的神经系统。从宏观上看,索引本身就是一种**为了加速查询而设计的复杂数据结构**。对向量数据创建索引后,Milvus 可以极大地提升向量相似性搜索的速度,代价是会占用额外的存储和内存资源。
![Milvus 索引结构与工作原理](./images/3_4_2.webp)
上图清晰地展示了 Milvus 向量索引的内部组件及其工作流程:
- **数据结构**:这是索引的骨架,定义了向量的组织方式(如 HNSW 中的图结构)。
- **量化**(可选):数据压缩技术,通过降低向量精度来减少内存占用和加速计算。
- **结果精炼**(可选):在找到初步候选集后,进行更精确的计算以优化最终结果。
Milvus 支持对标量字段和向量字段分别创建索引。
- **标量字段索引**:主要用于加速元数据过滤,常用的有 `INVERTED`、`BITMAP` 等。通常使用推荐的索引类型即可。
- **向量字段索引**:这是 Milvus 的核心。选择合适的向量索引是在查询性能、召回率和内存占用之间做出权衡的艺术。
#### 3.2.1 主要向量索引类型
Milvus 提供了多种向量索引算法,以适应不同的应用场景。以下是几种最核心的类型:
- **FLAT (精确查找)**
- **原理**:暴力搜索(Brute-force Search)。它会计算查询向量与集合中所有向量之间的实际距离,返回最精确的结果。
- **优点**:100% 的召回率,结果最准确。
- **缺点**:速度慢,内存占用大,不适合海量数据。
- **适用场景**:对精度要求极高,且数据规模较小(百万级以内)的场景。
- **IVF 系列 (倒排文件索引)**
- **原理**:类似于书籍的目录。它首先通过聚类将所有向量分成多个“桶”(`nlist`),查询时,先找到最相似的几个“桶”,然后只在这几个桶内进行精确搜索。`IVF_FLAT`、`IVF_SQ8`、`IVF_PQ` 是其不同变体,主要区别在于是否对桶内向量进行了压缩(量化)。
- **优点**:通过缩小搜索范围,极大地提升了检索速度,是性能和效果之间很好的平衡。
- **缺点**:召回率不是100%,因为相关向量可能被分到了未被搜索的桶中。
- **适用场景**:通用场景,尤其适合需要高吞吐量的大规模数据集。
- **HNSW (基于图的索引)**
- **原理**:构建一个多层的邻近图。查询时从最上层的稀疏图开始,快速定位到目标区域,然后在下层的密集图中进行精确搜索。
- **优点**:检索速度极快,召回率高,尤其擅长处理高维数据和低延迟查询。
- **缺点**:内存占用非常大,构建索引的时间也较长。
- **适用场景**:对查询延迟有严格要求(如实时推荐、在线搜索)的场景。
- **DiskANN (基于磁盘的索引)**
- **原理**:一种为在 SSD 等高速磁盘上运行而优化的图索引。
- **优点**:支持远超内存容量的海量数据集(十亿级甚至更多),同时保持较低的查询延迟。
- **缺点**:相比纯内存索引,延迟稍高。
- **适用场景**:数据规模巨大,无法全部加载到内存的场景。
#### 3.2.2 如何选择索引?
选择索引没有唯一的“最佳答案”,需要根据业务场景在**数据规模、内存限制、查询性能和召回率**之间进行权衡。
| 场景 | 推荐索引 | 备注 |
| :--- | :--- | :--- |
| 数据可完全载入内存,追求低延迟 | **HNSW** | 内存占用较大,但查询性能和召回率都很优秀。 |
| 数据可完全载入内存,追求高吞吐 | **IVF_FLAT / IVF_SQ8** | 性能和资源消耗的平衡之选。 |
| 数据量巨大,无法载入内存 | **DiskANN** | 在 SSD 上性能优异,专为海量数据设计。 |
| 追求 100% 准确率,数据量不大 | **FLAT** | 暴力搜索,确保结果最精确。 |
在实际应用中,通常需要通过测试来找到最适合自己数据和查询模式的索引类型及其参数。
### 3.3 检索
#### 3.3.1 基础向量检索 (ANN Search)
拥有了数据容器 (Collection) 和检索引擎 (Index) 后,最后一步就是从海量数据中高效地检索信息。这是 Milvus 的核心功能之一,**近似最近邻 (Approximate Nearest Neighbor, ANN) 检索**。与需要计算全部数据的暴力检索(Brute-force Search)不同,ANN 检索利用预先构建好的索引,能够极速地从海量数据中找到与查询向量最相似的 Top-K 个结果。这是一种在速度和精度之间取得极致平衡的策略。
- **主要参数**:
- `anns_field`: 指定要在哪个向量字段上进行检索。
- `data`: 传入一个或多个查询向量。
- `limit` (或 `top_k`): 指定需要返回的最相似结果的数量。
- `search_params`: 指定检索时使用的参数,例如距离计算方式 (`metric_type`) 和索引相关的查询参数。
#### 3.3.2 增强检索
在基础的 ANN 检索之上,Milvus 提供了多种增强检索功能,以满足更复杂的业务需求。
**过滤检索 (Filtered Search)**
在实际应用中,我们很少只进行单纯的向量检索。更常见的需求是“在满足特定条件的向量中,查找最相似的结果”,这就是过滤检索。它将**向量相似性检索**与**标量字段过滤**结合在一起。
- **工作原理**:先根据提供的过滤表达式 (`filter`) 筛选出符合条件的实体,然后仅在这个子集内执行 ANN 检索。这极大地提高了查询的精准度。
- **应用示例**
- **电商**:"检索与这件红色连衣裙最相似的商品,但只看价格低于500元且有库存的。"
- **知识库**:"查找与‘人工智能’相关的文档,但只从‘技术’分类下、且发布于2023年之后的文章中寻找。"
**范围检索 (Range Search)**
有时我们关心的不是最相似的 Top-K 个结果,而是“所有与查询向量的相似度在特定范围内的结果”。
- **工作原理**:范围检索允许定义一个距离(或相似度)的阈值范围。Milvus 会返回所有与查询向量的距离落在这个范围内的实体。
- **应用示例**
- **人脸识别**:"查找所有与目标人脸相似度超过 0.9 的人脸",用于身份验证。
- **异常检测**:"查找所有与正常样本向量距离过大的数据点",用于发现异常。
**多向量混合检索 (Hybrid Search)**
这是 Milvus 提供的一种极其强大的高级检索模式,它允许在一个请求中同时检索**多个向量字段**,并将结果智能地融合在一起。
- **工作原理**
1. **并行检索**:应用针对不同的向量字段(如一个用于文本语义的密集向量,一个用于关键词匹配的稀疏向量,一个用于图像内容的多模态向量)分别发起 ANN 检索请求。
2. **结果融合 (Rerank)**Milvus 使用一个重排策略(Reranker)将来自不同检索流的结果合并成一个统一的、更高质量的排序列表。常用的策略有 `RRFRanker`(平衡各方结果)和 `WeightedRanker`(可为特定字段结果加权)。
- **应用示例**
- **多模态商品检索**:用户输入文本“安静舒适的白色耳机”,系统可以同时检索商品的**文本描述向量**和**图片内容向量**,返回最匹配的商品。
- **增强型 RAG**: 结合**密集向量**(捕捉语义)和**稀疏向量**(精确匹配关键词),实现比单一向量更精准的文档检索效果。
**分组检索 (Grouping Search)**
分组检索解决了一个常见的痛点:检索结果多样性不足。想象一下,你检索“机器学习”,返回的前10篇文章都来自同一本教科书不同章节。这显然不是理想的结果。
- **工作原理**:分组检索允许指定一个字段(如 `document_id`)对结果进行分组。Milvus 会在检索后,确保返回的结果中每个组(每个 `document_id`)只出现一次(或指定的次数),且返回的是该组内与查询最相似的那个实体。
- **应用示例**
- **视频检索**:检索“可爱的猫咪”,确保返回的视频来自不同的博主。
- **文档检索**:检索“数据库索引”,确保返回的结果来自不同的书籍或来源。
通过这些灵活的检索功能组合,开发者可以构建出满足各种复杂业务需求的向量检索应用。
## 四、milvus多模态实践
在本节中,我们将通过一个完整的示例,演示如何使用 Milvus 和 Visualized-BGE 模型构建一个端到端的图文多模态检索引擎。
### 4.1 初始化与工具定义
首先导入所有必需的库,定义好模型路径、数据目录等常量。为了代码的整洁和复用,将 Visualized-BGE 模型的加载和编码逻辑封装在一个 `Encoder` 类中,并定义了一个 `visualize_results` 函数用于后续的结果可视化。
```python
import os
from tqdm import tqdm
from glob import glob
import torch
from visual_bge.visual_bge.modeling import Visualized_BGE
from pymilvus import MilvusClient, FieldSchema, CollectionSchema, DataType
import numpy as np
import cv2
from PIL import Image
# 1. 初始化设置
MODEL_NAME = "BAAI/bge-base-en-v1.5"
MODEL_PATH = "../../models/bge/Visualized_base_en_v1.5.pth"
DATA_DIR = "../../data/C3"
COLLECTION_NAME = "multimodal_demo"
MILVUS_URI = "http://localhost:19530"
# 2. 定义工具 (编码器和可视化函数)
class Encoder:
"""编码器类,用于将图像和文本编码为向量。"""
def __init__(self, model_name: str, model_path: str):
self.model = Visualized_BGE(model_name_bge=model_name, model_weight=model_path)
self.model.eval()
def encode_query(self, image_path: str, text: str) -> list[float]:
with torch.no_grad():
query_emb = self.model.encode(image=image_path, text=text)
return query_emb.tolist()[0]
def encode_image(self, image_path: str) -> list[float]:
with torch.no_grad():
query_emb = self.model.encode(image=image_path)
return query_emb.tolist()[0]
def visualize_results(query_image_path: str, retrieved_images: list, img_height: int = 300, img_width: int = 300, row_count: int = 3) -> np.ndarray:
"""从检索到的图像列表创建一个全景图用于可视化。"""
panoramic_width = img_width * row_count
panoramic_height = img_height * row_count
panoramic_image = np.full((panoramic_height, panoramic_width, 3), 255, dtype=np.uint8)
query_display_area = np.full((panoramic_height, img_width, 3), 255, dtype=np.uint8)
# 处理查询图像
query_pil = Image.open(query_image_path).convert("RGB")
query_cv = np.array(query_pil)[:, :, ::-1]
resized_query = cv2.resize(query_cv, (img_width, img_height))
bordered_query = cv2.copyMakeBorder(resized_query, 10, 10, 10, 10, cv2.BORDER_CONSTANT, value=(255, 0, 0))
query_display_area[img_height * (row_count - 1):, :] = cv2.resize(bordered_query, (img_width, img_height))
cv2.putText(query_display_area, "Query", (10, panoramic_height - 20), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2)
# 处理检索到的图像
for i, img_path in enumerate(retrieved_images):
row, col = i // row_count, i % row_count
start_row, start_col = row * img_height, col * img_width
retrieved_pil = Image.open(img_path).convert("RGB")
retrieved_cv = np.array(retrieved_pil)[:, :, ::-1]
resized_retrieved = cv2.resize(retrieved_cv, (img_width - 4, img_height - 4))
bordered_retrieved = cv2.copyMakeBorder(resized_retrieved, 2, 2, 2, 2, cv2.BORDER_CONSTANT, value=(0, 0, 0))
panoramic_image[start_row:start_row + img_height, start_col:start_col + img_width] = bordered_retrieved
# 添加索引号
cv2.putText(panoramic_image, str(i), (start_col + 10, start_row + 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
return np.hstack([query_display_area, panoramic_image])
```
### 4.2 创建 Collection
这是与 Milvus 交互的开始。首先初始化 Milvus 客户端,然后定义 Collection 的 Schema,它规定了集合的数据结构。
```python
# 3. 初始化客户端
print("--> 正在初始化编码器和Milvus客户端...")
encoder = Encoder(MODEL_NAME, MODEL_PATH)
milvus_client = MilvusClient(uri=MILVUS_URI)
# 4. 创建 Milvus Collection
print(f"\n--> 正在创建 Collection '{COLLECTION_NAME}'")
if milvus_client.has_collection(COLLECTION_NAME):
milvus_client.drop_collection(COLLECTION_NAME)
print(f"已删除已存在的 Collection: '{COLLECTION_NAME}'")
image_list = glob(os.path.join(DATA_DIR, "dragon", "*.png"))
if not image_list:
raise FileNotFoundError(f"在 {DATA_DIR}/dragon/ 中未找到任何 .png 图像。")
dim = len(encoder.encode_image(image_list[0]))
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dim),
FieldSchema(name="image_path", dtype=DataType.VARCHAR, max_length=512),
]
# 创建集合 Schema
schema = CollectionSchema(fields, description="多模态图文检索")
print("Schema 结构:")
print(schema)
# 创建集合
milvus_client.create_collection(collection_name=COLLECTION_NAME, schema=schema)
print(f"成功创建 Collection: '{COLLECTION_NAME}'")
print("Collection 结构:")
print(milvus_client.describe_collection(collection_name=COLLECTION_NAME))
```
**输出结果:**
```bash
--> 正在创建 Collection 'multimodal_demo'
Schema 结构:
{
'auto_id': True,
'description': '多模态图文检索',
'fields': [
{'name': 'id', 'description': '', 'type': <DataType.INT64: 5>, 'is_primary': True, 'auto_id': True},
{'name': 'vector', 'description': '', 'type': <DataType.FLOAT_VECTOR: 101>, 'params': {'dim': 768}},
{'name': 'image_path', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 512}}
],
'enable_dynamic_field': False
}
成功创建 Collection: 'multimodal_demo'
Collection 结构:
{
'collection_name': 'multimodal_demo',
'auto_id': True,
'num_shards': 1,
'description': '多模态图文检索',
'fields': [
{'field_id': 100, 'name': 'id', 'description': '', 'type': <DataType.INT64: 5>, 'params': {}, 'auto_id': True, 'is_primary': True},
{'field_id': 101, 'name': 'vector', 'description': '', 'type': <DataType.FLOAT_VECTOR: 101>, 'params': {'dim': 768}},
{'field_id': 102, 'name': 'image_path', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 512}}
],
'functions': [],
'aliases': [],
'collection_id': 459243798405253751,
'consistency_level': 2,
'properties': {},
'num_partitions': 1,
'enable_dynamic_field': False,
'created_timestamp': 459249546649403396,
'update_timestamp': 459249546649403396
}
```
上面的输出详细展示了刚刚创建的 `multimodal_demo` Collection 的完整结构。其 **Schema** 包含了三个核心字段(**Field**):一个自增的 `id` 作为**主键**,一个 768 维的 `vector` **向量字段**用于存储图像嵌入,以及一个 `image_path` **标量字段**来记录原始图片路径。
### 4.3 准备并插入数据
创建好 Collection 后,需要将数据填充进去。通过遍历指定目录下的所有图片,将它们逐一编码成向量,然后与图片路径一起组织成符合 Schema 结构的格式,最后批量插入到 Collection 中。
```python
# 5. 准备并插入数据
print(f"\n--> 正在向 '{COLLECTION_NAME}' 插入数据")
data_to_insert = []
for image_path in tqdm(image_list, desc="生成图像嵌入"):
vector = encoder.encode_image(image_path)
data_to_insert.append({"vector": vector, "image_path": image_path})
if data_to_insert:
result = milvus_client.insert(collection_name=COLLECTION_NAME, data=data_to_insert)
print(f"成功插入 {result['insert_count']} 条数据。")
```
### 4.4 创建索引
为了实现快速检索,需要为向量字段创建索引。这里选择 `HNSW` 索引,它在召回率和查询性能之间有着很好的平衡。创建索引后,必须调用 `load_collection` 将集合加载到内存中才能进行搜索。
```python
# 6. 创建索引
print(f"\n--> 正在为 '{COLLECTION_NAME}' 创建索引")
index_params = milvus_client.prepare_index_params()
index_params.add_index(
field_name="vector",
index_type="HNSW",
metric_type="COSINE",
params={"M": 16, "efConstruction": 256}
)
milvus_client.create_index(collection_name=COLLECTION_NAME, index_params=index_params)
print("成功为向量字段创建 HNSW 索引。")
print("索引详情:")
print(milvus_client.describe_index(collection_name=COLLECTION_NAME, index_name="vector"))
milvus_client.load_collection(collection_name=COLLECTION_NAME)
print("已加载 Collection 到内存中。")
```
**输出结果:**
```bash
--> 正在为 'multimodal_demo' 创建索引
成功为向量字段创建 HNSW 索引。
索引详情:
{'M': '16', 'efConstruction': '256', 'metric_type': 'COSINE', 'index_type': 'HNSW', 'field_name': 'vector', 'index_name': 'vector', 'total_rows': 0, 'indexed_rows': 0, 'pending_index_rows': 0, 'state': 'Finished'}
已加载 Collection 到内存中。
```
可以看出,索引创建成功,在 `vector` 字段上成功创建了 `HNSW` 索引,并使用 `COSINE` 作为距离度量。`M: '16'` 和 `efConstruction: '256'` 是 HNSW 索引的两个关键参数,分别控制着图中每个节点的最大连接数和索引构建时的搜索范围,这些参数直接影响检索的性能和准确性。`state: 'Finished'` 状态表明索引已成功构建。
### 4.5 执行多模态检索
这里通过定义一个包含图片和文本的组合查询,将其编码为查询向量,然后调用 `search` 方法在 Milvus 中执行近似最近邻搜索。
```python
# 7. 执行多模态检索
print(f"\n--> 正在 '{COLLECTION_NAME}' 中执行检索")
query_image_path = os.path.join(DATA_DIR, "dragon", "query.png")
query_text = "一条龙"
query_vector = encoder.encode_query(image_path=query_image_path, text=query_text)
search_results = milvus_client.search(
collection_name=COLLECTION_NAME,
data=[query_vector],
output_fields=["image_path"],
limit=5,
search_params={"metric_type": "COSINE", "params": {"ef": 128}}
)[0]
retrieved_images = []
print("检索结果:")
for i, hit in enumerate(search_results):
print(f" Top {i+1}: ID={hit['id']}, 距离={hit['distance']:.4f}, 路径='{hit['entity']['image_path']}'")
retrieved_images.append(hit['entity']['image_path'])
```
**输出结果:**
```bash
--> 正在 'multimodal_demo' 中执行检索
检索结果:
Top 1: ID=459243798403756667, 距离=0.9411, 路径='../../data/C3\dragon\dragon01.png'
Top 2: ID=459243798403756668, 距离=0.5818, 路径='../../data/C3\dragon\dragon02.png'
Top 3: ID=459243798403756671, 距离=0.5731, 路径='../../data/C3\dragon\dragon05.png'
Top 4: ID=459243798403756670, 距离=0.4894, 路径='../../data/C3\dragon\dragon04.png'
Top 5: ID=459243798403756669, 距离=0.4100, 路径='../../data/C3\dragon\dragon03.png'
```
这段输出展示了与图文组合查询最相似的5个**实体 (Entity)**。`distance` 字段代表了**余弦相似度**,值越接近 1 表示越相似。可以看到,`Top 1` 结果正是查询图片本身,其相似度得分最高(0.9411),这说明了检索的有效性。其余结果也都是龙的图片,并按相似度从高到低精确排列。
### 4.6 可视化与清理
最后,将检索到的图片路径用于可视化,生成一张直观的结果对比图。在完成所有操作后,应该释放 Milvus 中的资源,包括从内存中卸载 Collection 和删除整个 Collection。
```python
# 8. 可视化与清理
print(f"\n--> 正在可视化结果并清理资源")
if not retrieved_images:
print("没有检索到任何图像。")
else:
panoramic_image = visualize_results(query_image_path, retrieved_images)
combined_image_path = os.path.join(DATA_DIR, "search_result.png")
cv2.imwrite(combined_image_path, panoramic_image)
print(f"结果图像已保存到: {combined_image_path}")
Image.open(combined_image_path).show()
milvus_client.release_collection(collection_name=COLLECTION_NAME)
print(f"已从内存中释放 Collection: '{COLLECTION_NAME}'")
milvus_client.drop_collection(COLLECTION_NAME)
print(f"已删除 Collection: '{COLLECTION_NAME}'")
```
![检索结果可视化](./images/3_4_3.png)
通过上图可以看出,这个多模态检索引擎成功地理解了“一条龙”这个图文组合查询的意图,并从图库中找到了最相关的几张图片并进行排序。
> [本节完整代码](https://github.com/datawhalechina/all-in-rag/blob/main/code/C3/04_multi_milvus.py)
+277
View File
@@ -0,0 +1,277 @@
# 第五节 索引优化
在上一章的文本分块部分,已经简单介绍了一些索引优化的策略。本节将基于LlamaIndex的高性能生产级RAG构建方案[^1],对索引优化进行更深入的探讨。
## 一、上下文扩展
在RAG系统中,常常面临一个权衡问题:使用小块文本进行检索可以获得更高的精确度,但小块文本缺乏足够的上下文,可能导致大语言模型(LLM)无法生成高质量的答案;而使用大块文本虽然上下文丰富,却容易引入噪音,降低检索的相关性。为了解决这一矛盾,LlamaIndex 提出了一种实用的索引策略——**句子窗口检索(Sentence Window Retrieval**[^2]。该技术巧妙地结合了两种方法的优点:它在检索时聚焦于高度精确的单个句子,在送入LLM生成答案前,又智能地将上下文扩展回一个更宽的“窗口”,从而同时保证检索的准确性和生成的质量。
### 1.1 主要思路
句子窗口检索的思想可以概括为:**为检索精确性而索引小块,为上下文丰富性而检索大块**。
其工作流程如下:
(1)**索引阶段**:在构建索引时,文档被分割成**单个句子**。每个句子都作为一个独立的“节点(Node)”存入向量数据库。同时,每个句子节点都会在元数据(metadata)中存储其**上下文窗口**,即该句子原文中的前N个和后N个句子。这个窗口内的文本不会被索引,仅仅是作为元数据存储。
(2)**检索阶段**:当用户发起查询时,系统会在所有**单一句子节点**上执行相似度搜索。因为句子是表达完整语义的最小单位,所以这种方式可以非常精确地定位到与用户问题最相关的核心信息。
(3)**后处理阶段**:在检索到最相关的句子节点后,系统会使用一个名为 `MetadataReplacementPostProcessor` 的后处理模块。该模块会读取到检索到的句子节点的元数据,并用元数据中存储的**完整上下文窗口**来替换节点中原来的单一句子内容。
(4)**生成阶段**:最后,这些被替换了内容的、包含丰富上下文的节点被传递给LLM,用于生成最终的答案。
### 1.2 代码实现
下面通过 LlamaIndex 官网的示例,来演示如何实现句子窗口检索,并与常规的检索方法进行对比。该示例将加载一份PDF格式的IPCC气候报告,并就其中的专业问题进行提问。
核心代码如下:
```python
# 假设 Settings.llm 和 Settings.embed_model 已经预先配置好
# 1. 加载文档
documents = SimpleDirectoryReader(
input_files=["../../data/C3/pdf/IPCC_AR6_WGII_Chapter03.pdf"]
).load_data()
# 2. 创建节点与构建索引
# 2.1 句子窗口索引
node_parser = SentenceWindowNodeParser.from_defaults(
window_size=3,
window_metadata_key="window",
original_text_metadata_key="original_text",
)
sentence_nodes = node_parser.get_nodes_from_documents(documents)
sentence_index = VectorStoreIndex(sentence_nodes)
```
根据 LlamaIndex 的底层源码,`SentenceWindowNodeParser` 的核心逻辑位于 `build_window_nodes_from_documents` 方法中。其实现过程可以分解为以下几个关键步骤:
1**句子切分 (`sentence_splitter`)** :解析器首先接收一个文档(`Document`),然后调用 `self.sentence_splitter(doc.text)` 方法。这个 `sentence_splitter` 是一个可配置的函数,默认为 `split_by_sentence_tokenizer`,它负责将文档的全部文本精确地切分成一个句子列表(`text_splits`)。
2**创建基础节点 (`build_nodes_from_splits`)** :切分出的 `text_splits` 列表被传递给 `build_nodes_from_splits` 工具函数。这个函数会为列表中的**每一个句子**都创建一个独立的 `TextNode`。此时,每个 `TextNode``text` 属性就是这个句子的内容。
(3)**构建窗口并填充元数据 (主要循环)** :接下来,解析器会遍历所有新创建的 `TextNode`。对于位于第 `i` 个位置的节点,它会执行以下操作:
* **定位窗口**:通过列表切片 `nodes[max(0, i - self.window_size) : min(i + self.window_size + 1, len(nodes))]` 来获取一个包含中心句子及其前后 `window_size`(默认为3)个邻近节点的列表(`window_nodes`)。这个切片操作很巧妙地处理了文档开头和结尾的边界情况。
* **组合窗口文本**:将 `window_nodes` 列表中所有节点的 `text`(即所有在窗口内的句子)用空格拼接成一个长字符串。
* **填充元数据**:将上一步生成的长字符串(完整的上下文窗口)存入当前节点(第`i`个节点)的元数据中,键为 `self.window_metadata_key`(默认为 `"window"`)。同时,也会将节点自身的文本(原始句子)存入元数据,键为 `self.original_text_metadata_key`(默认为 `"original_text"`)。
4. **设置元数据排除项**:这是一个非常关键的细节。在填充完元数据后,代码会执行 `node.excluded_embed_metadata_keys.extend(...)``node.excluded_llm_metadata_keys.extend(...)`。这行代码的作用是告诉后续的嵌入模型和LLM,在处理这个节点时,**应当忽略** `"window"``"original_text"` 这两个元数据字段。这确保了只有单个句子的纯净文本被用于生成向量嵌入,从而保证了检索的高精度。而 `"window"` 字段仅供后续的 `MetadataReplacementPostProcessor` 使用。
通过以上步骤,`SentenceWindowNodeParser` 最终返回一个 `TextNode` 列表。列表中的每个节点都代表一个独立的句子,其 `text` 属性用于精确检索,而其 `metadata` 中则“隐藏”了用于生成答案的丰富上下文窗口。
```python
# 2.2 常规分块索引 (基准)
base_parser = SentenceSplitter(chunk_size=512)
base_nodes = base_parser.get_nodes_from_documents(documents)
base_index = VectorStoreIndex(base_nodes)
# 3. 构建查询引擎
sentence_query_engine = sentence_index.as_query_engine(
similarity_top_k=2,
node_postprocessors=[
MetadataReplacementPostProcessor(target_metadata_key="window")
],
)
base_query_engine = base_index.as_query_engine(similarity_top_k=2)
# 4. 执行查询并对比结果
query = "What are the concerns surrounding the AMOC?"
print(f"查询: {query}\n")
print("--- 句子窗口检索结果 ---")
window_response = sentence_query_engine.query(query)
print(f"回答: {window_response}\n")
print("--- 常规检索结果 ---")
base_response = base_query_engine.query(query)
print(f"回答: {base_response}\n")
```
(1)**构建句子窗口索引**:这一步利用了 `SentenceWindowNodeParser`。它将文档解析为以单个句子为单位的 `Node`,同时将包含上下文的“窗口”文本(默认为前后各3个句子)存储在每个 `Node` 的元数据中。这一步是实现“为检索精确性而索引小块”思想的关键。
(2)**构建查询引擎与后处理**:查询引擎的构建是实现“为生成质量而扩展上下文”的关键。
* 在创建 `sentence_query_engine` 时,配置中加入了一个重要的后处理器 `MetadataReplacementPostProcessor`
* 它的作用是:当检索器根据用户查询找到最相关的节点(也就是单个句子)后,这个后处理器会立即介入。
* 它会从该节点的元数据中读取出预先存储的完整“窗口”文本,并用它**替换**掉节点中原来的单个句子内容。
* 这样,最终传递给大语言模型的就不再是孤立的句子,而是包含丰富上下文的完整文本段落,从而确保了生成答案的质量和连贯性。
我们向两个引擎提出的问题是:“关于大西洋经向翻转环流(AMOC),人们主要担忧什么?” (What are the concerns surrounding the AMOC?)。
**代码输出如下:**
```bash
查询: What are the concerns surrounding the AMOC?
--- 句子窗口检索结果 ---
回答: The Atlantic Meridional Overturning Circulation (AMOC) is projected to decline over the 21st century with high confidence, though there is low confidence in quantitative projections of this decline. Observational records since the mid-2000s are too short to determine the relative contributions of internal variability, natural forcing, and anthropogenic forcing to AMOC changes. Additionally, there is low confidence in reconstructed and modeled AMOC changes for the 20th century due to limited agreement in quantitative trends. While an abrupt collapse before 2100 is not expected, the decline could have significant implications for global climate patterns.
--- 常规检索结果 ---
回答: The concerns surrounding the Atlantic Meridional Overturning Circulation (AMOC) primarily involve its projected decline over the 21st century across all Shared Socioeconomic Pathway (SSP) scenarios. While an abrupt collapse before 2100 is not expected, there is high confidence in this decline, though quantitative projections remain uncertain. Observational records since the mid-2000s are too short to clearly distinguish the contributions of internal variability, natural forcing, and anthropogenic forcing to these changes. This uncertainty highlights the need for further research to better understand and predict AMOC behavior and its broader climate impacts.
```
从输出结果中可以观察到:
* **两个答案都抓住了核心**:两个引擎都正确地识别出,对AMOC的主要担忧是其在21世纪预计的衰退。
* **句子窗口检索的答案更详尽、更连贯**:句子窗口检索的回答不仅指出了衰退的趋势,还补充了关于“定量预测的置信度低”、“观测记录时间过短”、“20世纪重建和模拟的变化置信度低”等多个维度的细节。这使得答案的信息量更大,上下文更完整,更像一个综述。
* **常规检索的答案相对宽泛**:常规检索的回答虽然正确,但内容相对概括,最后以“需要进一步研究”这样较为笼同的结论收尾。
这种差异正是句子窗口检索策略优势的体现。它通过“精确检索小文本块(单个句子),再扩展上下文(句子窗口)”的方式,为大语言模型提供了高度相关且信息丰富的上下文,从而生成了质量更高的答案。
> [完整代码](https://github.com/datawhalechina/all-in-rag/blob/main/code/C3/05_sentence_window_retrieval.py)
## 二、结构化索引
随着知识库的规模不断扩大(例如,包含数百个PDF文件),传统的RAG方法(即对所有文本块进行top-k相似度搜索)会遇到瓶颈。当一个查询可能只与其中一两个文档相关时,在整个文档库中进行无差别的向量搜索,不仅效率低下,还容易被不相关的文本块干扰,导致检索结果不精确。
为了解决这个问题,一个有效的方法是利用**结构化索引**。其原理是在索引文本块的同时,为其附加结构化的**元数据(Metadata)**。这些元数据可以是任何有助于筛选和定位信息的标签,例如:
* 文件名
* 文档创建日期
* 章节标题
* 作者
* 任何自定义的分类标签
![结构化索引](./images/3_5_1.webp)
实际上,在第二章“文本分块”中介绍的**基于文档结构的分块**方法,就是实现结构化索引的一种前置步骤。例如,在使用 `MarkdownHeaderTextSplitter` 时,分块器会自动将Markdown文档的各级标题(如 `Header 1`, `Header 2` 等)提取并存入每个文本块的元数据中。这些标题信息就是非常有价值的结构化数据,可以直接用于后续的元数据过滤。
通过这种方式,可以在检索时实现“元数据过滤”和“向量搜索”的结合。例如,当用户查询“请总结一下2023年第二季度财报中关于AI的论述”时,系统可以:
(1)**元数据预过滤**:首先通过元数据筛选,只在 `document_type == '财报'``year == 2023``quarter == 'Q2'` 的文档子集中进行搜索。
(2)**向量搜索**:然后,在经过滤的、范围更小的文本块集合中,执行针对查询“关于AI的论述”的向量相似度搜索。
这种“先过滤,再搜索”的策略,能够极大地缩小检索范围,显著提升大规模知识库场景下RAG应用的检索效率和准确性。LlamaIndex 提供了包括“自动检索”(Auto-Retrieval)在内的多种工具来支持这种结构化的检索范式。
### 2.1 代码实现:基于多表格的递归检索
在更复杂的场景中,结构化数据可能分布在多个来源中,例如一个包含多个工作表(Sheet)的 Excel 文件,每个工作表都代表一个独立的表格。在这种情况下,需要一种更强大的策略:**递归检索**[^3]。它能实现“路由”功能,先将查询引导至正确的知识来源(正确的表格),然后再在该来源内部执行精确查询。
下面使用一个包含多个工作表的电影数据 Excel 文件(`movie.xlsx`)来演示,其中每个工作表(如 `年份_1994`, `年份_2002` 等)都存储了对应年份的电影信息。
```python
# 1. 为每个工作表创建查询引擎和摘要节点
excel_file = '../../data/C3/excel/movie.xlsx'
xls = pd.ExcelFile(excel_file)
df_query_engines = {}
all_nodes = []
for sheet_name in xls.sheet_names:
df = pd.read_excel(xls, sheet_name=sheet_name)
# 为当前工作表创建一个 PandasQueryEngine
query_engine = PandasQueryEngine(df=df, llm=Settings.llm, verbose=True)
# 为当前工作表创建一个摘要节点(IndexNode)
year = sheet_name.replace('年份_', '')
summary = f"这个表格包含了年份为 {year} 的电影信息,可以用来回答关于这一年电影的具体问题。"
node = IndexNode(text=summary, index_id=sheet_name)
all_nodes.append(node)
# 存储工作表名称到其查询引擎的映射
df_query_engines[sheet_name] = query_engine
# 2. 创建顶层索引(只包含摘要节点)
vector_index = VectorStoreIndex(all_nodes)
# 3. 创建递归检索器
vector_retriever = vector_index.as_retriever(similarity_top_k=1)
recursive_retriever = RecursiveRetriever(
"vector",
retriever_dict={"vector": vector_retriever},
query_engine_dict=df_query_engines,
verbose=True,
)
# 4. 创建查询引擎
query_engine = RetrieverQueryEngine.from_args(recursive_retriever)
# 5. 执行查询
query = "1994年评分人数最多的电影是哪一部?"
print(f"查询: {query}")
response = query_engine.query(query)
print(f"回答: {response}")
```
1. **创建 PandasQueryEngine** :遍历 Excel 中的每个工作表,为每个工作表(即一个独立的 DataFrame)都实例化一个 `PandasQueryEngine`。其强大之处在于,它能将关于表格的自然语言问题(如“评分人数最多的是哪个”)转换成实际的 Pandas 代码(如 `df.sort_values('评分人数').iloc[-1]`)来执行。
2. **创建摘要节点 (`IndexNode`)** :对每个工作表,都创建一个 `IndexNode`,其内容是关于这个表格的一段摘要文本。这个节点将作为顶层检索的“指针”。
3. **构建顶层索引** :使用所有创建的 `IndexNode` 构建一个 `VectorStoreIndex`。这个索引不包含任何表格的详细数据,只包含指向各个表格的“指针”信息。
4. **创建 `RecursiveRetriever`** :这是实现递归检索的核心。将其配置为:
* `retriever_dict`: 指定顶层的检索器,即在摘要节点中进行检索的 `vector_retriever`
* `query_engine_dict`: 提供一个从节点 ID(即工作表名称)到其对应查询引擎的映射。当顶层检索器匹配到某个摘要节点后,递归检索器就知道该调用哪个 `PandasQueryEngine` 来处理后续查询。
**运行结果:**
```bash
查询: 1994年评分人数最少的电影是哪一部?
> Retrieving with query id None: 1994年评分人数最少的电影是哪一部?
> Retrieved node with id, entering: 年份_1994
> Retrieving with query id 年份_1994: 1994年评分人数最少的电影是哪一部?
> Pandas Instructions:
```
df[df['年份'] == 1994].nsmallest(1, '评分人数')['电影名称'].iloc[0]
```
> Pandas Output: 燃情岁月
回答: 燃情岁月
```
从输出中可以清晰地看到递归检索的完整流程:
1**顶层路由**`Retrieving with query id None`,系统首先在顶层的摘要索引中检索,根据问题“1994年...”匹配到了摘要节点 `年份_1994`
2**进入子层**`Retrieved node with id, entering: 年份_1994`,系统决定进入与“年份_1994”这个工作表关联的查询引擎。
3**子层查询**`Retrieving with query id 年份_1994``PandasQueryEngine` 接管查询,并将问题发送给 LLM,让其生成 Pandas 代码。
(4)**代码生成与执行**:LLM 生成了 `df[df['年份'] == 1994].nsmallest(1, '评分人数')['电影名称'].iloc[0]`,引擎执行后得到输出 `燃情岁月`
> [完整代码](https://github.com/datawhalechina/all-in-rag/blob/main/code/C3/06_recursive_retrieval.py)
> ⚠️ **重要安全警告**:实际上在 LlamaIndex 的官网有提到,`PandasQueryEngine` 是一个实验性功能,具有潜在的安全风险。它的工作原理是让 LLM 生成 Python 代码,然后使用 `eval()` 函数在本地执行。这意味着,在没有严格沙箱隔离的环境下,理论上可能执行任意代码。**因此,强烈不建议在生产环境中使用此工具**。
### 2.2 另一种实现方式
鉴于 `PandasQueryEngine` 的安全风险,还可以采用一种更安全的方式来实现类似的多表格查询,思路是**将路由和检索彻底分离**。
这种改进方法的具体步骤如下:
(1)**创建两个独立的向量索引**:
* **摘要索引(用于路由)**:为每个Excel工作表(例如,“1994年电影数据”)创建一个非常简短的摘要性`Document`,例如:“此文档包含1994年的电影信息”。然后,用所有这些摘要文档构建一个轻量级的向量索引。这个索引的唯一目的就是充当“路由器”。
* **内容索引(用于问答)**:将每个工作表的实际数据(例如,整个表格)转换为一个大的文本`Document`,并为其附加一个关键的元数据标签,如 `{"sheet_name": "年份_1994"}`。然后,用所有这些包含真实内容的文档构建一个向量索引。
2**执行两步查询**
* **第一步:路由**。当用户提问(例如,“1994年评分人数最少的电影是哪一部?”)时,首先在“摘要索引”中进行检索。由于问题中的“1994年”与“此文档包含1994年的电影信息”这个摘要高度相关,检索器会快速返回其对应的元数据,告诉系统目标是 `年份_1994` 这个工作表。
* **第二步:检索**。拿到 `年份_1994` 这个目标后,系统会在“内容索引”中进行检索,但这次会附加一个**元数据过滤器**(`MetadataFilter`),强制要求只在 `sheet_name == "年份_1994"` 的文档中进行搜索。这样,LLM就能在正确的、经过筛选的数据范围内找到问题的答案。
通过这种“先路由,后用元数据过滤检索”的方式,既实现了跨多个数据源的查询能力,又避免了执行代码的安全隐患。LlamaIndex 官方也提供了类似的结构化分层检索[^4]可以参考。
> [完整代码](https://github.com/datawhalechina/all-in-rag/blob/main/code/C3/07_recursive_retrieval_v2.py)
## 题外话:关于框架
> **有些人可能疑惑,为什么本教程不专注于一个框架(如 LlamaIndex 或 LangChain),而是混合使用,甚至造轮子?**
框架是加速开发的强大工具,是帮助我们快速跨越技术鸿沟的“桥梁”。但任何桥梁都有其设计边界和局限性。我们的目标不是成为一个熟练的“过桥者”,而是成为一个懂得如何设计和建造桥梁的“工程师”。
因此,本教程选择的路径是:
(1)**以原理为主**:我们优先关心的是“它是如何工作的?”而不是“我该调用哪个函数?”。新框架在诞生,老框架在迭代(当然不是笔者偷懒没更新 langchain🤫),但只要理解了底层的思想,我们将能更快地掌握任何现有或未来的框架。
(2)**拥抱灵活性**:真实世界的业务需求往往比框架预设的场景更复杂。当框架无法满足需求,或者像本节使用的 `PandasQueryEngine` 那样存在安全隐患时,懂得原理的话,就有能力去修改它,或者像本节的示例一样,用更底层的模块组合出更安全、合适的解决方案。
(3)**培养解决问题的能力**:只学习使用框架,好比是照着菜谱做菜,虽然能快速复刻出指定的菜肴,但一旦缺少某个食材或遇到意外情况,就可能束手无策。而理解原理,则像是学会了烹饪的精髓。这让你不仅能轻松地做出各种美食,还能创造新菜式。
如果你希望深入某个框架的细节,它的官方文档永远是最好、最权威的学习资料。而本教程的使命,是帮助你建立起关于 RAG 的坚实知识体系,让你无论面对何种工具,都能游刃有余。
## 参考文献
[^1]: [*Building Performant RAG Applications for Production*](https://docs.llamaindex.ai/en/stable/optimizing/production_rag/)
[^2]: [*LlamaIndex - Sentence Window Retrieval*](https://docs.llamaindex.ai/en/stable/examples/node_postprocessor/MetadataReplacementDemo/#metadata-replacement-node-sentence-window)
[^3]: [*Recursive Retriever + Query Engine Demo*](https://docs.llamaindex.ai/en/stable/examples/query_engine/pdf_tables/recursive_retriever)
[^4]: [*Structured Hierarchical Retrieval*](https://docs.llamaindex.ai/en/stable/examples/query_engine/multi_doc_auto_retrieval/multi_doc_auto_retrieval/)
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB