2.2 索引构建与检索方法
📌 本节目标
学完本节,你将理解为什么在RAG中需要“索引”,并掌握如何利用Facebook AI Similarity Search (FAISS) 库构建一个基础的向量索引。你将学会如何将文本转化为向量,并快速从海量向量中找到与用户查询最相似的“邻居”,为后续的检索增强生成打下坚实基础。
1. 核心概念讲解
想象一下,你有一间堆满了成千上万本书的图书馆,没有目录,也没有编号。当你想找一本关于“如何训练小狗”的书时,你只能一本一本地翻看,这效率极低。索引,就是给这些书建立一个按内容组织的“快速查找目录”。
在RAG的世界里,我们的“书”是大量的文本片段,而“内容”则被编码为向量(Vector)——也就是一堆浮点数。向量索引就是专门为这些浮点数设计的“超级目录”,它能让我们在极短的时间内,找到与某个查询向量“含义最接近”的那些向量。
1.1 什么是向量索引?
定义:向量索引是一种专门的数据结构,用于高效地组织和存储高维向量,并支持快速的相似性搜索(Similarity Search)。
比如:你有一张包含1000张人脸照片的数据库,每张照片都被转换成了一个128维的向量。向量索引就像一个人脸识别系统的“记忆库”。当你给它一张新的照片(也转成向量),它能迅速告诉你:数据库里哪张脸和你给的这张最像。
1.2 什么是相似性检索?
定义:给定一个查询向量(Query Vector),在索引中找出与它“距离最近”或“最相似”的K个向量(K-Nearest Neighbors, KNN)的过程。
比如:你脑子里想着一句“美味的意大利面做法”,这句话被编码成向量。相似性检索就是在你的食谱知识库(也全是向量)中,找出与这个想法最接近的3个食谱片段:“番茄肉酱面”、“奶油蘑菇意面”和“海鲜焗饭”。
1.3 什么是 FAISS?
定义:FAISS (Facebook AI Similarity Search) 是Meta(原Facebook)开发的一个开源库,专门用于高效地进行向量相似性搜索和聚类。它是目前业界最流行、性能最强大的工具之一。
比如:FAISS 为你提供了多种“建目录”的方法。有的方法追求100%准确但速度慢(暴力搜索),有的方法牺牲一点点精度来换取几百倍的速度(近似最近邻搜索)。你可以根据自己的需求(数据量、速度要求、精度要求)来选择最合适的“建目录”方法。
下面是一个使用FAISS构建最基础索引(暴力搜索)的代码示例:
import numpy as np
import faiss
# 1. 准备数据:假设我们有 5 个文本片段,每个被编码成 64 维的向量
# 在实际RAG中,这一步由嵌入模型(如 text-embedding-ada-002)完成
dimension = 64 # 向量维度
database_vectors = np.random.random((5, dimension)).astype('float32')
print("数据库向量形状:", database_vectors.shape) # 输出: (5, 64)
# 2. 构建索引
# 使用 L2(欧几里得距离)作为距离度量,进行暴力搜索 (IndexFlatL2)
# 暴力搜索意味着它会与数据库中的每一个向量进行比较,保证100%准确
index = faiss.IndexFlatL2(dimension)
# 检查索引是否为空
print("索引是否已训练(暴力搜索无需训练):", index.is_trained) # 输出: True
# 3. 将数据库向量添加到索引中
index.add(database_vectors)
print("索引中的向量总数:", index.ntotal) # 输出: 5
# 4. 准备一个查询向量(模拟用户的提问)
query_vector = np.random.random((1, dimension)).astype('float32')
# 5. 执行检索:找出与查询向量最相似的 3 个向量 (k=3)
k = 3
distances, indices = index.search(query_vector, k)
# 6. 打印结果
print(f"查询向量与最相似的 {k} 个向量的索引位置: {indices}")
print(f"对应的距离(值越小越相似): {distances}")
# 输出示例:
# 查询向量与最相似的 3 个向量的索引位置: [[2 4 0]]
# 对应的距离(值越小越相似): [[8.234 9.102 10.567]]
2. 深入理解
2.1 为什么需要索引?直接比较不行吗?
对于小规模数据(比如几千条),直接使用暴力搜索(如上面的IndexFlatL2)是完全可以的。但是,在真实的RAG应用中,知识库可能有数百万甚至数十亿条文本片段。想象一下,用户每次提问,系统都要把用户的问题和这上亿个向量逐一比较,计算距离,这将是灾难性的延迟(可能几十秒甚至几分钟)。
索引(尤其是近似最近邻索引,ANN) 的核心价值在于用精度换速度。它通过一种巧妙的“分而治之”策略,比如将向量空间划分成多个区域(聚类),搜索时只在最有可能包含答案的几个区域里查找,而不是全量搜索。这使得搜索时间从 O(N) 降低到 O(log N) 甚至常数级别。
| 特性 | 暴力搜索 (Flat) | 近似最近邻搜索 (IVF, HNSW等) | | :--- | :--- | :--- | | 速度 | 慢 (O(N)) | 极快 (O(log N) 或 O(1)) | | 精度 | 100% (精确) | 略低 (99% 或 95% 召回率) | | 适用场景 | 小数据集 (< 1万条) | 大数据集 (> 10万条) | | 内存占用 | 相对较低 | 相对较高 (需要额外结构) |
2.2 常见的 FAISS 索引类型
FAISS 提供了丰富的索引类型,初学者可以从以下两种开始:
IndexFlatL2(暴力搜索):最基础、最精确的索引。它不压缩向量,也不做任何近似。当你对精度要求极高且数据量不大时,这是最佳选择。IndexIVFFlat(倒排文件索引):一种高效的近似索引。它首先使用K-Means对数据库向量进行聚类,将向量空间划分为多个“桶”(Voronoi cells)。搜索时,它只在与查询向量最近的几个桶中进行搜索,从而大幅提升速度。
初学者常见误区:
- 忽视向量归一化:在使用余弦相似度(Cosine Similarity)时,需要先将所有向量(包括查询向量)进行L2归一化,然后使用内积(IP)距离。FAISS的
IndexFlatIP和IndexFlatL2在处理归一化后的向量时是等价的。忘记归一化会导致结果错误。 - 认为索引越大越好:索引的大小和类型需要根据数据量、内存限制和延迟要求进行权衡。一个过于复杂的索引(如HNSW)在小数据集上可能不如简单的Flat索引。
- 混淆了“索引构建”和“模型训练”:FAISS索引的
train()方法(如IVF索引需要)是为了学习聚类中心,这和深度学习模型的训练是两码事。它通常很快,只需要数据,不需要标签。
3. 实战演示:从零构建一个简单的向量检索器
在这个实战中,我们将构建一个迷你版的RAG检索器。我们会准备几段关于“水果”的文本,然后用一个简单的嵌入模型将它们转为向量,最后用FAISS进行检索。
步骤 1:安装必要的库
pip install sentence-transformers faiss-cpu
步骤 2:准备数据
# 我们的知识库:几段关于水果的简单描述
corpus = [
"苹果是一种常见的水果,有红、绿、黄等多种颜色,口感脆甜。",
"香蕉是长条形的黄色水果,富含钾元素,可以快速补充能量。",
"橘子是圆形的橙色水果,果肉多汁,富含维生素C。",
"草莓是心形的红色水果,味道酸甜,常用于制作甜点。",
"西瓜是一种大型水果,外皮绿色,内部果肉红色多汁,夏天食用很解暑。",
"葡萄是一种可以酿酒的水果,有绿色和紫色两种,通常成串生长。",
"榴莲被称为水果之王,气味独特,果肉金黄,口感绵密。",
]
步骤 3:加载嵌入模型并生成向量
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np
# 加载一个轻量级的中文句子嵌入模型
# 这个模型可以将句子转换为 384 维的向量
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
print("模型加载完成!")
# 将我们的知识库文本转换为向量
# encode 方法会返回一个 numpy 数组
corpus_embeddings = model.encode(corpus, convert_to_numpy=True)
print(f"知识库向量形状: {corpus_embeddings.shape}") # 输出: (7, 384)
print(f"向量数据类型: {corpus_embeddings.dtype}") # 输出: float32
步骤 4:构建 FAISS 索引并添加向量
# 获取向量的维度
dimension = corpus_embeddings.shape[1]
# 选择一个索引类型。这里我们使用 IndexFlatIP(内积搜索)
# 注意:对于余弦相似度,我们需要先将向量归一化,然后用内积搜索
# sentence-transformers 的模型默认返回的向量不是归一化的,我们手动进行归一化
faiss.normalize_L2(corpus_embeddings)
# 创建索引
index = faiss.IndexFlatIP(dimension)
print(f"索引是否已训练: {index.is_trained}")
# 将归一化后的向量添加到索引中
index.add(corpus_embeddings)
print(f"索引中的向量总数: {index.ntotal}")
步骤 5:执行检索
# 用户的查询
query = "我想找一种夏天吃的水果,很解渴。"
# 将查询也转为向量,并进行归一化
query_embedding = model.encode([query], convert_to_numpy=True) # 注意:需要传入列表
faiss.normalize_L2(query_embedding)
# 执行检索,找出最相似的 3 个结果
k = 3
distances, indices = index.search(query_embedding, k)
# 打印结果
print(f"\n查询: {query}")
print("检索结果:")
for i, idx in enumerate(indices[0]):
# 内积距离越接近1,表示越相似
print(f"{i+1}. 文本: {corpus[idx]} (相似度: {distances[0][i]:.4f})")
期望输出:
模型加载完成!
知识库向量形状: (7, 384)
向量数据类型: float32
索引是否已训练: True
索引中的向量总数: 7
查询: 我想找一种夏天吃的水果,很解渴。
检索结果:
1. 文本: 西瓜是一种大型水果,外皮绿色,内部果肉红色多汁,夏天食用很解暑。 (相似度: 0.7245)
2. 文本: 苹果是一种常见的水果,有红、绿、黄等多种颜色,口感脆甜。 (相似度: 0.4321)
3. 文本: 草莓是心形的红色水果,味道酸甜,常用于制作甜点。 (相似度: 0.3898)
恭喜! 你已经成功构建了你的第一个向量检索器。它成功地从7个文本片段中,找出了与“夏天解渴”最相关的“西瓜”。
4. 关键要点
- 索引是RAG的加速器:它通过数据结构优化,将向量搜索从全量比较(O(N))加速到近似搜索(O(log N)),是实现海量知识库实时检索的关键。
- 向量归一化是余弦相似度的前提:使用
IndexFlatIP实现余弦相似度时,必须对数据库向量和查询向量都进行L2归一化,否则结果不准确。 - FAISS 提供了“精度-速度”的调节器:从精确的
IndexFlat到近似的IndexIVF,你可以根据业务需求(延迟、吞吐量、召回率)选择合适的索引类型。 - 嵌入模型是桥梁:FAISS只负责向量检索,而将文本转化为有意义的向量,依赖于一个优秀的嵌入模型(如本节使用的
sentence-transformers)。
✏️ 练习任务
练习方向:实现一个简单的向量检索器
-
基础任务:
- 将上述实战演示中的知识库
corpus替换为你感兴趣的5-10个句子(例如,关于你最喜欢的电影、书籍或城市介绍)。 - 使用
IndexFlatL2(欧几里得距离)代替IndexFlatIP来构建索引。注意:使用L2距离时,不需要进行向量归一化。 - 编写一个函数
search(query, k),输入用户查询和返回数量k,输出最相似的k个文本及其距离。 - 期望输出:对于查询“一个关于科幻电影的介绍”,能正确返回你知识库中关于科幻电影的句子。
- 将上述实战演示中的知识库
-
进阶任务(选做):
- 尝试使用
IndexIVFFlat索引。你需要先设置聚类中心的数量(例如nlist = 2),然后调用index.train()方法。 - 比较
IndexFlatL2和IndexIVFFlat在相同查询下的检索结果和速度。你可以使用time模块来粗略测量搜索时间。 - 思考:
IndexIVFFlat返回的结果和IndexFlatL2完全一样吗?为什么?速度提升了多少?
- 尝试使用
💡 下一节我们将学习如何将检索到的文本片段与用户问题一起,优雅地“喂”给大语言模型(LLM),完成最终的答案生成。我们还会探讨如何优化这个“提示词”(Prompt)以获得更好的回答质量。
本节由 StudyAI8 AI 课程团队生成并审核。