索引构建(二):向量数据库与索引优化

在上一篇文章《索引构建(一):向量嵌入与多模态嵌入》中,我们学会了如何把文本和图像"编码"成一串数字向量。这相当于给图书馆里每一本书都提炼出了一张"内容摘要卡"。但现实是,一个企业级知识库动辄包含数百万甚至数十亿个这样的向量卡片。

现在面临的核心问题是:如何在短短几十毫秒内,从这亿万个"摘要卡"中精准找出与你当前问题最相关的那几张?

本篇就是解决这个问题的"检索系统搭建指南"。我们将深入向量数据库的底层原理,手把手带你使用轻量级的 FAISS 和生产级的 Milvus,最后揭秘如何通过"句子窗口检索"和"结构化路由"等高级索引策略,让RAG系统既"看得准"又"看得全"。

第一部分:向量数据库基础与核心原理

一、为什么非用向量数据库不可?

传统的数据库(如 MySQL)擅长处理结构化数据的精确匹配,例如查询 "WHERE age = 25"。但当你提出"找出和这张图片语义最像的10张图"或"找到和这段话意思最接近的文档"时,MySQL 就完全束手无策了------因为它无法理解"像不像",只能进行全表暴力遍历。在百万级数据下,这种计算的复杂度极高,耗时无法接受。

向量数据库正是为此而生。 它是一种专门为高维向量设计的数据库系统,通过精巧的索引算法,能在海量数据中实现毫秒级的近似最近邻(ANN)搜索。

特性维度 向量数据库 传统关系型数据库(如MySQL)
核心数据类型 高维向量(如768维浮点数数组) 结构化数据(整数、字符串、日期)
核心查询方式 相似性搜索(找最像的K个) 精确匹配(WHERE条件)与聚合查询
核心索引机制 HNSW、IVF、PQ、DiskANN B-Tree、Hash索引
典型应用场景 RAG、推荐系统、人脸识别、语音搜索 ERP、CRM、金融交易、数据报表
数据规模 可轻松应对千亿级向量 通常单表在千万到亿级行数据
性能特点 高维相似性检索性能极高 高维数据检索性能呈指数级下降
事务与一致性 通常为最终一致性 支持强一致性(ACID事务)

💡 黄金搭档 :在实际开发中,我们并非只用向量数据库。典型做法是:用MySQL存储业务元数据(如作者、分类、发布时间),用向量数据库存储向量并执行语义搜索

查询时先用MySQL做标量过滤,再将过滤后的ID列表传给向量数据库,仅在子集内做相似度搜索。这种"先过滤,再搜索"的策略兼顾了精度与效率。

二、向量数据库的核心工作原理

向量数据库之所以能如此快速地进行相似性搜索,靠的不是算力暴力破解,而是四种精妙的近似最近邻(ANN) 算法:

技术类别 代表算法 核心思想 特点
基于树的方法 Annoy(随机投影树) 通过递归二分将向量空间切分,形成树形结构 搜索复杂度O(log N),内存占用适中,构建速度快
基于哈希的方法 LSH(局部敏感哈希) 用特定的哈希函数将相似向量以高概率映射到同一个"桶"中 适合极大规模数据,但需要牺牲一定召回率
基于图的方法 HNSW(分层可导航小世界图) 构建多层图结构,上层稀疏(大跨度跳跃),下层密集(精细搜索) 速度极快、召回率极高,但内存消耗巨大
基于量化的方法 IVF + PQ(Faiss核心) IVF先聚类分群(缩小范围),PQ再压缩向量精度(减小内存) 内存占用极小,适合超大规模数据,精度略有损失

三、主流向量数据库对比与选型指南

3.1 主要产品一览
产品 类型 核心特点 性能亮点 适用场景
Pinecone 全托管云服务 Serverless架构、免运维、99.95% SLA 查询延迟<100ms,自动扩缩容 企业级生产环境、不想自建运维团队
Milvus 开源(LF AI & Data顶级项目) 分布式、云原生、支持GPU加速 亿级向量检索,高并发吞吐 大规模部署、高性能要求的开源首选
Qdrant 开源 Rust语言开发,二进制量化,过滤能力极强 单节点RPS>4000,极低延迟 性能敏感型应用,高并发小规模部署
Weaviate 开源 GraphQL原生API,内置20+AI模块 与AI生态深度集成,开箱即用 快速AI应用开发,多模态数据处理
Chroma 开源 本地优先,零依赖,Python原生 轻量级,内存占用极低 原型开发、教育培训、个人项目
FAISS 算法库(非数据库) Facebook出品,非数据库服务 只存文件,无网络开销,纯内存计算 学术研究、离线批量处理、概念验证
3.2 新手选型路线图
阶段 推荐方案 理由
入门学习/跑通Demo ChromaDBFAISS + LangChain 与LangChain/LlamaIndex紧密集成,几行代码就能运行,无需安装任何后台服务
中小规模生产(百万级) QdrantWeaviate 部署简单、功能完整、社区活跃,单机即可支撑
大规模生产(亿级以上) MilvusPinecone 分布式架构,水平扩展能力强,有完善的高可用和容灾方案

第二部分:轻量级本地存储实战 ------ FAISS

FAISS(Facebook AI Similarity Search)是Facebook AI Research开发的高性能相似性搜索库。它不是一个数据库(没有服务进程),而是一个算法库 。它把索引直接保存为本地文件(一个.faiss索引文件和一个.pkl映射文件),使用时在内存中加载。这种方式轻量且高效,非常适合快速原型设计和中小型应用。

2.1 环境准备

bash 复制代码
# CPU版本(大多数机器适用)
pip install faiss-cpu langchain-community sentence-transformers

# 如有NVIDIA GPU,可安装GPU版本以获得10倍以上加速
# pip install faiss-gpu

2.2 完整代码示例(创建 → 保存 → 加载 → 查询)

下面的代码演示了使用LangChain + FAISS完成一个完整的RAG索引闭环。

其中有一个极为重要的安全注意事项 :加载索引时,我们设置了 allow_dangerous_deserialization=True。为什么?因为FAISS保存索引时会生成两个文件:.faiss文件(存储向量索引)和.pkl文件(存储文档原文及ID映射)。.pkl是Python的序列化数据,pickle.load()在反序列化时可能执行恶意代码。LangChain出于安全考虑,强制开发者显式确认此参数。

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]

# 使用中文嵌入模型(BGE小模型,速度快)
embeddings = HuggingFaceEmbeddings(
    model_name="BAAI/bge-small-zh-v1.5",
    model_kwargs={'device': 'cpu'},
    encode_kwargs={'normalize_embeddings': True}
)

# ==========================================
# 2. 创建向量存储并保存到本地
# ==========================================
vectorstore = FAISS.from_documents(docs, embeddings)

# 保存索引到指定目录(生成两个文件)
local_faiss_path = "./faiss_index_store"
vectorstore.save_local(local_faiss_path)

print(f"✅ FAISS索引已保存至: {local_faiss_path}")
# 此时目录下会生成两个文件:
#   - index.faiss(向量索引数据)
#   - index.pkl(文档原文与ID映射)

# ==========================================
# 3. 从本地加载索引并执行查询
# ==========================================
# 加载时需指定完全相同的嵌入模型
# allow_dangerous_deserialization=True 告知系统:我们信任这个.pkl文件
loaded_vectorstore = FAISS.load_local(
    local_faiss_path,
    embeddings,
    allow_dangerous_deserialization=True  # 注意:生产环境中确保文件来源可信!
)

# 执行相似性搜索
query = "FAISS是做什么的?"
results = loaded_vectorstore.similarity_search(query, k=1)  # k=1 表示返回最相似的1个结果

print(f"\n📝 查询: '{query}'")
print("🎯 相似度最高的文档:")
for doc in results:
    print(f"   - {doc.page_content}")

运行结果:

text

bash 复制代码
✅ FAISS索引已保存至: ./faiss_index_store

📝 查询: 'FAISS是做什么的?'
🎯 相似度最高的文档:
   - FAISS是一个用于高效相似性搜索和密集向量聚类的库。

2.3 源码深度解析(知其所以然)

当执行 FAISS.from_documents() 时,LangChain内部经历了4层调用:

方法层级 方法名 职责
封装层(用户入口) from_documents Document对象列表中提取纯文本内容(.page_content)和元数据(.metadata),传递给from_texts
向量化入口 from_texts 调用 embeddings.embed_documents(texts),将文本列表批量转换为向量矩阵
框架构建(内部) __from 初始化一个空的FAISS索引结构(默认使用L2欧氏距离度量),创建用于存原文的docstore字典和index_to_docstore_id映射表
数据填充(核心) __add ①将向量列表转为numpy数组,调用self.index.add()灌入FAISS;②将原文和元数据存入docstore;③建立FAISS内部整数ID到文档唯一ID的映射关系

这个分层设计使得FAISS的索引构建清晰解耦,每一层都可以被独立替换或扩展。

第三部分:生产级分布式数据库 ------ Milvus

当你的数据量突破百万级,或者需要支持高并发实时查询时,FAISS这种单机内存方案就捉襟见肘了。这时,Milvus 作为开源向量数据库的领军者,值得你深入了解。

Milvus 诞生于Zilliz公司,现为LF AI & Data基金会的顶级项目。它采用云原生架构,具备高可用、高性能、易扩展的特性,能够处理十亿甚至百亿级别的向量数据。

3.1 极速部署(Docker Compose方案)

Milvus Standalone(单机版)依赖三个核心组件:milvus-standalone(主服务)、etcd(元数据存储)和MinIO(对象存储)。使用官方Docker编排脚本可一键拉起:

前置条件:确保系统中已安装Docker和Docker Compose。

bash

bash 复制代码
# 第一步:下载配置文件(使用v2.5.14版本)
# macOS / Linux
wget https://github.com/milvus-io/milvus/releases/download/v2.5.14/milvus-standalone-docker-compose.yml -O docker-compose.yml

# 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"

# 第二步:后台启动
docker compose up -d

# 第三步:验证运行状态(三个容器都应处于Running状态)
docker ps

常用管理命令:

bash

bash 复制代码
# 停止服务(保留数据卷)
docker compose down

# 彻底清理(删除所有数据,谨慎操作)
docker compose down -v

Milvus默认通过 19530 端口提供服务,这是后续代码连接时需要用到的地址。

3.2 核心概念详解(用"图书馆"类比)

为了方便理解,我们先建立一套图书馆比喻体系:

Milvus概念 图书馆类比 详细说明
Collection(集合) 整个图书馆 所有数据的顶层容器,类似于关系型数据库中的一张表(Table)
Schema(模式) 图书编目规则 定义数据结构的蓝图,必须包含主键字段向量字段,还可包含标量字段(如书名、作者)
Entity(实体) 一本具体的书 一条数据记录,包含一个向量和若干标量字段
Partition(分区) 图书馆的不同区域(小说区、科技区) Collection内部的物理隔离逻辑分区,查询时指定分区可大幅提速
Alias(别名) 一个动态推荐书单(如"本月热榜") 指向Collection的指针,用于应用层无感知的数据切换
3.2.1 Schema(模式定义)

在创建Collection之前,必须先定义Schema。一个典型的RAG场景Schema通常包含三类字段:

字段类型 说明 示例
主键字段 唯一标识每条数据,必选 id (INT64)
向量字段 存储核心向量数据,可以有一个或多个 embedding (FLOAT_VECTOR, 维度768)
标量字段 存储元数据,用于过滤查询 title (VARCHAR)、publish_year (INT64)、category (VARCHAR)
3.2.2 Partition(分区)------ 性能优化的第一把钥匙

Partition是Collection内部根据某个规则(如日期、地区)的逻辑划分。每个Collection在创建时自带一个名为 _default 的默认分区。

使用分区的核心价值:

  1. 提升查询性能 :查询时通过指定 partition_names 参数,只在特定分区内检索,大幅减少扫描数据量

  2. 简化数据管理:支持对特定分区进行独立的加载/卸载、删除等操作

一个Collection最多支持1024个分区。合理利用分区是Milvus性能优化最直接的手段之一。

3.2.3 Alias(别名)------ 实现零停机升级的秘密武器

Alias为Collection提供了一个应用层可感知的"昵称"。在应用程序中,我们始终使用别名来操作数据库,而不是直接使用Collection名称。

别名的典型应用场景------无缝数据迁移:

text

bash 复制代码
场景:你需要对线上Collection进行大规模数据更新或重建索引

危险做法(不推荐):
  直接在 collection_v1 上删除数据并重建 → 服务中断 ❌

安全做法(推荐):
  步骤1:新建一个 Collection(如 collection_v2),导入新数据并构建好索引
  步骤2:将别名 my_app_collection 从 collection_v1 原子性地切换到 collection_v2
  步骤3:验证无误后,删除或下线 collection_v1
  
  整个过程对上层应用完全透明,无需修改任何代码或重启服务 ✅

3.3 索引(Index)------ 向量检索的"加速引擎"

索引是向量数据库的灵魂。Milvus对标量字段和向量字段分别提供了不同的索引类型。

3.3.1 主要向量索引类型详解
索引类型 底层原理 优点 缺点 适用场景
FLAT 暴力搜索(Brute-force),逐一计算查询向量与所有向量的精确距离 100%召回率,结果最精确 速度极慢,不适合海量数据 数据量小于百万级,对精度要求极高(如科研验证)
IVF_FLAT 先通过K-means聚类将所有向量分成nlist个桶;查询时只搜索最相似的nprobe个桶内的向量,再做精确计算 速度与精度的最佳平衡,实现简单 召回率非100%(搜索的桶可能不包含全局最近邻) 通用推荐场景,大多数RAG应用的首选
IVF_SQ8 在IVF基础上,对桶内向量做8位标量量化(SQ8),大幅压缩内存 内存占用比IVF_FLAT减少约70% 精度略有损失 内存受限的部署环境
IVF_PQ 在IVF基础上,使用乘积量化(PQ)将高维向量分段压缩 内存占用极小(可压缩至1/10),支持极大规模 精度损失相对较大,查询速度略慢 超大规模(亿级以上),内存极其宝贵
HNSW 构建多层图结构:上层图稀疏(节点少),用于快速定位大致区域;下层图密集,用于精细搜索 查询速度极快,召回率极高 构建耗时长,内存占用巨大(是IVF的5-10倍) 对查询延迟有极致要求(如实时在线推荐)
DiskANN 微软研发的基于SSD磁盘的图索引,将图结构存储在磁盘上 支持远超内存容量的数据规模(十亿级以上),成本低廉 比纯内存索引延迟稍高 数据量巨大但预算有限,无法全内存加载的场景
3.3.2 索引选择决策指南
你的业务场景 推荐索引 决策理由
数据量 < 100万,且能全部载入内存,追求极速 HNSW 虽然吃内存,但在小数据量下效果无与伦比
数据量 100万 ~ 1亿,内存适中 IVF_FLAT 平衡之王,性能足够且开发调参最简单
数据量 1亿 ~ 10亿,内存有限 IVF_PQ 极大压缩内存,让普通服务器也能跑亿级数据
数据量 > 10亿,不想买大内存服务器 DiskANN 直接在SSD上跑图检索,省钱又高效
数据量 < 10万,老板要求100%召回率 FLAT 暴力精确计算,没有近似损失

3.4 Milvus 高级检索能力

Milvus不仅支持基础的Top-K向量检索,还提供了丰富的增强检索能力:

3.4.1 标量过滤检索(Filtered Search)

在实际业务中,单纯的向量检索往往不够。更常见的需求是"在满足特定条件的数据中,找最相似的向量"。这就是标量过滤检索。

text

bash 复制代码
典型应用示例:
  电商场景:"检索与这件红色连衣裙最相似的商品,但只展示价格低于500元且当前有库存的"
  知识库场景:"查找与'人工智能'相关的文档,但只限定在'技术'分类下、且发布于2023年之后"

实现方式 :在检索请求中传入 filter 表达式(如 price < 500 and status == "in_stock"),Milvus会先执行标量过滤,再在过滤后的子集上执行向量检索。

3.4.2 范围检索(Range Search)

Top-K检索返回的是"最相似的K个结果"。而范围检索返回的是"所有相似度超过某个阈值"的结果。

text

bash 复制代码
典型应用示例:
  人脸识别身份验证:"查找所有与目标人脸相似度超过0.92的人脸"(用于1:N比对)
  异常检测:"找出所有与正常样本向量欧氏距离大于20的异常数据点"
3.4.3 多向量混合检索(Hybrid Search)

这是Milvus最强大的高级功能之一。它允许在一个检索请求中,同时对多个向量字段进行搜索,并将结果智能融合。

工作原理

  1. 并行检索:对多个向量字段(如:一个用于语义的密集向量Dense、一个用于关键词匹配的稀疏向量Sparse)分别执行ANN检索

  2. 结果融合(Rerank) :Milvus使用重排策略(Reranker)将各检索结果合并

    • RRFRanker(倒数排名融合):平衡各方结果,公平对待每个检索器

    • WeightedRanker(加权融合):可为特定字段的结果赋予更高权重

text

bash 复制代码
典型应用示例:
  增强型RAG:同时使用密集向量(捕捉整体语义)和稀疏向量(精确匹配专有名词),实现比单一检索方式更高的召回率
  多模态商品检索:用户输入"安静舒适的白色耳机",系统同时检索商品的文本描述向量和图片内容向量
3.4.4 分组检索(Grouping Search)

分组检索解决的是一个常见痛点:检索结果多样性不足

想象一下,你搜索"机器学习",返回的前10个结果全部来自同一本教科书的10个不同章节。这显然不是理想的结果。

分组检索 允许你指定一个分组字段(如 source_book_id),Milvus会确保返回的结果中每个分组最多只出现一次(或指定次数),且返回的是该组内与查询最相似的那一条。

text

bash 复制代码
典型应用示例:
  文档检索:检索"数据库索引",确保返回的结果来自不同的书籍或不同作者
  视频推荐:检索"可爱的猫咪",确保推荐的视频来自不同的UP主,避免同质化

第四部分:打破瓶颈的高级索引优化策略

很多开发者搭建的RAG系统效果不理想,往往不是因为模型不够强,而是索引建得太粗糙。下面介绍两大"杀手锏"级别的优化策略,能让你的系统检索精度和回答质量同时大幅提升。

4.1 策略一:句子窗口检索(Sentence Window Retrieval)

4.1.1 核心痛点

在文本分块时,我们面临一个永恒的权衡:

分块策略 检索精度 生成质量 问题描述
小块(如单个句子) ⭐⭐⭐⭐⭐ 极高 ⭐⭐ 较低 检索能精准命中,但上下文缺失,LLM回答缺乏依据
大块(如512字段落) ⭐⭐ 较低 ⭐⭐⭐⭐⭐ 极高 上下文丰富,但检索容易召回大量噪音,关键信息被淹没

能不能既要检索的精度,又要生成的上下文广度

4.1.2 解决方案:分而治之

LlamaIndex提出的句子窗口检索完美解决了这个难题,核心思想是:

为检索精度而索引小块,为生成质量而扩展上下文。

工作流程分为四个阶段:

text

bash 复制代码
阶段1:索引构建(离线)
  原始文档 → 精确切分为单个句子
  每个句子作为一个独立的Node存入向量数据库
  关键动作:每个Node的metadata中存储其上下文窗口(原文中前N句 + 后N句的完整文本)
  注意:这个"窗口"文本只存元数据,不会被向量化,不影响检索精度

阶段2:检索(在线)
  用户提问 → 在所有单一句子Node上进行相似度搜索
  由于句子是最小的完整语义单元,检索精度极高,能精准命中核心信息所在句子

阶段3:后处理
  MetadataReplacementPostProcessor介入
  读取命中Node的元数据中的"窗口"文本,用这个完整的上下文窗口替换掉Node的原始内容

阶段4:生成
  包含完整上下文的Node → 传递给LLM → 生成高质量、有充足依据的答案
4.1.3 代码实现(LlamaIndex)
python 复制代码
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
from llama_index.core.node_parser import SentenceWindowNodeParser, SentenceSplitter
from llama_index.core.postprocessor import MetadataReplacementPostProcessor

# 假设 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,  # 前后各3个句子,即总共7个句子的窗口
    window_metadata_key="window",  # 窗口文本存储在元数据中的键名
    original_text_metadata_key="original_text",  # 原始句子存储在元数据中的键名
)
sentence_nodes = node_parser.get_nodes_from_documents(documents)
sentence_index = VectorStoreIndex(sentence_nodes)

# 2.2 常规分块索引(用于对比基准)
base_parser = SentenceSplitter(chunk_size=512)
base_nodes = base_parser.get_nodes_from_documents(documents)
base_index = VectorStoreIndex(base_nodes)

# ==========================================
# 3. 构建查询引擎
# ==========================================

# 3.1 句子窗口查询引擎(带后处理器)
sentence_query_engine = sentence_index.as_query_engine(
    similarity_top_k=2,
    node_postprocessors=[
        MetadataReplacementPostProcessor(target_metadata_key="window")
        # 核心:检索到的句子Node,其内容会被替换为metadata中的"window"完整上下文
    ],
)

# 3.2 常规查询引擎(不带后处理)
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")
4.1.4 SentenceWindowNodeParser 源码剖析

深入底层代码,我们可以看清它是如何实现的:

步骤 动作 关键细节
1. 句子切分 调用 self.sentence_splitter(doc.text) 将全文切分为独立的句子列表 可配置使用不同的分词器,默认按句号、问号、感叹号切分
2. 创建基础节点 为每个句子创建一个独立的 TextNodetext 属性即为该句子内容 此时每个节点都是孤立的,没有任何上下文
3. 构建窗口元数据 遍历所有节点:对于位置 i 的节点,通过切片获取 [i-window_size, i+window_size+1] 范围内的所有相邻节点 边界处理:自动处理文档开头和结尾,不会越界报错
4. 填充元数据 将窗口内所有句子的文本拼接为一个长字符串,存入当前节点的 metadata["window"] 同时存入 metadata["original_text"] 作为备查
5. 关键排除设置 执行 node.excluded_embed_metadata_keys = ["window", "original_text"] 这是保证检索精度的核心:告诉嵌入模型只对单句向量化,忽略元数据中的大段窗口文本
4.1.5 效果对比分析

当提问 "AMOC(大西洋经向翻转环流)的主要担忧是什么?" 时:

对比维度 句子窗口检索 常规分块检索
核心事实 ✅ 正确指出21世纪将衰退 ✅ 正确指出21世纪将衰退
信息丰富度 ✅ 补充了"定量预测置信度低"、"观测记录不足"、"20世纪重建置信度低"等多个维度的细节 ⚠️ 相对概括,以"需要进一步研究"等笼统表述收尾
答案风格 ✅ 更像一份详尽、有层次的综述 ⚠️ 宽泛、缺乏深度
上下文连贯性 ✅ 非常连贯,逻辑链条完整 ⚠️ 可能存在跳跃,细节不足

这个对比清晰地展示了句子窗口检索的价值:检索的精准(单句命中)+ 生成的丰富(窗口扩展)= 更高质量的答案

4.2 策略二:结构化路由与递归检索

4.2.1 核心痛点

当知识库规模极大且数据天然按类别/来源/时间分布在多个不同的数据源中时(例如:100个Excel文件,每个文件对应一个年份的数据;或50个PDF,每个对应一个产品型号),传统的全局向量检索会导致:

  • 检索效率低下:查询只涉及1994年的数据,但系统却要在所有年份的数据里漫无目的地搜索

  • 噪声干扰严重:其他99个年份的无关数据会产生大量"假阳性"结果,影响排序精度

4.2.2 解决方案:先路由,后检索(递归检索)

递归检索的思路非常清晰:建两层索引,顶层只做"路由决策",底层才执行"精确检索"。

text

bash 复制代码
两层架构:
  顶层(路由层):只存储每个数据源的摘要描述向量(如:"这个表格包含1994年的电影数据")
  底层(执行层):每个数据源对应一个独立的查询引擎(如:一个专门查询该表格的Pandas引擎)

查询流程:
  用户提问:"1994年评分人数最少的电影是哪一部?"
    ↓
  步骤1 - 顶层路由:在摘要索引中检索 → 匹配到"年份_1994"摘要节点
    ↓
  步骤2 - 路由跳转:系统根据匹配结果,自动跳转到"年份_1994"对应的底层查询引擎
    ↓
  步骤3 - 底层执行:在仅包含1994年数据的子集中执行精确查询
    ↓
  步骤4 - 返回结果:"燃情岁月"
4.2.3 代码实现(LlamaIndex递归检索器)

下面以一份包含多个Sheet的Excel文件(movie.xlsx,每个Sheet存储一个年份的电影数据)为例:

python 复制代码
import pandas as pd
from llama_index.core import VectorStoreIndex
from llama_index.core.objects import IndexNode
from llama_index.core.retrievers import RecursiveRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.experimental.query_engine import PandasQueryEngine

# ==========================================
# 1. 为每个Sheet创建独立的查询引擎 + 摘要节点
# ==========================================
excel_file = '../../data/C3/excel/movie.xlsx'
xls = pd.ExcelFile(excel_file)

# 存储每个Sheet名称 → 对应查询引擎的映射
sheet_query_engines = {}
# 存储所有摘要节点(用于构建顶层索引)
summary_nodes = []

for sheet_name in xls.sheet_names:
    df = pd.read_excel(xls, sheet_name=sheet_name)
    
    # 1.1 为当前Sheet创建一个Pandas查询引擎(将自然语言转为Pandas代码)
    query_engine = PandasQueryEngine(df=df, llm=Settings.llm, verbose=True)
    sheet_query_engines[sheet_name] = query_engine
    
    # 1.2 创建该Sheet的摘要描述节点(作为顶层路由的"指针")
    year = sheet_name.replace('年份_', '')
    summary_text = f"这个表格包含了年份为 {year} 的电影信息,可以用来回答关于这一年电影的具体问题。"
    node = IndexNode(text=summary_text, index_id=sheet_name)  # index_id指向对应的Sheet名称
    summary_nodes.append(node)

# ==========================================
# 2. 构建顶层路由索引(仅包含摘要节点)
# ==========================================
top_vector_index = VectorStoreIndex(summary_nodes)
top_retriever = top_vector_index.as_retriever(similarity_top_k=1)  # 只路由到最匹配的一个数据源

# ==========================================
# 3. 创建递归检索器
# ==========================================
recursive_retriever = RecursiveRetriever(
    "vector",  # 顶层使用向量检索
    retriever_dict={"vector": top_retriever},
    query_engine_dict=sheet_query_engines,  # 路由映射:摘要节点ID → 对应的查询引擎
    verbose=True,
)

# ==========================================
# 4. 执行查询
# ==========================================
query_engine = RetrieverQueryEngine.from_args(recursive_retriever)

query = "1994年评分人数最少的电影是哪一部?"
print(f"📝 查询: {query}")
response = query_engine.query(query)
print(f"🎯 回答: {response}")
4.2.4 运行日志解读

text

bash 复制代码
📝 查询: 1994年评分人数最少的电影是哪一部?

> Retrieving with query id None: 1994年评分人数最少的电影是哪一部?
> Retrieved node with id, entering: 年份_1994          ← 步骤1:顶层路由匹配到"年份_1994"
> Retrieving with query id 年份_1994: 1994年评分人数最少的电影是哪一部?  ← 步骤2:跳转到子引擎
> Pandas Instructions:                                  ← 步骤3:子引擎生成并执行代码
df[df['年份'] == 1994].nsmallest(1, '评分人数')['电影名称'].iloc[0]
> Pandas Output: 燃情岁月                               ← 步骤4:得到精确结果

🎯 回答: 燃情岁月
4.2.5 ⚠️ 致命安全红线

务必注意 :上面代码中的 PandasQueryEngine 是一个实验性功能 ,其底层机制是:让LLM根据自然语言问题生成Python代码 ,然后通过 eval() 函数在本地执行。

这意味着:LLM拥有了在本地执行任意Python代码的能力。如果LLM被恶意提示词劫持,理论上可以执行系统命令、删除文件、窃取数据。这是严重的远程代码执行(RCE)漏洞。

🚨 强烈警告生产环境严禁使用 PandasQueryEngine 及其类似工具 (如LangChain的 create_pandas_dataframe_agent)。

4.2.6 安全的替代方案

将"路由"与"检索"彻底分离,完全避免代码执行风险:

安全架构(两步走):

步骤 操作 实现方式
步骤1:路由(确定目标数据源) 使用摘要索引(纯向量检索)识别查询应定位到哪个数据源 top_retriever 返回 sheet_name = "年份_1994"
步骤2:检索(加过滤器查询) 在目标数据源上执行检索,但附加元数据过滤器 在内容索引上搜索时,强制添加过滤器:sheet_name == "年份_1994"
python 复制代码
# 安全实现示例(伪代码)
# 1. 路由:得到目标 sheet_name
target_sheet = top_retriever.retrieve(query)[0].metadata["sheet_name"]

# 2. 带过滤器的向量检索(安全,无代码执行)
filtered_results = content_vectorstore.similarity_search(
    query,
    filter={"sheet_name": target_sheet},  # 只搜索目标Sheet的数据
    k=5
)

这种方式完全避免了代码执行的风险,同时实现了同样的"先定位、后搜索"的精准路由效果。

第五部分:本章总结与回顾

至此,关于RAG索引构建的全部核心知识已经讲解完毕。让我们做一次系统的回顾:

核心知识点 关键理解
向量数据库的作用 在海量高维向量中实现毫秒级的相似性搜索,是RAG检索环节的物理基础
与传统数据库的区别 向量DB擅长相似性查询(找"像不像"),关系型DB擅长精确查询(找"等不等于"),两者互补使用
四大索引算法 HNSW(快速高召回,吃内存)、IVF系列(平衡之选)、FLAT(精确但慢)、DiskANN(超大容量,低成本)
FAISS 轻量级本地算法库,适合快速原型验证,非数据库服务,存为文件,需注意反序列化安全
Milvus 生产级开源分布式向量数据库,支持亿级数据、分布式架构、丰富的检索功能(过滤、混合、分组检索等)
Milvus核心概念 Collection(表)、Schema(结构)、Partition(分区,提速关键)、Alias(别名,零停机升级)
句子窗口检索 索引单句保精度,生成时扩展上下文保质量;巧妙解决了"小块检索精度高但上下文不足"的难题
结构化路由 通过摘要索引做顶层路由,先定位数据源再精确检索;解决"大规模多源数据下的检索效率"问题
安全红线 PandasQueryEngine等代码执行类工具存在严重安全风险,生产环境严禁使用

如果本文对你有帮助,欢迎点赞、收藏、转发,让更多朋友避开RAG落地中的那些坑!有任何疑问,欢迎在评论区交流讨论~