基本介绍
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 的论文作为测试:

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

可以看到输出 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 的列表。例如我现在准备了一张斯大林同志的肖像:

然后执行代码:
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 类 | 格式说明 |
|---|---|---|
.csv | PandasCSVReader | 逗号分隔值(表格数据) |
.docx | DocxReader | Microsoft Word 文档 |
.epub | EpubReader | EPUB 电子书 |
.hwp | HWPReader | 韩文文字处理器文档 |
.ipynb | IPYNBReader | Jupyter Notebook |
.jpeg / .jpg / .png / .gif / .webp | ImageReader | 图片文件 |
.mbox | MboxReader | MBOX 邮件归档 |
.md | 通用文本读取逻辑 | 默认按纯文本读取;可通过 file_extractor 指定 MarkdownReader |
.mp3 / .mp4 | VideoAudioReader | 音频与视频 |
.pdf | PDFReader | PDF 文档 |
.ppt / .pptm / .pptx | PptxReader | Microsoft PowerPoint |
.xls / .xlsx | PandasExcelReader | Excel 表格 |
| 未匹配的扩展名 | 通用文本读取逻辑 | 按文本读取,默认编码为 UTF-8;并不自动实例化 FlatReader |
-
依赖是惰性加载的。 默认专用 Reader 映射依赖
llama-index-readers-file包。如果未安装该包,也没有通过file_extractor指定解析器,则会回退到通用文本读取逻辑,但不能正确解析 PDF 等二进制格式。 -
映射表可能随版本变化。 不同版本的 LlamaIndex 支持的扩展名和对应的 Reader 类可能略有差异,建议以你当前安装版本的源码为准。这里以 0.14.25 版本整理。
-
自定义覆盖。 通过
SimpleDirectoryReader的file_extractor参数,你可以覆盖或扩展这张默认映射表。
各专用 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())

如上所示,可读性仍然比较差,很多乱码。
(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())

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)
结果如下:

4. 自定义 Reader
当官方或社区提供的 Reader 无法满足业务需求时(例如需要对接公司内部的私有 API、解析某种罕见的私有文件格式,或者从特定的消息队列中实时抓取数据),我们可以通过自定义 Reader 来实现扩展。
在 LlamaIndex 中,所有的 Reader 都继承自抽象基类 BaseReader。自定义一个 Reader 非常简单,核心只需两步:
-
继承
BaseReader类。 -
重写(Override)
load_data方法,并在该方法内部组装逻辑,最终返回一个包含Document对象的列表。
我们以读取 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)

文本切分与解析
虽然上面我们已经完成了对文档的读取,但是读进来的 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,可以看到结果如下:

红线部分是重叠的部分。
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])
结果如下:

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

所以这样使得最终分割的结果,既能严格控制在 chunk_size 的容量限制内,又能最大程度地保留自然语言的语义完整性。
除了最基础的 TokenTextSplitter 和 SentenceSplitter,LlamaIndex 还提供了许多针对特定格式或高级场景的切分工具。这里仅作简单列举,大家在实际开发中可以根据业务数据类型,查阅官方文档进行深入研究与测试:
- SemanticSplitterNodeParser(语义切分器):不按固定长度切分,而是利用 Embedding 模型计算相邻句子的“语义相似度”,在话题发生切换(断层)的位置进行物理截断,极大程度保证文本块的主题聚集。
- MarkdownNodeParser / HTMLNodeParser(结构化解析器):直接利用文档原有的标记语法(如 Markdown 的
#标题,或 HTML 的<p>标签)进行切分,天然保留原作者写作时的章节逻辑。我们在处理index.md时,其实也非常适合用这个工具。 - SentenceWindowNodeParser(句子窗口解析器):将单句作为节点,并在 Metadata 中保留前后若干句组成的窗口。检索后还需通过
MetadataReplacementPostProcessor等方式将窗口作为生成上下文,才能实现“小块检索,大块生成”。 - CodeSplitter(代码切分器):专门用于解析程序源代码。结合 AST 语法树按函数(Function)或类(Class)来切块,避免代码逻辑被强行腰斩。
构建索引与向量化
前面已经完成了将各种数据读取成程序可处理的文档,并完成了对文档的分割,接下来要做的,就是将分割好的文本块 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)  配置过程时,软件的主要从 [NixOS Package Search](https://nixos.org/nixos/packages.",
"file_path: data\\index.md dotfile)  配置过程时,软件的主要从 [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 文件夹中。
当前默认配置下,使用上述代码后,保存的目录结构如下:

其中,与我们当前文本向量索引最相关的是以下三个文件:
| 文件 | 保存的内容 |
|---|---|
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 模型及向量维度配置。不同模型即使输出的维度相同,其向量也不能直接混用。 如果更换模型,应重新生成文档向量并保存索引。
运行后结果如下,可以看到也是被成功读取到了:

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