把成本拆成能算的数

很多人只算了"装个向量库"的一次性投入,真正长期吃人的是后面几项。存储先给个量级:10 万 chunk、1024 维 float32,纯向量是 100000 × 1024 × 4 ≈ 410 MB,加上 HNSW 索引、元数据、原文和数据库开销,实际 1 到 1.5 GB,换 float16 或 int8 量化能砍一半。生成 embedding 是一次性大头,10 万条走本地 bge-m3 大约几小时,走 API 按 token 计费,长文档重切一次就是几块到几十块。最贵的是人力:清洗格式、判断分块是否合理、标注评测集、处理"为什么搜不到"的工单,这些没有一项能自动化掉。

更新策略:别每次全量重建

最常见的浪费是文档改一段就把十万条全部重新 embedding。正确做法是给每个 chunk 算内容指纹,只对新增和变化的块重算,删除的按路径清掉。

import hashlib, sqlite3

def fingerprint(text):
    return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]

db = sqlite3.connect("kb.db")
db.execute("CREATE TABLE IF NOT EXISTS chunks "
           "(id TEXT PRIMARY KEY, fp TEXT, path TEXT)")

to_embed = []
for ch in current_chunks():
    fp = fingerprint(ch["text"])
    row = db.execute("SELECT fp FROM chunks WHERE id = ?", (ch["id"],)).fetchone()
    if row is None or row[0] != fp:
        to_embed.append(ch)
        db.execute("INSERT OR REPLACE INTO chunks VALUES (?, ?, ?)",
                   (ch["id"], fp, ch["path"]))
db.commit()
print("需要重新 embedding 的块:", len(to_embed))

删除这一步最容易出事故:文档下线了却没删对应向量,用户还能检索出早已废弃的旧内容。做法是软删标记加上按 path 批量清理,并在清理后校验索引条数与源文档条数是否一致。

版本化与灰度迁移

把 embedding 模型名、维度、分块参数写进 collection 名称或元数据,例如 blog_bge-m3_500-60_v1。换模型时绝不能直接覆盖:新旧库双写,用同一批标注 query 对比 recall@5,灰度切一部分流量,确认新库确实更好再下线旧库。维度不一致时,有的库插入直接报错,有的悄悄存进去导致排序全乱,版本化是唯一能防住这件事的手段。

该监控什么,什么时候别自建

上线后盯四个数:空结果率、top1 相似度分布(整体偏低通常说明模型或分块配置错位)、检索 p95 延迟、孤儿 chunk 数。这条 SQL 用来找分块异常膨胀的文档:

SELECT path, COUNT(*) AS n FROM chunks
GROUP BY path HAVING COUNT(*) > 200
ORDER BY n DESC;

如果文档不到几百篇、更新又很频繁、团队里也没人愿意长期维护,就别自建。先用 ripgrep 做全文检索,或直接买现成服务。自建的隐性成本是"每次换模型都要重来一遍",以及"没人标注评测集就永远证明不了这次改动是变好还是变坏"。

最后列几条真出过问题的:换了 embedding 模型忘重建,相似度整体掉到 0.3 以下却没人报警;分块策略改了但旧块没清,同一段内容召回两遍;备份只备了数据库没备原始文档,索引损坏后无法重灌。备份策略里,原始文档、chunk 表、collection 版本三者必须成套。