RAG 索引持久化:为什么 FAISS 要存成两个文件?

从0手敲RAG学习日记 · 索引篇

上一篇把 RAG 基础流水线里的 10 个函数串了一遍,今天继续深入一个更实际的问题:索引建好之后,要不要存到磁盘?

一、索引是什么

  1. 知识库被切成很多小段(chunks)。
  2. 每段用 M3E 变成一串数字(向量)。
  3. 索引 = 把这些向量整理好,方便按「相似度」查找。

一句话:没有索引,每次提问都要把问题向量和所有块逐个比一遍,块一多就慢;有索引,FAISS 用专门结构做近邻搜索,很快取出 Top-K。

和数据库索引的相似点

维度数据库索引向量索引
目的加快查找加快查找
查什么按字段精确查按语义相似度查向量

二、为什么要保存索引

建索引的成本很高,完整流程是:读文档 → 切块 → M3E 编码(最慢)→ 写入 FAISS。

若每次 python main.py 都重做一遍,启动要等很久。持久化的目的就一句话:把算好的向量索引落到磁盘,下次启动直接读,跳过重新编码。

三、磁盘上存了什么?为什么是两个文件

FAISS 只存向量和下标,不存原文。检索时还要用下标找回 chunks[i] / sources[i],所以必须另存元数据:

文件内容
index_store/faiss.index向量索引(二进制)
index_store/meta.jsonchunks、sources,以及当时的 chunk_size / chunk_overlap

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

注意:meta 里记下切块参数,是为了防止「向量和下标对不上新切块」。你改了 .env 里的 chunk_size / chunk_overlap 后,旧索引会失效,load_index 返回 None,强制重建。

四、关键函数拆解

1. save_index —— 保存索引

落盘两样东西:faiss.index 是向量索引本身,meta.json 是 chunks + sources,因为 FAISS 不存原文。

# ============================================================
# 索引持久化(可选,加速下次启动)
# ============================================================
def save_index(
    index: faiss.IndexFlatIP,
    chunks: list[str],
    sources: list[str],
    index_dir: Path | None = None,
) -> Path:
    """
    落盘两样东西:
    - faiss.index : 向量索引本身
    - meta.json   : chunks + sources(因为 FAISS 不存原文)
    """
    index_dir = index_dir or INDEX_DIR
    index_dir.mkdir(parents=True, exist_ok=True)
    # 写 FAISS 二进制索引
    faiss.write_index(index, str(index_dir / "faiss.index"))
    # 写文本元数据,保证下标还能对回原文
    meta = {
        "chunks": chunks,
        "sources": sources,
        # 记下切块参数:参数变了就视为旧索引,强制重建
        "chunk_size": CHUNK_SIZE,
        "chunk_overlap": CHUNK_OVERLAP,
    }
    (index_dir / "meta.json").write_text(
        json.dumps(meta, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    return index_dir

功能:保存索引,faiss.write_index + 写 meta.json。

2. load_index —— 加载本地索引

任一文件缺失,或者切块参数和当前配置不一致,就返回 None,让上层重新 build。

def load_index(
    index_dir: Path | None = None,
) -> tuple[faiss.IndexFlatIP, list[str], list[str]] | None:
    """
    从磁盘读回索引。
    任一文件缺失则返回 None,调用方应改为重新 build。
    """
    index_dir = index_dir or INDEX_DIR
    index_path = index_dir / "faiss.index"
    meta_path = index_dir / "meta.json"
    if not index_path.exists() or not meta_path.exists():
        return None
    meta = json.loads(meta_path.read_text(encoding="utf-8"))
    # 切块参数与当前配置不一致 → 当作没有索引,让上层重建
    if meta.get("chunk_size") != CHUNK_SIZE or meta.get("chunk_overlap") != CHUNK_OVERLAP:
        return None
    index = faiss.read_index(str(index_path))
    return index, meta["chunks"], meta["sources"]

功能:加载本地索引,缺文件或参数不一致就返回 None。

3. build_rag_index —— 一键构建整条索引流水线

把加载模型、切块、创建索引合并成一个方法;构建完成后可选落盘。

def build_rag_index(
    data_dir: Path | None = None,
    model: SentenceTransformer | None = None,
    *,
    persist: bool = True,
    index_dir: Path | None = None,
) -> tuple[SentenceTransformer, faiss.IndexFlatIP, list[str], list[str]]:
    """
    把前面步骤串起来:
    加载 M3E
    → build_corpus(读文档 + 切块)
    → encode_texts(块 → 向量)
    → build_faiss_index(向量 → 可检索索引)
    → 可选 save_index
    返回四件套,后续 search() 全都要用:
    model, index, chunks, sources
    """
    # ① 模型:可外部传入(避免重复加载),否则读本地
    model = model or load_m3e_model()
    # ② 语料:所有块 + 来源文件名
    chunks, sources = build_corpus(data_dir)
    if not chunks:
        # ③a 空知识库:仍建一个同维度空索引,方便以后上传文档再 rebuild
        dim = model.get_sentence_embedding_dimension()
        index = faiss.IndexFlatIP(dim)
    else:
        # ③b 有内容:向量化 + 建索引
        embeddings = encode_texts(model, chunks)
        index = build_faiss_index(embeddings)
    # ④ 是否写到 index_store/
    if persist:
        save_index(index, chunks, sources, index_dir)
    return model, index, chunks, sources

功能:构建并可选落盘,persist=True 会在本地保存索引。

4. load_or_build_rag_index —— 先加载,没有再构建

多一步判断,不用每次都重复构建。服务启动时常用:磁盘已有索引就直接加载,没有就重新 build。

def load_or_build_rag_index(
    data_dir: Path | None = None,
    model: SentenceTransformer | None = None,
    index_dir: Path | None = None,
) -> tuple[SentenceTransformer, faiss.IndexFlatIP, list[str], list[str]]:
    """
    启动服务时常用:
    - 磁盘已有索引 → 直接加载(快)
    - 没有 → 重新 build_rag_index(慢一次)
    """
    model = model or load_m3e_model()
    loaded = load_index(index_dir)
    if loaded is not None:
        index, chunks, sources = loaded
        return model, index, chunks, sources
    return build_rag_index(data_dir, model, persist=True, index_dir=index_dir)

功能:多一步判断,优先读盘,失败再走创建。

5. _refresh_state —— 刷新内存里的 RAG 四件套

服务启动、上传 / 删除文档、手动重建索引时都会调用它。它负责两件事:一是重建或加载索引,二是把磁盘文件列表和 SQLite documents 表对齐。

def _refresh_state(
    *,
    rebuild: bool = False,
    model=None,
) -> None:
    """
    刷新内存里的 RAG 四件套,并同步 documents 表。
    什么时候调用?
    - 服务启动(lifespan)
    - 上传 / 删除文档之后
    - 手动「重建索引」
    """
    # ① 尽量复用已在内存里的 model
    model = model or state.get("model")
    # ② 构建或加载索引
    if rebuild or model is None:
        model, index, chunks, sources = build_rag_index(DATA_DIR, model, persist=True)
    else:
        model, index, chunks, sources = load_or_build_rag_index(DATA_DIR, model)
    # ③ 写回全局 state,后续 /search、/ask 都从这里取
    state.update(
        {
            "model": model,
            "index": index,
            "chunks": chunks,
            "sources": sources,
        }
    )
    # ④ 磁盘文件列表 ↔ SQLite documents 表对齐
    db.sync_documents_from_disk(list_documents(DATA_DIR))

上传 / 删除文档时会调用 _refresh_state(rebuild=True),等于重新 build 并再次 save_index,保证磁盘索引和当前知识库一致。

五、完整流程串起来

load_or_build_rag_index()
  ├─ load_index 成功? → 直接用(快)
  └─ 失败 / 切块参数变了? → 再走 build_rag_index 并重新 save

启动优先读盘,日常刷新用 load_or_build_rag_index;上传 / 删除文档后用 _refresh_state(rebuild=True)。这就是 RAG 索引创建以及保存的完整流程。


写在最后

索引持久化是 RAG 服务化的关键一步。它让「启动慢」变成「启动快」,让「每次重启都重新编码」变成「一次编码、多次复用」。

下一篇会继续聊文档管理:上传、删除、重建索引怎么和 FastAPI 接口结合起来。如果这篇对你有帮助,欢迎点赞收藏,后续更新我们接着聊 RAG 服务化。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐