XiaoMATE 小美同学 RAG 优化实战实践

1. 概述

前两天有一个 陈ying 大佬(他跟我一样,也在自己做一个与XiaoMATE类似的、复刻数字版本全记忆的自己的数字人)在微信上私信聊起关于个人助手及记忆的事情,后来聊天如何做好个人的记忆的问题上,其中很大一个点就在于心情札记(https://mp.weixin.qq.com/s/lI5nASpTs6EtSiJKaSBz7A)中的人物关系功能上,这部分也是我前前后后、反反复复一直未能把 XiaoMATE 调教到理想状态的一个关键问题点。

我从历史的心情札记的记录中已经提取出来很多不同的人物以及该人物与我的关系,目标中希望做到:“我问他去年奶奶生日的时候,姐姐在做什么”。

RAG-20261025

但是当我去问一些问题的时候,RAG查询出来的记录结合现有的prompt提示词,返回的结果经常会神一刀、鬼一刀。有一些问题即使在后台RAG召回测试的时候有命中,并且 Rerank 重排序也都正确的筛选出来,但是放到 LLM 返回的时候,组织出来的语言依然不理想,很是头疼。

RAG-20261106151630_661_28

这个周末在家就再把 XiaoMATE 的RAG功能再整理一下,希望可以再来优化优化。以下是这周的一些实践与尝试。

1.1 个人札记与记忆问答场景

XiaoMATE 的 **Journal(札记)**模块是用户记录日常生活、心情和想法的地方。用户可以随时写下当天的见闻、情绪变化或重要事项,系统会自动从中提取人物(JournalPerson)、组织(JournalOrganization)和人物提及(JournalPersonMention)关系,构建用户的个人记忆图谱。

在后续的语音或文字对话中,用户会自然地基于这些积累的记忆发起提问:

  • “上周三我提到准备换工作,当时我是怎么想的?”
  • “上次见小王他说是哪个公司的来着?”
  • “我今年心情最低落的是什么时候?”

这类问题的共同特点是:需要 AI 从用户长期积累的大量个人数据中,快速、准确地召回相关上下文,再组织成自然语言回答。这正是 **RAG(检索增强生成)**技术在 XiaoMATE 中的核心应用场景。

1.2 场景中遇到的实际问题

在上述札记问答场景中,系统运行暴露出一系列性能和体验问题:

问题一:首次查询延迟过高

当用户积累了几百条札记后,首次触发记忆检索需要从数据库拉取全部数据、逐条计算 Embedding、构建 FAISS 索引,整个过程可能耗时 20~30 秒。在语音对话场景中,用户说完”上次提到的小王是哪个公司的?”后,若需要等待半分钟才能得到回应,交互体验将完全断裂。

问题二:批量导入时的无效计算

用户一次性导入 100 条历史日记,后台自动提取人物和组织信息。此过程会触发数十次数据变更信号。若每次变更都立即重建索引,系统将在后台空转数分钟。而现实情况是,用户导入完数据可能就去忙别的事了,索引建完即被 LRU 淘汰,计算资源被大量浪费。

问题三:重复查询的冗余开销

用户围绕同一话题连续追问时,例如先问”这款产品的保修期是多久?”再问”那维修点在哪?”,每次都要重新执行完整的 Embedding + FAISS + Rerank 流程,响应时间无法缩短。

问题四:数据更新后索引不同步

札记内容修改或删除后,若向量索引未及时更新,AI 可能检索到已不存在或过期的内容,导致回答错误,降低用户信任。

问题五:多用户环境下的资源与隐私

每个用户都有独立的个人记忆,预加载所有用户的索引会导致内存爆炸;同时用户 A 的私人札记绝不能出现在用户 B 的检索结果中。

这些问题并非 XiaoMATE 独有,而是 RAG 系统在生产环境中的共性挑战。

1.3 扩展到常见的 RAG 痛点

将上述问题放到更广泛的 RAG 应用场景中,可以归纳为以下五类典型痛点:

痛点具体表现典型影响
检索延迟高每次查询经历 Embedding → 向量检索 → Rerank 全流程,首次查询还需建索引用户等待数秒至数十秒,实时对话体验差
Embedding 成本高全量重建时逐条调用 Embedding API,HTTP 往返次数与文档数成正比耗时占比可达 70%,消耗大量 API 额度或本地算力
无效重建频繁批量导入、后台提取时每次变更都触发重建,但用户尚未开始对话大量计算被浪费,索引建完即被 LRU 淘汰
数据一致性差数据库已更新但向量索引未同步,检索到过期内容回答不准确,降低系统可信度
多用户资源竞争并发重建导致内存/CPU 峰值,预加载所有用户又内存爆炸系统不稳定,扩容成本高

1.4 业界常见优化方向

针对上述痛点,业界已形成较为成熟的优化方向:

  1. 多级缓存:在检索结果、向量索引、Embedding 向量三个层面分别设置缓存,减少重复计算
  2. 懒加载与预加载结合:高频数据预加载、低频数据懒加载,平衡内存与延迟
  3. 增量更新:追踪数据变更,仅对新增/修改的文档重新 Embedding 和索引,避免全量重建
  4. 批量与并发:合并短文档批量 Embedding,并发发送请求以提升吞吐量
  5. 冷热分离与索引结构升级:大数据量时采用近似索引(HNSW/IVF),热数据快速更新、冷数据定期合并

1.5 本版本采取的方案及原因

XiaoMATE 面向个人语音助手场景,其 RAG 需求具有以下特征:

  • 用户规模中等:单实例服务数十至数百用户,非海量并发
  • 数据量级可控:单用户记忆数百至数千条,知识库数量通常 < 20
  • 实时性要求高:语音交互对延迟敏感,需尽量降低首次查询等待
  • 数据来源多样:既有用户主动维护的知识库,也有系统自动提取的个人记忆

基于以上特征,本项目在当前版本中采用 “三级缓存 + 懒加载/预加载混合 + 写时失效” 的总体策略:

层级/机制实现方式解决的核心问题
第一层:检索结果缓存缓存 query → results 映射,TTL 5 分钟重复查询的冗余开销
第二层:向量索引内存缓存Journal LRU 10 人 / KB LRU 3 个首次查询后的后续查询延迟
第三层:本地 FAISS 索引文件按用户/知识库独立目录持久化进程重启后的全量重建问题
写时失效数据变更时清除缓存,不立即重建无效重建频繁 + 数据一致性
用户/知识库隔离独立索引、独立缓存键空间隐私安全 + 检索准确性

并在附录中规划了短期、中期、长期的分阶段优化路线(详见第 A 章):

阶段核心措施目标
短期小批量 Embedding + 只失效不立即重建重建耗时从数十秒降至 2~5 秒
中期Embedding 本地缓存 + 并发请求 + 增量更新历史数据零重复计算,重建降至 秒级/毫秒级
长期冷热索引分离 + HNSW/IVF 索引结构升级支撑万级以上文档量,查询 O(n) → O(log n)

2. 三层缓存架构总览

┌─────────────────────────────────────────────────────────────┐
│  第一层:检索结果缓存 (RetrievalCache)                        │
│  - 缓存 key: kb_id + query 的 MD5 哈希                      │
│  - 容量: 500 条, TTL: 5 分钟                                │
│  - 作用: 避免相同查询重复执行向量相似度计算                  │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│  第二层:向量索引内存缓存                                     │
│  ├─ JournalMemoryStore: 用户级 FAISS 索引, LRU 10 人       │
│  └─ VectorStoreManager: 知识库级 FAISS 索引, LRU 3 个      │
│  - 作用: 避免重复从磁盘加载或从数据库重建                    │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│  第三层:本地 FAISS 索引文件                                  │
│  - Journal: media/journal_vectors/{user_id}/faiss_index/   │
│  - KB: 由 kb.faiss_index_path 指定目录                      │
│  - 作用: 进程重启后快速恢复索引, 避免全量重建                │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│  数据源:数据库                                               │
│  - JournalEntry / JournalPerson / JournalOrganization       │
│  - KnowledgeChunkModel (知识库切片)                         │
└─────────────────────────────────────────────────────────────┘

3. 第一层:检索结果缓存 (RetrievalCache)

文件: engine/rag/retrieval_cache.py

3.1 工作原理

  • 缓存键: {kb_id}:{query_md5_前16位},query 经过归一化(去首尾空格、转小写)
  • 命中策略: 精确匹配,LRU 淘汰 + TTL 过期双重控制
  • 写入时机: 每次向量检索完成后,将结果写入缓存
  • 清除时机: 知识库/个人记忆数据发生变更时主动清空对应 kb_id 的缓存

3.2 关键参数

参数默认值说明
max_size500最大缓存条目数
ttl300 秒单条缓存存活时间

3.3 使用场景

该缓存主要服务于 LLM 对话中的重复查询场景。例如用户在连续对话中围绕同一话题提问,或系统内部多次召回同一知识库内容时,可直接命中缓存,省去一次完整的 Embedding + FAISS 搜索 + Rerank 流程。


4. 第二层:向量索引内存缓存

系统维护两套独立的内存缓存,分别服务于**个人记忆(Journal)和知识库(Knowledge Base)**两个场景。

4.1 JournalMemoryStore — 个人记忆向量索引

文件: engine/rag/journal_memory.py

核心特性

特性说明
隔离粒度按 user_id 隔离,每个用户独立一个 FAISS 索引
缓存容量最多缓存 10 个活跃用户的索引(MAX_CACHE_USERS = 10)
索引类型FAISS IndexFlatL2,欧氏距离
持久化路径media/journal_vectors/{user_id}/faiss_index/

三层回退加载链

当调用 journal_memory_store.search(user_id, query) 时,按以下优先级获取向量索引:

  1. 内存缓存命中: 检查 _vectorstores OrderedDict,命中后移至队尾(LRU 更新)
  2. 本地文件加载: 检查 index.faiss 是否存在,存在则 FAISS.load_local() 加载到内存
  3. 数据库全量重建: 从 JournalEntry、JournalPerson、JournalOrganization 拉取全部数据,逐条 Embedding 后构建新索引,同时写入本地文件和内存缓存

数据建模

单条 JournalEntry 会被转换为 1~N 个 Document:

  • 主内容 Document: 包含日期、心情、标题、正文(过长时按句子边界切片)
  • 人物提及 Document: 每条 person_mention 生成一个独立 Document,增强人物相关查询的召回率

人物档案和组织档案也各自转为独立 Document,方便直接查询某人物/组织的信息。

Embedding 策略

采用逐条 Embedding 而非批量:

for i, text in enumerate(texts):
    vec = embeddings.embed_query(text)  # 单条调用

原因:部分 Embedding 服务端(如 llama.cpp)对单请求总 token 数有限制(如 512 tokens),OpenAIEmbeddings.embed_documents 内部可能合并多条文本导致超限。逐条调用确保每条请求不超过单条文本长度。

4.2 VectorStoreManager — 知识库向量索引

文件: engine/rag/vectorstore_manager.py

核心特性

特性说明
隔离粒度按 kb_id 隔离,每个知识库独立一个 FAISS 索引
缓存容量最多缓存 3 个知识库(max_cache_size = 3)
索引类型FAISS,由 FAISS.from_documents() 自动构建
持久化路径由 LLmKnowledgeBaseModel.faiss_index_path 字段指定

加载策略

与 JournalMemoryStore 不同,VectorStoreManager 采用预加载 + 按需加载的混合策略:

  • 启动预加载: xiaoluo/asgi.py 在应用启动后延迟 2 秒,异步调用 preload_all_vectorstores(),并行构建所有知识库的向量索引
  • 按需加载: 检索时若缓存未命中,调用 load_vectorstore(kb_id) 从数据库构建,并加入 LRU 缓存

5. 第三层:本地 FAISS 索引文件

5.1 Journal 索引文件

  • 路径: media/journal_vectors/{user_id}/faiss_index/
  • 文件内容: index.faiss(向量索引)+ index.pkl(docstore 映射)
  • 生命周期:
    • 创建:sync_user_journals() 成功构建索引后自动保存
    • 删除:invalidate_cache() 或 _remove_user_index() 时级联删除

5.2 知识库索引文件

  • 路径: 由 LLmKnowledgeBaseModel.faiss_index_path 配置
  • 保存时机: _build_vectorstore_from_db() 构建完成后自动保存
  • 注意: 若该字段为空,则不保存本地文件,每次只能从数据库重建

6. 缓存失效机制

6.1 Journal 信号驱动失效

文件: xiaoluo/signals/journal_signals.py

采用 Django 的 post_save / post_delete 信号机制,监听以下 4 个模型的变更:

模型信号触发场景
JournalEntrypost_save / post_delete札记创建、修改、删除
JournalPersonpost_save / post_delete人物档案变更
JournalOrganizationpost_save / post_delete组织档案变更
JournalPersonMentionpost_save / post_delete人物提及记录变更

信号处理器调用 _invalidate_journal_cache(user_id),执行以下操作:

  1. 从 JournalMemoryStore 的内存缓存中移除该用户的 FAISS 索引
  2. 删除该用户的本地索引文件目录(shutil.rmtree)
  3. 下次该用户发起检索时,自动触发数据库全量重建

设计意图: 采用”失效”而非”立即重建”,是因为 Journal 数据量通常不大,但变更可能频繁。若每次保存都重建索引,会造成大量冗余计算。懒加载策略确保只有真正需要检索时才付出构建成本。

6.2 知识库主动重建失效

文件: engine/rag/knowledge_service.py

知识库变更(上传文件、增删改 chunk)时,由 KnowledgeBaseService 主动调用:

  1. retrieval_cache.clear(kb.kb_id) — 清空该知识库的检索结果缓存
  2. vectorstore_manager.rebuild_vectorstore(kb.kb_id) — 从数据库重新构建向量索引,替换内存缓存

6.3 增量同步时的级联清除

文件: engine/rag/journal_memory.py 中的 incremental_sync()

Journal 索引重建完成后,会进一步清除 LLM 驱动层的检索缓存:

strategy.retrieval_cache.clear(f"journal_{user_id}")

这确保了即使同一查询在短时间内重复发起,也不会拿到旧数据。


7. 懒加载与预加载策略对比

维度JournalMemoryStore(个人记忆)VectorStoreManager(知识库)
加载时机纯懒加载:首次 search() 时才构建预加载 + 按需:启动时预加载所有,运行时按需补充
适用原因用户维度多(可能上百人),预加载内存爆炸知识库数量可控(通常 < 20),预加载提升首次查询体验
首次查询延迟较高(需从数据库拉取 + Embedding + 建索引)较低(启动时已预热)
缓存淘汰LRU 10 人LRU 3 个

8. 线程安全与并发控制

8.1 锁机制汇总

组件锁类型作用范围
RetrievalCachethreading.Lock全局缓存字典读写
JournalMemoryStore._cache_lockthreading.Lock内存缓存 _vectorstores 的增删改
JournalMemoryStore._locks[user_id]asyncio.Lock单用户索引构建互斥,防止并发重建
VectorStoreManager._rebuild_locks[kb_id]threading.Lock单知识库索引构建/重建互斥

8.2 线程池

  • JournalMemoryStore: ThreadPoolExecutor(max_workers=2),用于执行同步的 FAISS 操作和 Embedding 调用
  • VectorStoreManager: ThreadPoolExecutor(max_workers=2),用途同上

所有同步阻塞操作(FAISS 构建/保存/加载、Embedding API 调用)均通过 run_in_executor 或 _run_sync() 包装为异步接口,避免阻塞事件循环。


9. 典型日志解读

以下是一段用户创建札记后的完整缓存失效日志:

2026-11-06 11:27:26 - xiaoluo - INFO - [journal_memory.py:247] -
    [JournalMemoryStore] 用户 1 本地索引文件已删除: G:\oddmeta\x2\media\journal_vectors\1\faiss_index

2026-11-06 11:27:26 - xiaoluo - INFO - [journal_signals.py:30] -
    [JournalSignal] 用户 1 journal 索引缓存已失效

2026-11-06 11:27:26 - xiaoluo - INFO - [view_journal.py:109] -
    用户 1 创建札记: 56

时序说明

  1. 用户在前端创建一条新札记
  2. view_journal.py 保存 JournalEntry 到数据库
  3. Django 发出 post_save 信号
  4. journal_signals.py 的 on_journal_entry_saved 处理器被触发
  5. 调用 journal_memory_store.invalidate_cache(1)
  6. 内存缓存中的用户 1 索引被移除,本地 faiss_index 目录被删除
  7. 日志输出三条记录,分别来自 MemoryStore、Signal 和 View

后续检索时预期日志

[JournalMemoryStore] 从本地加载索引: user=1
# 或(若本地文件也被删除)
[JournalMemoryStore] 用户 1 索引同步完成: N 条文档
[JournalMemoryStore] FAISS 检索到 X 条文档 (user=1)
[JournalMemoryStore] 返回 Y 条文档 (user=1)

10. 关键配置参数

参数路径默认值所在文件说明
chat_models.embedding.model_typeollamaconfig.yamlEmbedding 服务类型
chat_models.embedding.urlhttp://127.0.0.1:11434config.yamlEmbedding 服务端点
chat_models.embedding.modelnomic-embed-textconfig.yamlEmbedding 模型名
chat_models.rerank.top_k5config.yaml返回文档数上限
chat_models.rerank.max_doc_length800config.yaml单文档最大字符数
MAX_CACHE_USERS10journal_memory.pyJournal 内存缓存用户数
max_cache_size3vectorstore_manager.py知识库内存缓存数
FAISS_SEARCH_K10journal_memory.pyFAISS 粗排返回数量

11. 文件位置索引

文件职责
engine/rag/retrieval_cache.py检索结果缓存(第一层)
engine/rag/journal_memory.py个人记忆向量索引(第二层 + 第三层)
engine/rag/vectorstore_manager.py知识库向量索引(第二层 + 第三层)
engine/rag/knowledge_service.py知识库管理、切片、文件解析
xiaoluo/signals/journal_signals.pyJournal 数据变更信号、缓存失效
xiaoluo/asgi.pyASGI 入口,启动时预加载知识库向量索引
xiaoluo/views/view_llm.pyLLM 对话接口,调用 Journal 检索
xiaoluo/views/view_journal.py札记 CRUD,保存后触发信号
xiaoluo/views/view_kb_api.py知识库 API,管理向量库生命周期

12. 设计取舍与注意事项

  1. Journal 为何不预加载? 用户数可能很多,预加载所有用户的索引会占用大量内存且大部分用户并非即时活跃。懒加载确保只有正在对话的用户才会占用资源。
  2. 为何 Embedding 逐条而非批量? 兼容 llama.cpp 等本地 Embedding 服务的单请求 token 限制(512 tokens)。批量 embed_documents 可能将多条文本合并为一个请求导致超限。逐条虽慢但更稳定。
  3. 索引文件删除后如何恢复? 完全透明。下次 search() 调用时自动进入”数据库重建”分支,用户无感知。
  4. LRU 淘汰会丢失数据吗? 不会。内存缓存被淘汰仅意味着下次需从本地文件或数据库重新加载,所有持久数据仍在数据库中。

附录:索引重建耗时优化方案

A.1 当前耗时瓶颈分析

通过代码分析,索引重建的耗时主要集中在以下环节(按影响程度排序):

瓶颈耗时占比原因
逐条 Embedding~70%_build_faiss_index() 中 for 循环逐条调用 embed_query(),每条一次 HTTP 往返
全量重建~20%即使只新增 1 条札记,incremental_sync() 也是全量拉取 + 全量重建
频繁触发~5%人物提取、批量提取后立即重建,用户可能尚未开始对话
数据库全量查询~5%每次重建 prefetch_related 拉取该用户全部数据

量化估算:假设某用户有 500 条文档,Embedding 服务单次耗时 50ms,逐条调用总耗时约 25 秒。若并发 5 个用户同时触发重建,用户体验会严重受损。


A.2 优化方案总览

按实施成本和收益分为三个层级:

┌─────────────────────────────────────────────────────────────────┐
│  短期方案(改动小,立竿见影)                                    │
│  ├─ 小批量 Embedding(预计提速 5~10 倍)                        │
│  ├─ 批量操作后只失效不立即重建(减少 90% 无效重建)            │
│  └─ 并发 Embedding 请求(预计提速 3~5 倍)                     │
├─────────────────────────────────────────────────────────────────┤
│  中期方案(需要架构调整,收益持久)                              │
│  ├─ Embedding 结果本地缓存(避免重复计算历史文档)             │
│  ├─ 真正的 FAISS 增量更新(只处理变更文档)                    │
│  └─ 变更追踪 + 选择性更新(只拉取增量数据)                    │
├─────────────────────────────────────────────────────────────────┤
│  长期方案(数据量很大时启用)                                    │
│  ├─ 冷热索引分离(基础索引 + 增量索引)                        │
│  └─ 索引结构升级(HNSW / IVF 替代 FlatL2)                     │
└─────────────────────────────────────────────────────────────────┘

A.3 短期方案

方案 1:小批量 Embedding(最推荐,改动最小)

问题:当前代码因兼容 llama.cpp 的 512 token 限制,采用逐条 embed_query。但多数文档较短,大量单条请求造成严重的网络往返开销。

优化思路:按估算 token 数动态分 batch,控制每批总 token < 450(留余量)。

# 伪代码示例
async def _batch_embed(self, texts: List[str], embeddings, max_tokens_per_batch: int = 450) -> List[List[float]]:
    """按 token 估算分批 embedding,减少 HTTP 往返次数"""
    import asyncio

    def estimate_tokens(text: str) -> int:
        # 简易估算:中文 ≈ 字数,英文 ≈ 词数 * 1.3
        cn_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
        en_words = len(re.findall(r'[a-zA-Z]+', text))
        return cn_chars + int(en_words * 1.3) + 2  # +2 为特殊标记

    batches = []
    current_batch = []
    current_tokens = 0

    for text in texts:
        tokens = estimate_tokens(text)
        if current_batch and current_tokens + tokens > max_tokens_per_batch:
            batches.append(current_batch)
            current_batch = [text]
            current_tokens = tokens
        else:
            current_batch.append(text)
            current_tokens += tokens

    if current_batch:
        batches.append(current_batch)

    # 批量调用 embed_documents(每批一次请求)
    all_vectors = []
    for batch in batches:
        # embed_documents 内部会将 batch 发给服务端
        vectors = await self._run_sync(embeddings.embed_documents, batch)
        all_vectors.extend(vectors)

    return all_vectors

预期效果:假设平均每条文档 80 token,512 token 限制下每批可合并 56 条,HTTP 请求量减少到原来的 1/5 ~ 1/6,重建耗时从 25 秒降至 **45 秒**。

兼容性处理:若检测到 Ollama 本地服务,可适当放宽 token 限制(如 2048);若检测到远程 API(如 SiliconFlow),可用更大的 batch。


方案 2:批量操作后只失效、不立即重建(减少无效重建)

问题:view_journal.py 中人物提取和批量提取后都立即调用 incremental_sync(),而此时用户可能尚未开始对话,重建的索引可能长时间不被使用。

具体场景:

  • 批量提取 100 条札记 → 循环中触发 100 次信号失效 → 最后又调一次全量重建
  • 用户只是导入/整理数据,还没开始聊天,索引建完就被 LRU 淘汰

优化思路:

  1. 信号处理器保持原逻辑(invalidate_cache 清除内存 + 删除本地文件)—— 不变
  2. 人物提取流程中去掉 incremental_sync 调用
  3. 批量提取流程中也去掉 incremental_sync 调用
  4. 依赖懒加载机制:用户真正发起对话、触发 search() 时,自动从数据库重建

若担心首次查询延迟,可叠加延迟重建策略:

# 在 invalidate_cache 中启动一个延迟任务
async def _delayed_rebuild(user_id: int, delay: int = 30):
    """延迟重建:数据变更后 30 秒再重建,期间如有新变更则重置计时器"""
    await asyncio.sleep(delay)
    await journal_memory_store.sync_user_journals(user_id)

这样用户在批量整理数据时,系统只失效不重建;等用户整理完、停顿 30 秒后,后台自动重建好索引,下次对话即查即用。


方案 3:并发 Embedding 请求(提速 3~5 倍)

问题:即使做了小批量,batch 之间仍是顺序执行。

优化思路:使用 asyncio.Semaphore 控制并发数,多个 batch 同时发送请求。

async def _batch_embed_concurrent(self, texts, embeddings, max_tokens_per_batch=450, max_concurrency=5):
    semaphore = asyncio.Semaphore(max_concurrency)

    async def embed_one_batch(batch):
        async with semaphore:
            return await self._run_sync(embeddings.embed_documents, batch)

    batches = self._split_into_batches(texts, max_tokens_per_batch)
    results = await asyncio.gather(*[embed_one_batch(b) for b in batches])
    return [vec for r in results for vec in r]

注意:并发数需根据 Embedding 服务端承载能力调整。Ollama 本地服务通常可承受 5~10 并发;远程 API 需参考其速率限制。


A.4 中期方案

方案 4:Embedding 结果本地缓存(避免重复计算)

问题:历史札记的内容几乎不变,但每次重建都要重新计算它们的 Embedding。

优化思路:以文档内容的 MD5 哈希为 key,将 Embedding 向量持久化到本地文件。重建时先查缓存,未命中再调用服务端。

缓存目录结构:
media/journal_vectors/
  ├── embeddings_cache/
  │     ├── a3f2/
  │     │     └── b1c8d9e7.npy   # hash 前 4 位分目录,防单目录文件过多
  │     └── ...
  └── 1/faiss_index/

关键逻辑:

def _get_cached_embedding(self, text: str) -> Optional[np.ndarray]:
    text_hash = hashlib.md5(text.encode()).hexdigest()
    cache_path = os.path.join(self.EMBEDDING_CACHE_ROOT, text_hash[:4], f"{text_hash}.npy")
    if os.path.exists(cache_path):
        return np.load(cache_path)
    return None

def _save_embedding_cache(self, text: str, vector: np.ndarray):
    text_hash = hashlib.md5(text.encode()).hexdigest()
    cache_dir = os.path.join(self.EMBEDDING_CACHE_ROOT, text_hash[:4])
    os.makedirs(cache_dir, exist_ok=True)
    np.save(os.path.join(cache_dir, f"{text_hash}.npy"), vector)

预期效果:对于已有大量历史数据的用户,重建时只有新增/修改的文档需要重新 Embedding,历史文档直接读本地缓存。若本次变更仅涉及 5% 的文档,Embedding 耗时降至原来的 5%。


方案 5:真正的 FAISS 增量更新

问题:当前 incremental_sync 注释明确说明”直接全量重建”。FAISS 的 IndexFlatL2 实际上支持 add_with_ids,可以在已有索引上追加向量。

优化思路:

# 伪代码:增量添加新文档到现有索引
async def add_documents_incremental(self, user_id: int, new_documents: List[Document]):
    # 1. 加载现有索引(内存或本地文件)
    vectorstore = await self._get_or_build_vectorstore(user_id)
    if vectorstore is None:
        # 首次构建,走全量路径
        return await self.sync_user_journals(user_id)

    # 2. 只对新文档做 Embedding
    embeddings = self._get_embeddings()
    new_vectors = await self._batch_embed([d.page_content for d in new_documents], embeddings)

    # 3. 获取当前最大 ID,为新文档分配连续 ID
    max_id = max(int(k) for k in vectorstore.docstore._dict.keys())
    new_ids = {str(max_id + 1 + i): doc for i, doc in enumerate(new_documents)}

    # 4. 追加到 FAISS 索引
    import numpy as np
    vectorstore.index.add_with_ids(
        np.array(new_vectors, dtype=np.float32),
        np.array([max_id + 1 + i for i in range(len(new_documents))], dtype=np.int64)
    )
    vectorstore.docstore.add(new_ids)
    # 同步 index_to_docstore_id 映射...

    # 5. 保存更新后的索引
    await self._run_sync(vectorstore.save_local, index_path)

删除的处理:IndexFlatL2 不支持直接删除向量。可采用软删除策略:

  • 维护一个 deleted_ids 集合
  • 检索结果中过滤掉已删除的 ID
  • 定期(如每天凌晨)或当删除比例超过阈值(如 20%)时,触发一次全量重建来压缩索引

方案 6:变更追踪 + 选择性更新

问题:增量更新需要知道”哪些文档是新增的、哪些是修改的、哪些是删除的”。

优化思路:在数据库或本地文件中记录每个用户的索引元数据:

# 索引元数据(保存为 JSON)
{
    "user_id": 1,
    "last_sync_at": "2026-11-06T10:00:00Z",
    "indexed_doc_count": 500,
    "doc_fingerprints": {
        "journal_42": "md5_hash_of_content",
        "person_7": "md5_hash_of_content",
        ...
    }
}

同步流程:

  1. 读取上次同步的元数据
  2. 从数据库拉取该用户全部文档(这一步目前无法避免,但可通过数据库索引优化查询速度)
  3. 计算每条文档的内容指纹(MD5)
  4. 对比指纹,找出:
    • 新增:元数据中不存在的文档
    • 修改:指纹变化的文档
    • 删除:元数据中有但数据库中不存在的文档
  5. 只对新文档和修改的文档重新 Embedding,已删除的文档标记删除
  6. 更新元数据文件

与方案 4 结合:修改的文档内容指纹变了,Embedding 缓存中自然未命中,会重新计算;未变更的文档直接从 Embedding 缓存读取。


A.5 长期方案

方案 7:冷热索引分离

适用场景:用户数据量很大(> 5000 条文档),全量索引加载和搜索都开始变慢。

架构:

┌─────────────────────────────────────────┐
│  热索引(增量索引)                      │
│  - 最近 7/30 天的数据                   │
│  - 变更频繁,接受增量更新               │
│  - 使用 IndexFlatL2(文档少,精确搜索) │
└─────────────────────────────────────────┘
                    ↓ 合并结果
┌─────────────────────────────────────────┐
│  冷索引(基础索引)                      │
│  - 历史数据(7/30 天前)                │
│  - 很少变更,定期(每周/每月)重建      │
│  - 使用 IndexHNSWFlat(文档多,快速)   │
└─────────────────────────────────────────┘

查询流程:

  1. 同时在热索引和冷索引中执行相似度搜索
  2. 合并结果,按距离排序,取 top_k
  3. 热索引的变更不影响冷索引,冷索引的定期重建不影响日常查询

方案 8:索引结构升级

索引类型特点适用场景
IndexFlatL2(当前)精确搜索,O(n) 复杂度,内存占用中等文档 < 2000 条
IndexHNSWFlat近似搜索,构建快、查询快,内存占用较大文档 2000~50000 条
IndexIVFFlat近似搜索,需训练,查询速度快文档 > 10000 条,数据较静态
IndexIVFPQ量化压缩,内存占用极小,精度略有损失文档 > 50000 条,内存紧张

建议:当用户文档量超过 2000 条时,自动切换为 IndexHNSWFlat,可在几乎不损失精度的前提下,将查询耗时从 O(n) 降至 O(log n)。


A.6 推荐实施优先级

优先级方案预估收益实施难度说明
P0方案 2:只失效不立即重建减少 90% 无效重建极低删除几行代码即可
P0方案 1:小批量 Embedding提速 5~10 倍低改造 _build_faiss_index()
P1方案 3:并发 Embedding再提速 3~5 倍低加 Semaphore + gather
P1方案 4:Embedding 本地缓存历史数据零重复计算中需新增缓存管理逻辑
P2方案 6:变更追踪只拉取增量数据中需新增元数据管理
P2方案 5:FAISS 增量更新避免全量重建中需处理增删改逻辑
P3方案 7:冷热分离支撑海量数据高架构调整较大
P3方案 8:索引结构升级查询 O(n) → O(log n)中数据量大时启用

短期(本周可完成):实施 P0 方案,重建耗时可从 25 秒降至 2~5 秒。

中期(1~2 周):叠加 P1 方案,对于以历史数据为主的用户,重建耗时进一步降至 秒级甚至毫秒级。

长期:当产品用户量和单用户数据量显著增长后,再引入冷热分离和索引结构升级。

Leave a comment

Your email address will not be published. Required fields are marked *