RAG 增量索引实测:把 434.7 秒的全量重建压到 1.35 秒,以及"字节级一致"为什么只属于复用路径

一、环境与结论先行

环境:Apple 芯片 Mac(macOS 26),Python 3.13,向量服务用 llama.cpp 起 Qwen3-Embedding-0.6B(Q8_0),只依赖标准库 + numpy,不用向量数据库(这个规模不需要)。

索引长这样:

项 值
笔记数 498 份
切片总数 8825 片
向量维度 1024
单片刻度 平均 390 字(范围 40 ~ 700)
总字符 约 345 万
向量矩阵 36 MB(float32)

先把结论放前面,后面逐条给实测:

  1. 无改动时 :复用 497 份文件、重编 0 片 、墙钟 1.35 s ,产出与上一版字节级一致;
  2. 全量重建 :重编 8825 片 、墙钟 434.7 s ------ 两者相差 322 倍;
  3. 重编出来的向量不可复现 :同样的输入重算一遍,max|Δ| = 2.8e-4,完全相同的元素只占 76%;
  4. 但检索结果完全可复现:重建前后同一 query 的 Top-3 完全一致。
  5. 检索端到端 0.14 ~ 0.16 s (含查询编码 + 加载矩阵);纯余弦计算 0.153 ms。

二、增量怎么做:先能"识别没变过的文件"

整件事的前提是:你得能低成本地回答"这个文件自上次索引后动过没有"。

最直接的办法是记下每个文件的 mtime + size ,写进一份清单(index_manifest.json):

json 复制代码
{
  "built_at": "2026-10-09T04:21:16",
  "model": "qwen3-embedding-0.6b",
  "files": {
    "笔记A.md": {"mtime": 1789180613, "size": 8260},
    "笔记B.md": {"mtime": 1789825245, "size": 7310}
  }
}

下一次重建时对照:

python 复制代码
st  = f.stat()
key = {"mtime": int(st.st_mtime), "size": int(st.st_size)}
old = prev_manifest.get(rel)

if old and old["mtime"] == key["mtime"] and old["size"] == key["size"]:
    # 没变过 → 复用上一版的向量行
else:
    # 变了 / 新文件 → 重新切片 + 重新编码

同时还要能"把上一版的向量行按文件定位出来"。这依赖一个前提:同一个文件的切片在矩阵里是连续的 (因为写入时就是按文件顺序追加的)。所以维护一张 rel → (起始行, 片数) 的映射:

python 复制代码
def build_ranges(meta):
    ranges = {}
    for i, m in enumerate(meta):
        r = m["rel"]
        if r not in ranges:
            ranges[r] = [i, 0]
        ranges[r][1] += 1
    return ranges

有了它,"复用"就退化成一次内存里的行复制。


三、🔴 一个决定成败的细节:复用行不重新归一化

这里有个坑,藏着整个方案的成败。

向量服务(llama-server)不做归一化,所以写索引前必须自己做 L2 归一化。那么"复用的行"要不要再归一化一次?

不要。原样搬。

python 复制代码
mat = np.zeros((len(slots), dim), dtype=np.float32)
for i in reuse_slots:
    mat[i] = prev_mat[old_row[i]]        # ← 原样照搬,一个数都不碰

if new_slots:
    normed = l2norm(new_mat)             # 只有新编的才归一化
    for j, i in enumerate(new_slots):
        mat[i] = normed[j]

为什么这行注释值得单独拎出来讲------见第六节。一句话:归一化是浮点运算,只要重算,结果就可能和别人算的不一样;只有"不算",才能保证一致。


四、四条路径复现

拿真实库跑四条路径,每次记录"复用多少 / 重算多少 / 花了多久":

路径 复用 重算 总片数 墙钟 产物
什么都不改 497 份 / 8825 片 0 片 8825 1.35 s sha256 与上一版一致
新增 1 份笔记 497 份 1 片 8826 1.39 s ---
修改这 1 份 497 份 1 片 8826 1.40 s ---
删除这 1 份 497 份 0 片 8825 1.36 s sha256 回到删除前

复现命令(rag-ask.sh / rag-build.sh 会自己起停向量服务):

bash 复制代码
# 无改动(不需要服务,因为一片都不会编)
python3 rag-index.py

# 造一个临时笔记 → 增量
printf '# 临时笔记\n\n用于验证增量重建的段落。\n' > "$VAULT/临时测试.md"
python3 rag-index.py          # 应只重编这 1 份

# 删除它 → 再增量
rm "$VAULT/临时测试.md"
python3 rag-index.py          # 应删除 1 份,且 sha 回到基线

两条值得单独说的:

  • 第一行 :脚本从头到尾一片都没编,1.35 秒跑完,产物与上一版逐字节相同 。这不是"数值接近",是 sha256 相等。
  • 第四行 :删掉新增的文件,索引回到删除之前的状态------片数对、字节也对。删除路径没有留下残留行。

另外注意一个数字:498 份笔记,但"复用"只报了 497 份。 差的那 1 份是在切片阶段就被静默跳过了 (切不出长度达标的片段,代码里是一句 continue)。日志只有一行提示,不影响退出码。这个我留在最后一节说。


五、全量 vs 增量:434.7 s vs 1.35 s

同一批内容,两种走法:

需编码片数 墙钟
增量(无改动) 0 1.35 s
增量(改 1 份笔记) 1 1.40 s
全量重建 8825 434.7 s

322 倍。 这个收益的来源不是"算得快",是绝大多数片根本不参与计算。


六、🔴 关键发现:重编出来的向量,是不可复现的

这是这轮最有意思的一条。我本以为"同样的输入 + 同样的模型 + 同样的参数,重算一遍应该得到同样的矩阵"。实测:不一样。

我记下当前矩阵的 sha256,强制 --full 全量重建,再取一次 ------ 指纹变了。把两份矩阵逐元素相减:

指标 值
形状 两次都是 (8825, 1024)
max |Δ| 2.792 × 10⁻⁴
平均 |Δ| 4.800 × 10⁻⁶
完全相同的元素占比 75.89 %

也就是说,四分之一以上的数字变了 ------ 变得极小,但确实在变。

那检索会受影响吗?同一 query 重建前后各查一次:

Top-3 完全一致,连相似度分数都没变。

我的理解:

  1. 向量不可复现。模型内部是浮点并行运算,批处理的切分与调度只要有一点差别,末位就会漂。这是模型的属性,不是 bug。
  2. 但"漂"不影响排序 。2.8e-4 这个量级,改不动"哪几片最像"。
  3. 结论:不要用 hash 判断"内容有没有变"。 它会告诉你"变了",然后把你引向一个不存在的问题。

而这恰好回到第三节那个细节:"字节级一致"之所以能成立,是因为那些行根本没有被重算。 一旦把复用路径改成"搬过来再归一化一次",字节级一致会立刻消失,而检索结果依然正确------一个看起来没坏、实际已经悄悄退化的系统就是这么来的。


七、三个把我骗了的外推

跑全量之前我想先估个时间。随手拿 200 片文本编了一次,得到"约 5 ms/片",推算:

8825 × 5 ms ≈ 45 秒

实测 434.7 秒。差 9.7 倍。

查因,三个假设:

假设一:批大小。 把批大小从 16 加到 512 重测:

批大小 总耗时(512 片) ms/片 片/秒
16 2.70 s 5.3 189
64 2.59 s 5.1 198
128 2.56 s 5.0 200
512 2.55 s 5.0 201

吞吐几乎不变。假设一,错。

假设二:文本长度。 我估时间用的那 200 片是随手造的短句(十几字),而真实片段平均 390 字。换成真实长度的片段重测:

样本 平均字数 ms/片
短文本 19 4.3
真实片段 285 23.1

5.4 倍。找到主因了。

假设三:按真实长度重新外推。 23.1 ms × 8825 ≈ 204 秒。

实测 434.7 秒。还是差 2 倍。

到这里我停止猜测。剩下的两倍,可能是样本长度仍偏短(285 vs 全库 390),也可能是模型连跑七分钟后的降速------没有实测支撑,我不写结论。

教训 :小样本外推 在"样本与目标不同分布"时,可能错到没有参考价值。我错的三次分别错在样本太短 → 还是短 → 说不清。能实测就别估。

八、复现清单

文件 行数 职责
rag-index.py 380 切片 + 编码 + 增量判定 + 落盘
rag-search.py 90 查询编码 + 余弦 top-k
rag-build.sh 46 起向量服务 → 跑索引 → 停服务
rag-ask.sh 42 日常检索入口(自动确保服务在跑)
rag-inc-test.sh 73 四条路径的端到端测试

关键 CLI:

bash 复制代码
python3 rag-index.py            # 增量(默认)
python3 rag-index.py --full     # 忽略清单,全量重建
python3 rag-index.py --dry-run  # 只算切片与增量差异,不调模型
python3 rag-index.py --adopt    # 迁移用:把当前文件状态当作已索引

几个实现上的注意点:

  1. 复用路径原样搬行,绝不重新归一化------这是"未变更时字节级一致"的根因;
  2. 新旧维度不一致必须退全量(换了模型就会触发),否则拼出来是个坏矩阵;
  3. --dry-run 不调模型,用来在改代码后快速验证切片与增量判定,很省时间;
  4. llama-server 起服务的头两秒 /health 已就绪但模型可能仍在加载,首个编码请求要带重试,否则会拿到错误体。

九、还没解决的问题

脚本在切片阶段会静默跳过 那些"切不出有效片段"的笔记------本轮 498 份里有 1 份被跳过,日志只有一行提示,且不影响退出码。

危险的地方在于:"少索引了一份笔记"和"这份笔记本来就该被跳过",在产物上长得一模一样。 没有断言、没有非零退出码,它就会一直安安静静地错下去。

这个我没在这轮修,先记下来。

相关推荐
猿界零零七9 分钟前
Agent 开发面试题--Day002【Agent和Workflow的有什么区别】
人工智能·大模型
lucas_AI11 分钟前
OpenAI 重写「模型宪法」:五条价值观取代规则清单,第一条给了人类意图
人工智能
品牌常新11 分钟前
室外巡检机器人推荐:园区与厂区怎么选
数据库·人工智能·机器人
爱吃火鸡面呀17 分钟前
OpenCV 颜色识别实战:从 HSV 原理到摄像头实时检测
人工智能·opencv·计算机视觉
揽秀亭长17 分钟前
音频、五线谱、MIDI 如何转成简谱?
人工智能·音视频
大鹏的NLP博客22 分钟前
从格赖斯合作原则看大语言模型与提示词工程
人工智能·自然语言处理·大模型·agent
会议咨询27 分钟前
2027年神经网络、计算机视觉与信息工程国际会议(NCVIE 2027)
人工智能·神经网络·计算机视觉
跨境卫士—小依29 分钟前
TikTokShop旧版物流模板组件停用跨境技术团队如何完成系统迁移
大数据·人工智能·新媒体运营·跨境电商·跨境·营销策略
阿俊-全栈开发33 分钟前
LikeShop 多商户标准版智能商品分类与标签自动打标:大模型+向量检索二开实践
大数据·人工智能·分类·likeshop·likeshop开源商城
AI创界者33 分钟前
【AI生视频本地部署】最新 MiniMax-H3 全模态生成模型本地部署与极致优化:N卡 6G 显存流畅跑、无审查加速完整工作流实战
人工智能·aigc·音视频