RAG索引构建

一、什么是索引

在数据库和信息检索里,“索引”本质上是 一种数据结构或方法,用来加速查询。在 RAG 中,我们通常需要在一个向量库里快速找到和用户 query 语义相似的文档。这里的“索引”就是指 对存储的文本向量建立的高效检索结构

如果没有索引,那查询的时候只能暴力计算 query 与库里所有向量的相似度(复杂度 O ( n ) O(n) O(n))。一旦数据量大(百万、上亿),速度就完全不够用。

所以就需要构建索引,用一些算法(比如树结构、图结构、量化方法)来缩小搜索范围。

1.1 常见的向量索引类型

在向量数据库(如 Milvus、Faiss、Pinecone)中,常见的索引方式有:

  • FLAT(暴力检索)

    • 原理:暴力搜索(Brute-force Search)。它会计算查询向量与集合中所有向量之间的实际距离,返回最精确的结果。
    • 没有加速结构,每次 query 都和所有向量计算相似度。
    • 精度最高,但速度慢,只适合小数据集。
  • IVF(Inverted File Index,倒排文件)

    • 原理:类似于书籍的目录。它首先通过聚类将所有向量分成多个“桶”(nlist),查询时,先找到最相似的几个“桶”,然后只在这几个桶内进行精确搜索。IVF_FLATIVF_SQ8IVF_PQ 是其不同变体,主要区别在于是否对桶内向量进行了压缩(量化)。
    • 常见:IVF_FLAT、IVF_SQ8、IVF_PQ(后两者会进一步压缩存储)。
    • 优点:速度快,适合大规模向量;缺点:可能有召回率损失。
  • HNSW(Hierarchical Navigable Small World Graph)

    • 原理:构建一个多层的邻近图。查询时从最上层的稀疏图开始,快速定位到目标区域,然后在下层的密集图中进行精确搜索。
    • 查询时沿着图走,可以快速找到近似邻居。
    • 优点:查询速度快,精度高;缺点:构建索引比较耗时,占用内存大。
  • Annoy(Approximate Nearest Neighbors Oh Yeah)

    • **原理:**基于多棵二叉树(Binary Trees)构建一个森林(Forest)。每棵树通过随机投影(Random Projections)递归地分割数据空间,将相似的向量聚集在相同的叶子节点(Leaf Nodes)中。查询时,在森林中的所有树上进行遍历,收集所有可能包含近邻的叶子节点中的向量,最后对这些候选向量进行精确距离计算并排序返回。
    • 基于多棵随机投影树。
    • 构建速度快,适合静态数据集。
    • 缺点:更新不方便。
  • DiskANN (基于磁盘的索引)

    • 原理:一种为在 SSD 等高速磁盘上运行而优化的图索引。
    • 支持远超内存容量的海量数据集(十亿级甚至更多),同时保持较低的查询延迟。
    • 相比纯内存索引,延迟稍高。
    • 数据规模巨大,无法全部加载到内存的场景。

1.2 索引举例——IVF 索引

IVF_FLAT 为例,假设我们有 100 万条向量,维度 768:

  • 先用 KMeans 聚成 1000 个桶(cluster centers)。
  • 每个桶下面挂一堆向量。
  • 查询时先找到 query 最近的几个桶,再只在这些桶里暴力比对。

从“长相”来看,它其实就是:

索引 = {
    "centroids": [c1, c2, ..., c1000],   # 每个桶的中心点向量
    "inverted_lists": {
        c1: [向量ID1, 向量ID2, ...],
        c2: [向量ID33, 向量ID34, ...],
        ...
    }
}

这就是索引的“内容”,它不像一个简单字符串,而是一个包含聚类中心+倒排表的复杂数据结构。

1.3 IVF 索引下的完整流程

假设有如下 IVF 索引:

索引 = {
    "centroids": [c1, c2, ..., c1000],   # 聚类中心
    "inverted_lists": {
        c1: [v1, v2, v3],
        c2: [v4, v5],
        c3: [v6, v7, v8],
        ...
    }
}

检索步骤:

  1. 输入 query 向量
    用户输入文本 → embedding 模型 → 得到向量 q

  2. 找到最接近的聚类中心

    • 计算 q 和所有 centroids 的相似度(通常是欧式距离)。
    • 比如发现 q 最近的是 c2c3
  3. 限定搜索范围(候选集合)

    • 只进入 c2c3 的 inverted list
    • 候选集合 = {v4, v5, v6, v7, v8}
  4. 计算相似度分数

    • 逐个计算 q 和候选集合中每个向量的相似度:

      sim(q, v4)
      sim(q, v5)
      sim(q, v6)
      sim(q, v7)
      sim(q, v8)
      

      这里的 sim 就是余弦相似度 / 内积 / L2 距离。

  5. 排序 & 返回结果

    • 按分数排序,选 top-k 个最相似的向量 ID。

    • 返回结果格式可能是:

      [
        {"id": v7, "score": 0.92},
        {"id": v5, "score": 0.90},
        {"id": v6, "score": 0.85}
      ]
      

总结

  • 你不是直接用 inverted_list 计算相似度,而是:
    1. query 找最近的聚类中心;
    2. 从相应的 inverted_list 里取候选向量;
    3. 再逐一计算 query 和候选向量的相似度
    4. 最后排序返回。

👉 索引只决定“你跟谁比”,真正的“分数”还是通过 query 向量 vs 数据向量 算出来的。

二、选择索引的方法

选择索引没有唯一的“最佳答案”,需要根据业务场景在数据规模、内存限制、查询性能和召回率之间进行权衡。

场景 推荐索引 备注
数据可完全载入内存,追求低延迟 HNSW 内存占用较大,但查询性能和召回率都很优秀。
数据可完全载入内存,追求高吞吐 IVF_FLAT / IVF_SQ8 性能和资源消耗的平衡之选。
数据量巨大,无法载入内存 DiskANN 在 SSD 上性能优异,专为海量数据设计。
追求 100% 准确率,数据量不大 FLAT 暴力搜索,确保结果最精确。

在实际应用中,通常需要通过测试来找到最适合自己数据和查询模式的索引类型及其参数。

三、索引优化

我们知道,获得这个向量索引的前提条件,就是获得分块后的文本,然后将文本映射到设定维数向量。也就是说,在索引之前,有以下几步。

数据准备与建库 (Index Building)

  1. 文本分块 (Chunking):
    • 目的:将长文本(如文档、书籍、网页)切分成更小的、语义完整的片段(例如,一个段落、几句话或固定大小的字符块)。
    • 原因
      • 精度:Embedding 模型通常有输入长度限制(如512或1024个token)。一个长文本包含多个主题,将其整体编码为一个向量会失去细节,无法精确定位到最相关的信息。分块后,每个向量代表一个更聚焦的语义单元,检索精度更高。
      • 召回:一份文档中的不同部分可能回答不同的问题。分块后,同一份文档可以有多个入口点,提高了被检索到的机会。
    • 结果:你得到了一个文本块列表 [chunk1, chunk2, chunk3, ...]
  2. 向量化 (Embedding):
    • 使用 Embedding 模型(如 text-embedding-ada-002, BGE, M3E等)将每一个文本块转化为一个向量。
    • 结果:你得到了一个与文本块一一对应的向量列表 [v1, v2, v3, ...]元数据(记录每个向量对应哪个原始文档的哪个块)。
  3. 构建索引 (Indexing):
    • 将上一步得到的所有向量 [v1, v2, v3, ...] 添加到向量数据库中(如 Milvus, Faiss, ChromaDB等)。
    • 数据库会执行您描述的 IVF 流程:
      • 对这些向量进行聚类,生成 centroids
      • 将每个向量分配到最近的聚类中心,形成 inverted_lists
    • 结果:向量索引构建完成,等待查询。

3.1 上下文扩展

在RAG系统中,常常面临一个权衡问题:使用小块文本进行检索可以获得更高的精确度,但小块文本缺乏足够的上下文,可能导致大语言模型(LLM)无法生成高质量的答案;而使用大块文本虽然上下文丰富,却容易引入噪音,降低检索的相关性。为了解决这一矛盾,LlamaIndex 提出了一种实用的索引策略——句子窗口检索(Sentence Window Retrieval)2。该技术巧妙地结合了两种方法的优点:它在检索时聚焦于高度精确的单个句子,在送入LLM生成答案前,又智能地将上下文扩展回一个更宽的“窗口”,从而同时保证检索的准确性和生成的质量。

3.1.1 实现流程

句子窗口检索的思想可以概括为:为检索精确性而索引小块,为上下文丰富性而检索大块

其工作流程如下:

  1. 索引阶段:在构建索引时,文档被分割成单个句子。每个句子都作为一个独立的“节点(Node)”存入向量数据库。同时,每个句子节点都会在元数据(metadata)中存储其上下文窗口,即该句子原文中的前N个和后N个句子。这个窗口内的文本不会被索引,仅仅是作为元数据存储。
  2. 检索阶段:当用户发起查询时,系统会在所有单一句子节点上执行相似度搜索。因为句子是表达完整语义的最小单位,所以这种方式可以非常精确地定位到与用户问题最相关的核心信息。
  3. 后处理阶段:在检索到最相关的句子节点后,系统会使用一个名为 MetadataReplacementPostProcessor 的后处理模块。该模块会读取到检索到的句子节点的元数据,并用元数据中存储的完整上下文窗口来替换节点中原来的单一句子内容。
  4. 生成阶段:最后,这些被替换了内容的、包含丰富上下文的节点被传递给LLM,用于生成最终的答案。
3.1.2 实现流程示例

目标:让用户能够针对一份冗长的产品技术文档进行提问,并得到准确且上下文丰富的答案。

原始文档片段(假设):

…(前文)… 为确保系统安全,必须定期更换密码。

1.3.2 配置加密通信
要启用端到端加密(E2EE),请导航至“设置”->“安全”页面。在此页面底部,您会找到一个名为“高级通信安全”的复选框。勾选此框并保存设置。重启服务后,所有节点间的通信都将使用AES-256算法进行加密。

请注意,启用此功能可能会导致性能有轻微下降(预计延迟增加5-10%),这是加密解密的正常开销。在启用前,请确保所有节点均已升级至v2.1或更高版本…

…(后文)…


第1步:索引阶段 (Indexing)**

操作:系统接收整个文档,并进行处理。

  1. 句子分割:将文档拆分成单个句子,每个句子成为一个独立的“节点”,并生成对应的嵌入向量(Embedding)。
  2. 存储元数据(上下文窗口):为每个句子节点计算并存储其上下文。假设我们设置窗口大小 N = 1(即前1句和后1句)。
节点ID 核心句子 (被索引的向量) 元数据(存储的上下文窗口)
Node_A 要启用端到端加密(E2EE),请导航至“设置”->“安全”页面。 前1句: 为确保系统安全,必须定期更换密码。 后1句: 在此页面底部,您会找到一个名为“高级通信安全”的复选框。
Node_B 在此页面底部,您会找到一个名为“高级通信安全”的复选框。 前1句: 要启用端到端加密(E2EE),请导航至“设置”->“安全”页面。 后1句: 勾选此框并保存设置。
Node_C 勾选此框并保存设置。 前1句: 在此页面底部,您会找到一个名为“高级通信安全”的复选框。 后1句: 重启服务后,所有节点间的通信都将使用AES-256算法进行加密。
Node_D 重启服务后,所有节点间的通信都将使用AES-256算法进行加密。 前1句: 勾选此框并保存设置。 后1句: 请注意,启用此功能可能会导致性能有轻微下降(预计延迟增加5-10%),这是加密解密的正常开销。
Node_E 请注意,启用此功能可能会导致性能有轻微下降(预计延迟增加5-10%),这是加密解密的正常开销。 前1句: 重启服务后,所有节点间的通信都将使用AES-256算法进行加密。 后1句: 在启用前,请确保所有节点均已升级至v2.1或更高版本...

此时,向量数据库中只存储了第2列(加粗部分)的嵌入向量。第3列的元数据只是作为附加信息存储在数据库中,不会被单独索引。


第2步:检索阶段 (Retrieval)

用户查询:“启用加密功能后,用什么算法进行加密?”

  1. 查询嵌入:将用户查询转化为一个查询向量 Q
  2. 相似性搜索:系统将 Q 与向量数据库中的所有句子向量(Node_A, Node_B, Node_C, Node_D, Node_E …)进行比较。
  3. 返回最相关节点:由于查询非常具体地提到了“加密”和“算法”,与 Node_D 的核心句子(...使用AES-256算法进行加密。)的语义匹配度最高。因此,系统首先检索到的是 Node_D

第3步:后处理阶段 (Postprocessing)

操作MetadataReplacementPostProcessor 开始工作。

  1. 获取元数据:处理器查看检索到的 Node_D 的元数据。
  2. 替换内容:处理器不会把孤零零的 重启服务后,所有节点间的通信都将使用AES-256算法进行加密。 这个句子发送给LLM。而是用元数据中存储的完整上下文窗口替换掉节点的原始内容。

最终发送给LLM的Node_D内容变为:

勾选此框并保存设置。重启服务后,所有节点间的通信都将使用AES-256算法进行加密。请注意,启用此功能可能会导致性能有轻微下降(预计延迟增加5-10%),这是加密解密的正常开销。

(即:前1句 + 核心句子本身 + 后1句)


第4步:生成阶段 (Generation)

操作:LLM接收到的提示(Prompt)大致如下:

你是一个专业的客服助手。请根据以下提供的上下文信息,准确回答用户的问题。

【上下文】
勾选此框并保存设置。重启服务后,所有节点间的通信都将使用AES-256算法进行加密。请注意,启用此功能可能会导致性能有轻微下降(预计延迟增加5-10%),这是加密解密的正常开销。

【用户问题】
启用加密功能后,用什么算法进行加密?

LLM的回复

根据文档,启用加密功能并重启服务后,所有节点间的通信将使用 AES-256算法 进行加密。请注意,这可能会带来5-10%的性能开销。

3.1.3 代码
import os
from llama_index.core.node_parser import SentenceWindowNodeParser, SentenceSplitter
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Settings
from llama_index.llms.deepseek import DeepSeek
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.core.postprocessor import MetadataReplacementPostProcessor

# 1. 配置模型
Settings.llm = DeepSeek(model="deepseek-chat", temperature=0.1, api_key=os.getenv("DEEPSEEK_API_KEY"))
Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-en")

# 2. 加载文档
documents = SimpleDirectoryReader(
    input_files=[r"all-in-rag\data\C3\pdf\IPCC_AR6_WGII_Chapter03.pdf"]
).load_data()

# 3. 创建节点与构建索引
# 3.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)

# 3.2 常规分块索引 (基准)
base_parser = SentenceSplitter(chunk_size=512)
base_nodes = base_parser.get_nodes_from_documents(documents)
base_index = VectorStoreIndex(base_nodes)

# 4. 构建查询引擎
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)

# 5. 执行查询并对比结果
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")

博客原文写的非常好。根据 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。此时,每个 TextNodetext 属性就是这个句子的内容。

  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 使用。

    解释:假设我们不禁用 window 元数据。那么,嵌入模型在读取节点时,看到的将不再是单纯的句子,而是一长段包含了前后多个句子的“窗口文本”。当用户查询一个非常具体的问题时(例如“加密使用什么算法?”),最匹配的本应是精确描述算法的那个句子(Node_D)。但如果向量是由一大段文本生成的,其中可能包含了“性能下降”、“升级版本”等无关信息,这个向量的语义就会变得模糊,导致检索时可能无法排在最前面,从而严重降低召回率

通过以上步骤,SentenceWindowNodeParser 最终返回一个 TextNode 列表。列表中的每个节点都代表一个独立的句子,其 text 属性用于精确检索,而其 metadata 中则“隐藏”了用于生成答案的丰富上下文窗口。

##输出结果
查询: What are the concerns surrounding the AMOC?

--- 句子窗口检索结果 ---
回答: There is low confidence in understanding changes to the Atlantic Meridional Overturning Circulation (AMOC) during the 20th century due to limited agreement between reconstructed data and model simulations. Observational records since the mid-2000s are too short to determine the causes of its variability. However, it is expected to decline throughout the 21st century under all future scenarios, though an abrupt collapse before 2100 is not anticipated.

--- 常规检索结果 ---
回答: The Atlantic Meridional Overturning Circulation (AMOC) is expected to decline throughout the 21st century across all Shared Socioeconomic Pathway scenarios. However, there is insufficient observational data to determine the specific contributions of internal variability, natural forcing, and human-caused factors to these changes. While a decline is anticipated, an abrupt collapse before 2100 is not expected.

3.2 结构化索引

  • 想象一下,您的知识库不是一个PDF,而是公司过去十年的所有技术手册、市场报告和客户合同,总共500个PDF。
    • “信号噪声”问题:当一个用户询问“2023款A型设备的安全操作规程”时,这个查询与2023年的手册高度相关,但与2015年的手册、市场报告或无关设备的合同几乎无关。传统的全库Top-K搜索会强制将所有文档的文本块与查询进行相似度比较。最终,返回的Top-K个结果可能夹杂着来自不相关文档但文本相似度高的片段(例如,其他年份的规程、其他设备的操作步骤)。这些无关片段就是“噪声”,它们会稀释上下文,误导LLM生成不准确或泛泛而谈的答案。
    • 效率瓶颈:计算一个查询向量与数百万个向量之间的距离(即使使用ANN索引)在硬件上仍然是昂贵的,会带来较高的延迟和计算成本。如果每次查询都必须扫描整个库,其扩展性将非常差。
3.2.1 结构化索引(Structured Indexing)的底层机制

仅仅拥有元数据是不够的,必须有一种高效的方式来基于它们进行查询。这就是结构化索引的用武之地。

一个成熟的结构化索引系统通常包含两种索引协同工作:

  • 向量索引(Vector Index):负责处理基于语义相似度的搜索。它存储文本块嵌入,并快速找到与查询向量最相似的Top-N个块。
  • 元数据索引(Metadata Index):负责处理基于精确值或范围的过滤。它通常是一个倒排索引(Inverted Index) 或集成在向量数据库中的过滤引擎

倒排索引简单原理:它创建一个映射,告诉你哪个值出现在哪些文档/块中。

text

"2023" -> [chunk_45, chunk_901, chunk_1023, ...]
"Device-A" -> [chunk_12, chunk_45, chunk_500, ...]
"Safety Precautions" -> [chunk_45, chunk_200, ...]

当进行过滤时,数据库可以非常快地通过集合交集(AND)或并集(OR) 操作找到同时满足多个条件的文本块ID集合。例如,快速定位所有 year=2023 AND product=Device-A AND section="Safety Precautions" 的块。

3.2.2 结构化索引(Structured Indexing)的流程

结合了上述两种索引后,查询流程变得非常高效和精准:

  1. 解析查询意图:系统首先解析用户的自然语言查询,识别出其中隐含的过滤条件
    • 用户查询:“帮我找2023款A型设备的安全操作规程”
    • 解析出的过滤器year == "2023", product_line == "Device-A", section_header CONTAINS "Safety"
  2. 元数据过滤:查询引擎将解析出的过滤器发送给元数据索引。元数据索引迅速返回一个符合所有条件的候选文本块ID列表。这个列表可能只包含几千甚至几百个块,相比整个数百万的库,范围急剧缩小。
  3. 语义搜索:将用户的查询转换为向量,但只在上一步得到的候选块列表中进行向量相似度搜索(Top-K)。这步操作的数据量小了几个数量级,因此速度极快。
  4. 生成答案:将精准检索到的、高相关度的Top-K个文本块作为上下文,发送给LLM生成最终答案。
3.2.3 代码
import os
import pandas as pd
from llama_index.core import VectorStoreIndex
from llama_index.core.schema import IndexNode
from llama_index.experimental.query_engine import PandasQueryEngine
from llama_index.core.retrievers import RecursiveRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.llms.deepseek import DeepSeek
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.core import Settings

# 配置模型
Settings.llm = DeepSeek(model="deepseek-chat", api_key=os.getenv("DEEPSEEK_API_KEY"))
Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-zh-v1.5")

# 1.加载数据并为每个工作表创建查询引擎和摘要节点
excel_file = r'all-in-rag\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)
    
    # 为当前工作表(DataFrame)创建一个 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. 创建递归检索器
# 3.1 创建顶层检索器,用于在摘要节点中检索
vector_retriever = vector_index.as_retriever(similarity_top_k=1)

# 3.2 创建递归检索器
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}")

结果:

INFO:sentence_transformers.SentenceTransformer:Load pretrained SentenceTransformer: BAAI/bge-small-zh-v1.5
Load pretrained SentenceTransformer: BAAI/bge-small-zh-v1.5
INFO:sentence_transformers.SentenceTransformer:1 prompt is loaded, with the key: query
1 prompt is loaded, with the key: query
查询: 1994年评分人数最少的电影是哪一部?
Retrieving with query id None: 1994年评分人数最少的电影是哪一部?
Retrieved node with id, entering: 年份_1994
Retrieving with query id 年份_1994: 1994年评分人数最少的电影是哪一部?
INFO:httpx:HTTP Request: POST https://api.deepseek.com/chat/completions "HTTP/1.1 200 OK"
HTTP Request: POST https://api.deepseek.com/chat/completions "HTTP/1.1 200 OK"
> Pandas Instructions:
```
df[df['年份'] == 1994].nsmallest(1, '评分人数')['电影名称'].iloc[0]
```
> Pandas Output: 燃情岁月
Got response: 燃情岁月
INFO:httpx:HTTP Request: POST https://api.deepseek.com/chat/completions "HTTP/1.1 200 OK"
HTTP Request: POST https://api.deepseek.com/chat/completions "HTTP/1.1 200 OK"
回答: 燃情岁月
  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 来处理后续查询。

参考文献

1.all-in-rag 第五节 索引优化

Logo

为武汉地区的开发者提供学习、交流和合作的平台。社区聚集了众多技术爱好者和专业人士,涵盖了多个领域,包括人工智能、大数据、云计算、区块链等。社区定期举办技术分享、培训和活动,为开发者提供更多的学习和交流机会。

更多推荐