Skip to content

LlamaIndex 基础使用

发表于: 时间 16:30
加载中...

基本介绍

LlamaIndex 具有强大的数据连接、索引与检索能力,也支持智能体工作流。在实际应用中,可以根据需求与 LangChain、LangGraph 等框架结合使用,将知识检索与智能体编排连接起来。

简单来说,LlamaIndex 目前主要用来构建 RAG(检索增强生成)知识库。

首先安装相关依赖:

pip install llama-index llama-index-readers-file python-dotenv

关于 llama-index 这个库,自 v0.10 版本起,官方对其进行了模块化重构,将其拆分成了核心库(llama-index-core)以及针对具体模型、向量数据库和读取器的独立集成包(例如 llama-index-embeddings-openai、llama-index-llms-openai 等)。在生产环境中,也可以采用“按需安装”的模式,根据实际使用的模型和工具单独引入对应的集成包,以减少不需要的依赖。

对于学习探索和快速原型开发,官方提供了 llama-index 入门包,它包含核心模块及 OpenAI 的 LLM 与 Embedding 集成。本文使用 llama-index 0.14.25,该版本不再附带文件 Reader,因此上面的安装命令显式添加了 llama-index-readers-file,并安装后文加载 .env 使用的 python-dotenv。不同文件格式的 Reader 还可能需要额外依赖。

常见的几个 Reader 类

1. 本地目录读取器(SimpleDirectoryReader)

这是 LlamaIndex 中最核心、最常用、也最“傻瓜化”的加载器。你只需给它一个本地文件夹的路径,它就能自动扫描目录,并根据文件后缀名(如 .txt, .pdf, .docx, .csv, .md, .pptx, .jpg ,.mp4 等)在底层自动调用对应的解析工具,将所有内容转换为标准化的 Document 对象。

SimpleDirectoryReader 常用的参数有如下 3 个,但其参数不只包含这三个,其他的参数读者可以自行阅读参考文档,这 3 个是比较常用的:

from llama_index.core import SimpleDirectoryReader
reader = SimpleDirectoryReader(
    input_dir="./data", # 输入路径,即文件所在的根目录
    # input_files=["./data/1.txt","./data/2.pdf"] # 指定只处理文件
    recursive=True, # 开启递归读取所有子目录下的文件,默认是 False
    exclude=["*.tmp", "temp/*"]  # 排除临时文件或目录
)

然后使用 reader.load_data() 转成 document 列表:

documents = reader.load_data()

我这里在 data 目录下先只放一篇 A3PRVR.pdf 的论文作为测试:

image.png

然后我们打印输出 documents 的长度:

image-20260922114100430

可以看到输出 9,而这篇 AAAI2026 论文的 PDF 刚好有 9 页。本例默认使用的 PDFReader 按页返回 Document,但并非所有 PDF 读取方式都如此;该 Reader 也可以通过 return_full_document=True 返回整篇文档。

再看一下 document[0] 的数据结构:

from pprint import pprint
pprint(documents[0].dict())

打印输出结果如下(其中 text 字段因为太长我省略了后面的部分):

{'class_name': 'Document',
 'embedding': None,
 'end_char_idx': None,
 'excluded_embed_metadata_keys': ['file_name',
                                  'file_type',
                                  'file_size',
                                  'creation_date',
                                  'last_modified_date',
                                  'last_accessed_date'],
 'excluded_llm_metadata_keys': ['file_name',
                                'file_type',
                                'file_size',
                                'creation_date',
                                'last_modified_date',
                                'last_accessed_date'],
 'id_': 'ffabb7ce-eab0-4878-99b0-aa20a43cc7aa',
 'metadata': {'creation_date': '2026-09-22',
              'file_name': 'A3PRVR.pdf',
              'file_path': 'E:\\glader\\AgentLearning\\05-LlamaIndex\\data\\A3PRVR.pdf',
              'file_size': 1075393,
              'file_type': 'application/pdf',
              'last_modified_date': '2026-07-01',
              'page_label': '1'},
 'metadata_seperator': '\n',
 'metadata_template': '{key}: {value}',
 'relationships': {},
 'start_char_idx': None,
 'text': 'Action-and-object Aware Alignment for Partially Relevant Video '
         'Retrieval\n'
         '...',
 'text_template': '{metadata_str}\n\n{content}'}

可以看到,比较核心的属性有 metadata 和 text,分别表示这个 document 的元数据和文本内容。

SimpleDirectoryReader 默认处理 PDF 时不会提取内嵌图片。若需提取,读者需参考官方文档,通过 file_extractor 指定支持图片提取的解析器,或改用 LlamaParse、PyMuPDF 等工具。

虽然如此,但是 SimpleDirectoryReader 直接对图片文件进行处理,是可以的,他会返回 ImageDocument 的列表。例如我现在准备了一张斯大林同志的肖像:

image.png

然后执行代码:

img_documents = SimpleDirectoryReader(input_files=["./data/sidalin.jpg"]).load_data()
pprint(img_documents[0].dict())
print("=" * 10)
print(type(img_documents[0]))

得到结果:

{'class_name': 'ImageDocument',
 'embedding': None,
 'end_char_idx': None,
 'excluded_embed_metadata_keys': ['file_name',
                                  'file_type',
                                  'file_size',
                                  'creation_date',
                                  'last_modified_date',
                                  'last_accessed_date'],
 'excluded_llm_metadata_keys': ['file_name',
                                'file_type',
                                'file_size',
                                'creation_date',
                                'last_modified_date',
                                'last_accessed_date'],
 'id_': '2ddc1bfb-6d7d-4e07-a742-cb4d052a58d5',
 'image': None,
 'image_mimetype': None,
 'image_path': 'data\\sidalin.jpg',
 'image_url': None,
 'metadata': {'creation_date': '2026-09-22',
              'file_name': 'sidalin.jpg',
              'file_path': 'data\\sidalin.jpg',
              'file_size': 23769,
              'file_type': 'image/jpeg',
              'last_modified_date': '2026-09-22'},
 'metadata_seperator': '\n',
 'metadata_template': '{key}: {value}',
 'relationships': {},
 'start_char_idx': None,
 'text': '',
 'text_embedding': None,
 'text_template': '{metadata_str}\n\n{content}'}
==========
<class 'llama_index.core.schema.ImageDocument'>

可以看到,图片的 text 属性是空的。

总体而言,SimpleDirectoryReader 的核心机制是按文件扩展名查表:它会在内部维护的默认 Reader 映射表中查找匹配项,并自动委派给对应的 Reader 来解析不同格式的文件。默认映射关系如下:

扩展名对应的 Reader 类格式说明
.csvPandasCSVReader逗号分隔值(表格数据)
.docxDocxReaderMicrosoft Word 文档
.epubEpubReaderEPUB 电子书
.hwpHWPReader韩文文字处理器文档
.ipynbIPYNBReaderJupyter Notebook
.jpeg / .jpg / .png / .gif / .webpImageReader图片文件
.mboxMboxReaderMBOX 邮件归档
.md通用文本读取逻辑默认按纯文本读取;可通过 file_extractor 指定 MarkdownReader
.mp3 / .mp4VideoAudioReader音频与视频
.pdfPDFReaderPDF 文档
.ppt / .pptm / .pptxPptxReaderMicrosoft PowerPoint
.xls / .xlsxPandasExcelReaderExcel 表格
未匹配的扩展名通用文本读取逻辑按文本读取,默认编码为 UTF-8;并不自动实例化 FlatReader

各专用 Reader 的具体用法,读者可参考 LlamaIndex 官方文档或对应集成包的说明进一步了解。这里不再赘述。

2. 网页读取器(SimpleWebPageReader / BeautifulSoupWebReader)

顾名思义,这两个 Reader 都是来读取 web 网页上的内容的。这两个需要单独安装:

pip install llama-index-readers-web

(1)SimpleWebPageReader

SimpleWebPageReader 是最简单的网页读取器。它用 requests 抓取页面,再根据 html_to_text 参数决定是否把 HTML 转成可读文本。

例如:

from llama_index.readers.web import SimpleWebPageReader
from pprint import pprint
# 实例化时不传 URL
reader = SimpleWebPageReader(html_to_text=True)
# URL 传给 load_data()
documents = reader.load_data(
    urls=["https://memo.myyrh.com","https://mblog.mslxl.com","https://blog.galyoo.top/post/newera/"]
)
pprint(documents[0].dict())

image.png

如上所示,可读性仍然比较差,很多乱码。

(2)BeautifulSoupWebReader

from pprint import pprint
from llama_index.readers.web import BeautifulSoupWebReader
reader = BeautifulSoupWebReader()
documents = reader.load_data(urls=["https://memo.myyrh.com"
                 ,"https://mblog.mslxl.com",
                 "https://blog.galyoo.top/post/newera/"])
pprint(documents[0].dict())

image.png

BeautifulSoupWebReader 基于 BeautifulSoup 解析网页,基础用法只需传入 urls。对没有专用提取规则的网站,它默认提取整个页面的文本,可能包含导航和页脚,并不保证只提取正文。需要精确提取时,可以通过 website_extractor 定制站点规则;更高级的用法和异常处理这里不展开,读者可自行查阅文档研究。

3. 数据库的读取(DatabaseReader)

DatabaseReader 用来读取数据库中的数据并转化为 documents,其支持多种类型的数据库,例如 MySQL,PostgreSQL 和 SQLite 等。

这里以 MySQL 为例,首先需要安装如下库:

pip install pymysql llama-index-readers-database

然后写一个简单的示例,我这里提前准备了一个数据库表:

from llama_index.readers.database import DatabaseReader
reader = DatabaseReader(
    scheme="mysql+pymysql",
    host="localhost",
    port="3306",
    user="root",
    password="123456",
    dbname="test"
)
documents = reader.load_data(query="select * from user;")
print(documents)

结果如下:

image.png

4. 自定义 Reader

当官方或社区提供的 Reader 无法满足业务需求时(例如需要对接公司内部的私有 API、解析某种罕见的私有文件格式,或者从特定的消息队列中实时抓取数据),我们可以通过自定义 Reader 来实现扩展。

在 LlamaIndex 中,所有的 Reader 都继承自抽象基类 BaseReader。自定义一个 Reader 非常简单,核心只需两步:

我们以读取 md 为例,虽然社区已经有提供的读取 md 的 Reader,但是这里我们为了方便演示,我们手动自己写一个读取 md 的自定义 Reader:

from llama_index.core.readers.base import BaseReader
from llama_index.core import Document
# 自定义 Reader 类用于读取 md
class MyMdReader(BaseReader):
    # 重写 load_data 方法
    def load_data(self, file_path: str):
        with open(file_path, "r", encoding="utf-8") as f:
            text = f.read()
        return [Document(text=text[0:100]),Document(text=text[100:])]
documents = MyMdReader().load_data("./data/index.md")
print(documents)

image.png

文本切分与解析

虽然上面我们已经完成了对文档的读取,但是读进来的 Document 通常还需要进一步处理:它们往往粒度太粗,比如一篇 PDF 可能按页返回,一页里既有正文又有页眉页脚、参考文献;同时长度也不可控,直接 embedding 或塞进 LLM 容易超上下文,检索时也可能引入噪声。因此,在正式构建索引之前,通常需要“文本切分与解析”,把原始 Document 拆成粒度合适、语义相对完整的 Node。这一步可以手动执行,也可以交给 from_documents() 内部完成。LlamaIndex 中负责这件事的组件包括 TextSplitter / NodeParser。

我们选择了一篇高质量的 NixOS 介绍博客,并以它的源 Markdown 文档作为本部分练习与演示的素材。

我们首先将下载的 index.md 放入到我们的 data 目录下。

1. 使用 TokenTextSplitter 对文本进行切分

TokenTextSplitter 常用参数有两个,分别是 chunk_size 和 chunk_overlap。chunk_size 用于控制每个切分块的最大 token 数,chunk_overlap 则指定相邻块之间重叠的 token 数。设置 chunk_overlap 的目的,是让被切断的句子或语义片段在相邻块中都有出现,从而降低关键信息因切分而丢失的风险。其他参数读者可以自行探究。

我们接下来测试如下代码:

from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import TokenTextSplitter
documents = SimpleDirectoryReader(input_files=["./data/index.md"]).load_data()
print(len(documents))
splitter = TokenTextSplitter(chunk_size=100, chunk_overlap=50)
nodes = splitter.get_nodes_from_documents(documents=documents)
print(len(nodes))
print("=" * 100)
print(nodes[10])
print("=" * 100)
print(nodes[11])

简单设置 chunk_size=100,chunk_overlap=50,可以看到结果如下:

image.png

红线部分是重叠的部分。

2. 使用 SentenceSplitter 对文本进行切分

还是使用上述的 index.md, 使用如下代码:

from llama_index.core.node_parser import SentenceSplitter

splitter = SentenceSplitter(chunk_size=100, chunk_overlap=50)
nodes = splitter.get_nodes_from_documents(documents=documents)
print(len(nodes))
print("=" * 100)
print(nodes[10])
print("=" * 100)
print(nodes[11])

结果如下:

image.png

可以看到,我们同样使用和 (1) 中相同的参数,得到的结果最本质的区别是: SentenceSplitter 的结果倾向于以一个完整的句子结尾,而不是直接在句子中间进行截断。

其核心算法步骤如下:

image.png

所以这样使得最终分割的结果,既能严格控制在 chunk_size 的容量限制内,又能最大程度地保留自然语言的语义完整性。

除了最基础的 TokenTextSplitter 和 SentenceSplitter,LlamaIndex 还提供了许多针对特定格式或高级场景的切分工具。这里仅作简单列举,大家在实际开发中可以根据业务数据类型,查阅官方文档进行深入研究与测试:

构建索引与向量化

前面已经完成了将各种数据读取成程序可处理的文档,并完成了对文档的分割,接下来要做的,就是将分割好的文本块 nodes 进行向量化转化,并构建成特定的数据结构以便于后续的快速检索。在 LlamaIndex 中,这一步被称为构建索引(Indexing)。

最开始我们安装的 llama-index,里面已经集成了 llama-index-embeddings-openai,在这里我们无须重新安装,可以直接测试。

我们首先定义如下环境变量:

OPENAI_API_KEY=你ai的api_key
OPENAI_API_BASE=你ai的base_url的地址

构建索引主要使用的类为 VectorStoreIndex,常见用法如下:

1. 从 nodes 中构建索引

import httpx, json
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.core import VectorStoreIndex
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core import SimpleDirectoryReader
from dotenv import load_dotenv
load_dotenv()
documents = SimpleDirectoryReader(input_files=["./data/index.md"]).load_data()
splitter = SentenceSplitter(chunk_size=100, chunk_overlap=50)
nodes = splitter.get_nodes_from_documents(documents=documents)
# 用于查看发送给接口的数据
http_client = httpx.Client(
    event_hooks={
        "request": [
            lambda request: print(
                "\n===== 请求 =====\n",
                json.dumps(json.loads(request.read()), ensure_ascii=False, indent=2),
            )
        ],
        "response": [
            lambda response: print(
                "\n===== 返回 =====\n",
                json.dumps(json.loads(response.read()), ensure_ascii=False, indent=2),
            )
        ],
    }
)
embed_model = OpenAIEmbedding(
    model_name="qwen3.7-text-embedding", embed_batch_size=5, http_client=http_client
)
index = VectorStoreIndex(nodes=nodes, embed_model=embed_model)

此时 VectorStoreIndex 主要传递两个参数,分别是 nodes 和 embed_model,表示前面我们分割好的 nodes 文本块和用于将文本块转化成向量的嵌入模型接口实例。这里我们采用的是 qwen3.7-text-embedding,批次大小设置为 5,即每次网络请求最多将 5 个文本块(Nodes)打包发送,最后一批可能不足 5 个。模型名称及可用性以你使用的接口为准。

http_client 可以用来方便测试查看并理解发送给接口的数据,正式开发可以不用填入

输出有若干次请求的结果,我们拿其中一次的结果进行分析:

===== 请求 =====
 {
  "input": [
    "file_path: data\\index.md  com/Misterio77/nix-starter-configs) 重构一下代码  配置文件在这: [mslxl/.dotfile](https://github.com/mslxl/.dotfile)  ![Whole](shot_1694708389.png)  配置过程时,软件的主要从 [NixOS Package Search](https://nixos.org/nixos/packages.",
    "file_path: data\\index.md  dotfile)  ![Whole](shot_1694708389.png)  配置过程时,软件的主要从 [NixOS Package Search](https://nixos.org/nixos/packages.html?channel=nixos-20.03) 中查询,部分与系统关系比较密切的通过 [Option](https://search.nixos.",
    "file_path: data\\index.md  org/nixos/packages.html?channel=nixos-20.03) 中查询,部分与系统关系比较密切的通过 [Option](https://search.nixos.org/options) 开启  使用中 NixOS 还是比较舒爽的,以安装 atuin 为例,如果是其他发行版安装可能还需要手动修改 `.",
    "file_path: data\\index.md  org/options) 开启  使用中 NixOS 还是比较舒爽的,以安装 atuin为例,如果是其他发行版安装可能还需要手动修改 `.zshrc` 文件,而 nix 只需要简单的添加 4 行配置,其他过程由 nix 自动完成。  ```nix  programs.",
    "file_path: data\\index.md  zshrc` 文件,而 nix 只需要简单的添加 4 行配置,其他过程由 nix自动完成。  ```nix  programs.atuin = {     enable = true;     enableBashIntegration = true;  enableZshIntegration = true;   };"
  ],
  "model": "qwen3.7-text-embedding",
  "encoding_format": "base64"
}
2026-10-03 11:53:51,482 - INFO - HTTP Request: POST https://ai.mygld.top/v1/embeddings "HTTP/1.1 200 OK"

===== 返回 =====
 {
  "object": "list",
  "data": [
    {
      "object": "embedding",
      "index": 0,
      "embedding": "uf6YPG4yBj1BPH89gR......"
    },
    {
      "object": "embedding",
      "index": 1,
      "embedding": "ghlMPJIw1jxFby......"
    },
    {
      "object": "embedding",
      "index": 2,
      "embedding": "YoGTPJwupTxbNRE8k......"
    },
    {
      "object": "embedding",
      "index": 3,
      "embedding": "t4JjPCWjnzzcH......"
    },
    {
      "object": "embedding",
      "index": 4,
      "embedding": "yr2YPPuD9jwfjdU8......"
    }
  ],
  "model": "qwen3.7-text-embedding",
  "usage": {
    "prompt_tokens": 487,
    "total_tokens": 487
  },
  "id": "ca28146a-0d05-923f-a667-a66608e059c4"
}

可以看到,当前请求批次大小为 5,里面的 input 数组有 5 个分割出来的文本块,每一个的格式都是 基本路径元数据+文本块文本内容,在请求里设置 "encoding_format": "base64" 表示以 base64 形式返回嵌入编码结果。

可以看到响应中,在 data 数组中,返回了 5 个编码结果的对象,其中 embedding 是 base64 形式返回的,为了方便展示,每个结果我做了省略展示。

这个 base64 结果本身并不是文本块经过 Base64 编码后的结果,而是文本经过 Embedding 模型处理后得到的向量数据的 Base64 表示。Embedding 模型会将每一个文本块转换成一个由大量浮点数构成的高维向量,例如:

[0.1234, -0.5271, 0.0812, …, 0.3145]

这些浮点数在实际传输时可以表示为二进制数据,再通过 Base64 编码转换成字符串,因此最终我们在 HTTP 返回结果中看到的是类似:

“uf6YPG4yBj1BPH89gR…”

这样的内容。客户端拿到这个 Base64 字符串后,可以再将其解码为原始的二进制数据,并进一步还原成浮点数向量,从而用于后续的向量存储、相似度计算和检索。

之所以不直接在 JSON 中返回类似 [0.1234, -0.5271, 0.0812, ...] 这样的浮点数数组,主要是为了提高数据传输效率。Embedding 向量通常具有较高的维度,如果直接使用 JSON 数组表示,不仅需要保存大量数字的文本表示,还会包含逗号、括号等额外的 JSON 格式字符,而 Base64 的方式可以直接对向量的二进制表示进行编码,避免将每个浮点数转换成文本后再进行传输。因此,对于维度较高、数量较多的 embedding 向量,使用 Base64 形式通常更加适合网络传输。

OpenAI 的 Embeddings API 也支持设置 "encoding_format": "float",直接返回 JSON 浮点数数组;第三方兼容接口是否支持该参数,应以其实现为准。

为了验证返回的 base64 向量格式,我从 LlamaIndex 调用的 OpenAI Python SDK 中提取了将 base64 转化为浮点数向量的逻辑,并写成了脚本,如下:

import array
import base64
import binascii
import json
def decode_embedding(encoded: str) -> list[float]:
    encoded = encoded.strip()
    if encoded.startswith('"'):
        encoded = json.loads(encoded)
    data = base64.b64decode(encoded, validate=True)
    if len(data) == 0 or len(data) % 4 != 0:
        raise ValueError(
            "Decoded data must contain a nonempty float32 vector (4 bytes per value)."
        )
    return array.array("f", data).tolist()

if __name__ == "__main__":
    try:
        vector = decode_embedding(input("Base64 embedding: "))
    except (ValueError, binascii.Error) as exc:
        print(f"Invalid embedding: {exc}")
        raise SystemExit(1)
    print(f"Dimensions: {len(vector)}")
    print("First 10 values: ") # 打印输出前 10 维度
    print(json.dumps(vector[:10], indent=2))

接下来我们运行后,复制一条我们前面生成的 base64,输入后查看输出:

Dimensions: 1024
First 10 values: 
[
  0.025365496054291725,
  0.03432830423116684,
  0.028960606083273888,
  -0.03395381569862366,
  -0.028785843402147293,
  -0.016377722844481468,
  0.044839005917310715,
  -0.09951463341712952,
  -0.011097405105829239,
  -0.007508536335080862
]

VectorStoreIndex 返回的对象数据结构具体如下:

index:VectorStoreIndex
├── index_struct:索引映射
│   └── 向量 ID → 节点 ID
├── vector_store:向量存储
│   ├── 节点 ID → embedding 数字列表
│   └── 节点对应的元数据
├── docstore:节点存储
│   └── 节点 ID → 节点对象(正文、元数据等)
└── embed_model:用于编码文档和查询的模型

所以我们可以基于上面返回的 index,来实现后续 RAG 的一系列操作。

2. 直接从 documents 中构建索引

上面我们先将 Document 切分成 Node,再将 nodes 传给 VectorStoreIndex。这种方式便于在生成向量之前查看切分结果,或者对节点进行过滤、修改元信息等处理。

如果不需要单独处理切分后的节点,也可以使用 VectorStoreIndex.from_documents(),直接从文档构建索引。下面沿用前面已经创建的 documents 和 embed_model:

from llama_index.core import VectorStoreIndex
from llama_index.core.node_parser import SentenceSplitter

index = VectorStoreIndex.from_documents(
    documents,
    embed_model=embed_model,
    transformations=[
        SentenceSplitter(chunk_size=100, chunk_overlap=50),
    ],
)

transformations 用来指定构建索引前执行的转换步骤。这里传入与上一节相同的 SentenceSplitter,让框架内部完成文本切分;如果省略该参数,则使用全局 Settings.transformations 中配置的转换流程。在本文使用的 llama-index-core 0.14.25 中,未修改全局配置时,默认使用 SentenceSplitter,参数为 chunk_size=1024、chunk_overlap=200,单位都是 token,而不是字符。

默认转换流程等价于:

transformations=[
    SentenceSplitter(chunk_size=1024, chunk_overlap=200),
]

前面单独创建的 splitter = SentenceSplitter(chunk_size=100, chunk_overlap=50) 不会自动成为全局配置。因此,省略 transformations 不会沿用这个切分器。若希望使用前面的 100 / 50 设置,需要像本节示例一样显式传入,或事先修改 Settings.transformations。默认转换流程只负责切分,向量化仍由后续索引构建阶段调用 embed_model 完成。

直接从 documents 构建索引并不是跳过切分,而是将切分过程交给框架执行。 两种写法的主要流程一致:

Documents → 切分为 Nodes → 生成 Embedding 向量 → 构建索引
构建方式适用场景
VectorStoreIndex(nodes=nodes, embed_model=embed_model)已经有节点,或者需要在向量化之前检查、过滤、修改节点
VectorStoreIndex.from_documents(documents, ...)从文档开始,使用指定或默认的转换流程直接完成索引构建

这两种方式是本篇基础教程中最需要掌握的入口,可以根据是否需要单独处理节点选择其中一种,不需要依次执行两次。对于相同文档分别运行这两段构建代码,通常会分别调用 Embedding 接口,不会自动复用另一份索引的向量。

除上述常见的两种构建索引的方式之外,还有许多其他的构建方式来适用于其他的业务场景,读者可以根据需要自行去阅读官方文档。

持久化存储

通过上述进行向量化索引后,在没有配置外部向量数据库的情况下,向量、文本块节点以及索引映射默认都保存在内存中。程序退出后,这些数据不会自动保留。如果每次启动都重新读取文档、切分并调用 Embedding 接口,不仅耗时,还会产生重复的接口调用费用。因此,我们可以将构建好的索引持久化到本地,后续直接加载使用。

1. 将索引保存到本地

在前面构建 index 的代码后面,添加如下代码即可:

index.set_index_id("nixos")
index.storage_context.persist(persist_dir="./storage")

首先为当前的 index 设置一个 id。 storage_context 是索引使用的存储上下文,管理文档存储、索引存储和向量存储等组件。调用它的 persist() 方法,可以将默认内存存储中的数据保存到指定目录,而不是只保存某一个向量字段。

这里的 ./storage 相对于运行程序时的工作目录。例如,我们在 05-LlamaIndex 目录下运行脚本,数据就会保存在该目录下的 storage 文件夹中。

当前默认配置下,使用上述代码后,保存的目录结构如下:

image.png

其中,与我们当前文本向量索引最相关的是以下三个文件:

文件保存的内容
docstore.json文本块节点的正文、元信息和节点关系等
index_store.json索引的 ID、类型以及向量 ID 到节点 ID 的映射等
default__vector_store.json节点对应的浮点数向量,以及向量存储使用的元信息等

image__vector_store.json 和 graph_store.json 来自默认存储上下文中的其他存储组件。在当前仅处理文本的示例中,它们可以为空,不代表我们额外生成了图片向量或知识图谱。

持久化保存的是已经构建好的数据,这一步不会再次调用 Embedding 接口。上述文件组成一个完整的存储目录,后续加载时应保留整个目录。

2. 重新加载索引

接下来,我们可以另写一个脚本来验证加载过程。

from llama_index.core import load_index_from_storage
from llama_index.core import StorageContext
from llama_index.embeddings.openai import OpenAIEmbedding
from dotenv import load_dotenv

load_dotenv()

embed_model = OpenAIEmbedding(model_name="qwen3.7-text-embedding", embed_batch_size=5)
storage_context = StorageContext.from_defaults(persist_dir="./storage")
index = load_index_from_storage(
    storage_context=storage_context, embed_model=embed_model, index_id="nixos"
)
print("索引 ID:", index.index_id)
print("已加载向量数量:", len(index.vector_store.data.embedding_dict))

StorageContext.from_defaults() 负责恢复存储组件,load_index_from_storage() 则根据保存的索引信息恢复相应的索引对象。加载过程中会使用已经保存的文档向量,不会为这些文本块重新调用 Embedding 接口。

不过,模型对象本身及其 API 密钥不会由这些存储文件恢复,因此仍需重新配置 embed_model。后续检索时,用户的问题也需要转换成向量,再与保存的文档向量进行比较。

加载时应使用与构建索引时相同的 Embedding 模型及向量维度配置。不同模型即使输出的维度相同,其向量也不能直接混用。 如果更换模型,应重新生成文档向量并保存索引。

运行后结果如下,可以看到也是被成功读取到了:

image.png

向量检索

前面我们已经完成了文档读取、切分、向量化以及索引的保存和加载。接下来,我们要根据用户的问题,从索引中找到相关的文本块。这一步称为检索(Retrieval)。

检索返回的是原始文档中的相关片段,而不是聊天模型生成的答案。先单独观察检索结果,可以判断知识库是否找回了回答问题所需的材料,再决定如何将这些材料交给聊天模型。

1. 创建 Retriever 并执行检索

沿用上一节从本地加载的 index,添加如下代码:

retriever = index.as_retriever(similarity_top_k=3)
results = retriever.retrieve("为什么选择 NixOS?")

for i, result in enumerate(results, start=1):
    print(f"\n===== 检索结果 {i} =====")
    print("节点 ID:", result.node.node_id)
    print("相似度分数:", result.score)
    print("正文:", result.node.text)
    print("元信息:", result.node.metadata)

index.as_retriever() 根据索引创建检索器,similarity_top_k=3 表示最多返回 3 个候选文本块(按照相似度从高到低)。仅创建检索器不会发送 Embedding 请求,执行 retrieve() 时才会编码查询并进行检索。

运行代码后,检索结果如下:

===== 检索结果 1 =====
节点 ID: 531edc03-4647-4bca-8df2-ab2a08b05755
相似度分数: 0.7450133282241779
正文: NixOS 的优点主要就是集中在可重复性和软件隔离上。可重复性即如果 `configuratoin.nix` 中的内容是相同的且为 pure 的,那么同一份配置文件会产生完全一样的系统。
元信息: {'file_path': 'data\\index.md', 'file_name': 'index.md', 'file_type': 'text/markdown', 'file_size': 8588, 'creation_date': '2026-09-23', 'last_modified_date': '2026-09-23'}

===== 检索结果 2 =====
节点 ID: 5405ace3-7e8b-4423-ba47-1e99a4189d79
相似度分数: 0.7446583632063953
正文: 因此 NixOS 也被称为滚不挂的系统(不作死的情况)。

实际上 NixOS 经常被人诟病占用硬盘,上手难度高,软件打包困难等。
元信息: {'file_path': 'data\\index.md', 'file_name': 'index.md', 'file_type': 'text/markdown', 'file_size': 8588, 'creation_date': '2026-09-23', 'last_modified_date': '2026-09-23'}

===== 检索结果 3 =====
节点 ID: 8a1403f9-e272-4456-b633-16b1d8b5b570
相似度分数: 0.7301938186802789
正文: ---
title: "皈依 NixOS"
pubDate: 2023-09-14
categories:
  - Linux
  - NixOS
---

用了几天时间折腾 NixOS,现在大概是能满足日常日常使用了,几天使用下来体验还算不错。
元信息: {'file_path': 'data\\index.md', 'file_name': 'index.md', 'file_type': 'text/markdown', 'file_size': 8588, 'creation_date': '2026-09-23', 'last_modified_date': '2026-09-23'}

检索过程可以概括为:

用户问题
   ↓ 使用索引配置的 Embedding 模型
查询向量
   ↓ 与已保存的文档向量计算相似度
选取分数最高的若干条向量记录
   ↓ 根据 ID 找回节点
返回文本块、元信息和相似度分数

2. 理解检索结果 NodeWithScore

results 是一个列表,每个元素是 NodeWithScore 对象。它将一个节点及其检索分数组合在一起:

results:list[NodeWithScore]
├── NodeWithScore
│   ├── node:检索到的节点
│   │   ├── node_id:节点 ID
│   │   ├── text:文本块正文
│   │   └── metadata:文件路径等元信息
│   └── score:检索分数
└── ...

因此,读取正文用 result.node.text,读取分数用 result.score。节点仍然保留读取和切分阶段的元信息,后续可以利用这些信息展示来源文件、页码或其他引用信息,具体有哪些字段取决于 Reader 和文档类型。

3. 理解 similarity_top_k 和相似度分数

similarity_top_k 控制候选结果的数量,而不是相关性的最低要求。例如,similarity_top_k=3 表示最多返回 3 条结果,并不保证每一条都能回答问题。当可检索节点少于 3 个时,实际返回数量也可能更少。

在本文的默认向量存储和默认查询模式下,使用余弦相似度比较查询向量与文档向量,结果按分数从高到低返回。余弦相似度衡量两个向量方向的接近程度,分数越高,表示在当前模型的向量空间中越接近。

相似度分数不是回答正确率,也不是“这条文本相关的概率”。 即使某个结果分数较高,也需要查看正文,确认它是否包含回答问题所需的信息。如果换用其他向量数据库或检索模式,分数的计算方式和含义也可能不同,不能直接套用同一套判断标准。

可以保持同一个问题,分别设置 similarity_top_k=1、3 和 5,比较返回的文本块数量与内容。这里只需重新创建检索器,不需要重新构建文档索引:

query = "为什么选择 NixOS?"

for top_k in [1, 3, 5]:
    retriever = index.as_retriever(similarity_top_k=top_k)
    results = retriever.retrieve(query)

    print(f"\n===== top_k={top_k},实际返回 {len(results)} 条 =====")
    for result in results:
        print("节点 ID:", result.node.node_id)
        print("分数:", result.score)
        print("正文:", result.node.text)

上述代码会执行三次检索,通常也会分别编码查询。它适合用来观察参数的影响,实际应用中应根据文档内容和问题类型选择合适的候选数量。返回太少可能遗漏必要材料,返回太多则可能引入无关片段,并增加后续生成回答时的上下文长度。

最后需要区分“按向量相似度排序”和“重排(Rerank)”。本节的普通向量检索已经按照相似度返回候选结果,但没有额外调用独立的重排模型。重排通常是在初步召回后,再对问题与候选文本的相关性进行评价,重新调整顺序,属于后续检索优化的内容。不作为基础使用讲解。

至此,我们已经可以从加载后的知识库中找回相关文本。下一步再将问题与检索结果交给聊天模型,就可以构建生成回答的 RAG 流程。


下一篇文章
LangChain4j 框架的学习