写给谁看:文档量在几百到几千 chunk 这个区间,正准备装 FAISS 或 Chroma 的人。 上一篇讲切分(96 篇博客切出 720 个 chunk),这篇讲向量化和存储。检索准不准是下一篇的事,这篇只讲索引怎么建。
RAG 教程的默认第三步是「装个向量库」。我这个量级压根不需要。
先把量级算清楚:
720 个 chunk × 1024 维 × float32 = 2,949,120 字节 ≈ 2.81 MB
2.81 MB。 一张手机照片的大小。全部塞进内存,一次矩阵乘法就算完了对所有 chunk 的相似度,耗时在毫秒级。
装 FAISS 或 Chroma 能带来什么?多一个依赖、多一层部署、多一套要维护的持久化格式。检索质量一点不变 —— 因为暴力检索是精确的,近似最近邻(ANN)那些索引结构存在的意义是在百万级向量上牺牲一点精度换速度。我这里没有速度问题,也就没有牺牲精度的理由。
什么时候真的需要向量库
免得这篇被当成「向量库无用论」。分界线大概在这几件事上:
| 需要向量库 | 我这个场景 |
|---|---|
| 向量数到十万、百万级,全量点积扛不住 | 720 条 |
| 内存装不下,需要磁盘索引 / 分片 | 2.81 MB |
| 需要元数据过滤 + 向量检索混合查询 | 用不到 |
| 多进程 / 多机共享同一份索引 | 单机单进程 |
| 需要增量插入删除,且不想重建 | 96 篇博客,重建全量 34 秒 |
⭐ 最后一行是关键:全量重建只要 34 秒,那「增量更新」这个需求就不存在了。向量库很大一部分复杂度是为了解决增量和规模,这两个问题我都没有。
反过来说,等哪天文档到几万条、重建要十几分钟了,再上向量库 —— 那时候它解决的是真问题。
存的时候先归一化,检索时省一次除法
余弦相似度是 dot(a,b) / (|a|·|b|)。如果入库时就把每个向量归一化成单位长度,检索时余弦相似度就是一次点积。
mat = np.asarray(vectors, dtype=np.float32)
norms = np.linalg.norm(mat, axis=1, keepdims=True)
norms[norms == 0] = 1.0 # 防止零向量除零
mat /= norms
np.save("embeddings.npy", mat)
⚠️ norms[norms == 0] = 1.0 这行别省。理论上 embedding 不会返回零向量,但输入是空字符串或纯符号时不好说,除零会得到一堆 nan,而 nan 会静默地让相似度排序全乱 —— 它不报错。
检索那一侧就是:
q = embed(query) # 也要归一化
scores = mat @ q # 一次矩阵乘,得到 720 个分数
top = np.argsort(-scores)[:k]
embedding 选型:先复用已有的 key
用的是百炼的 text-embedding-v3,1024 维,OpenAI 兼容接口。
选它的第一个理由不是效果,是它和我另一个项目用的是同一个 key。多引一套凭证意味着多一处要管的密钥、多一个会过期的额度、多一个出问题时要排查的地方。在还没证明效果有差别之前,减少变量比追求最优更重要。
⏳ 待对照的是本地 bge-small-zh:免费、无网络依赖,但要自己跑模型。这个对比要等评测集跑完才有意义 —— 现在换过去,我没有任何指标能说明是变好还是变坏。
⚠️ 上一篇漏算的一笔成本:挂标题路径贵了 28.5%
上一篇我说「给每个 chunk 挂标题路径,成本就是拼个字符串」。做到这一步才发现不对。
| 字数 | 是什么 |
|---|---|
| 204,971 | 纯正文 |
| 263,298 | ⭐ 实际送去向量化的文本(正文 + 标题路径) |
差 58,327 字,+28.5%。 embedding 按字符收费,这三成是真花出去的。
这个交易仍然划算 —— 换来的是那些几十字的 chunk 从「检索不到任何东西」变成「能命中」,而总花费也就一毛三。但**「几乎没有代价」这句话,在有数字之前我不该写**。
📌 顺带一条通用的:凡是「顺手加一个字段」的优化,都要想一遍它在下游按量计费的环节会放大成多少。
实测数字
| 项 | 值 |
|---|---|
| chunk 数 | 720 |
| 维度 | 1024 |
| 送去向量化的字数 | 263,298 |
| 耗时 | 34.3 秒 |
| API 调用次数 | 72(批量 10) |
| 平均每次 | 0.48 秒 |
| 矩阵大小 | 2.81 MB |
| 成本 | ⭐ 约 ¥0.13 |
(定价按写作时 ¥0.0005/1k token 估,中文粗算 1 字 1 token,以官方页面为准。)
⭐ 一毛三。 这个数字值得单独说一句:很多人对「调 embedding 要花钱」的心理预期远高于实际,于是先去折腾本地模型,把简单的事做复杂了。先花一毛三跑通,再决定要不要省这一毛三。
两个工程细节
批量大小取 10,偏保守。 各家 embedding 接口对单次输入条数和总 token 都有上限,而超限的报错通常很含糊。批量从小开始,跑通了再往上调 —— 这比一开始塞 100 条然后猜哪个限制被撞了要快。
指数退避重试。 批量接口最常见的失败是限流,退避重试三次基本能过。
for attempt in range(retry):
try:
return client.embeddings.create(model=MODEL, input=texts,
encoding_format="float")
except Exception as e:
if attempt == retry - 1:
raise
time.sleep(2 ** attempt) # 1s, 2s, 4s
⭐ 另外给脚本留了个 --limit N 参数,先跑 20 条确认接口通了再全量。这个习惯在按量计费的接口上很值 —— 全量跑到一半才发现参数写错,钱和时间都白花。
下一步:先有评测集,再谈调参
索引建好了,但我现在不能说检索效果好 —— 没有评测集,任何「感觉挺准的」都不算数。
评测集已经建了 25 条,形态是 问题 → 期望命中的文章,量 top-k 召回率。两条纪律:
- ⭐ 问题用读者的措辞写,不能照抄文章标题。 照抄会让检索虚高,测不出真实效果。
- ⭐ 其中 8 条刻意设计成「问法和原文用词几乎不重叠」,单独算这一组的召回率 —— 那才是语义检索真正被考验的地方。比如问「外层 div 的高度被里面浮动的元素撑不开」,去命中一篇标题叫 BFC 的文章:两句话没有一个共同的词。
召回率数字下一篇给。
最值得记的三条
- 几百到几千 chunk 不需要向量库。 720 × 1024 维只有 2.81 MB,全量点积毫秒级;而全量重建只要 34 秒,连「增量更新」这个需求都不存在。向量库的复杂度主要用来解决规模和增量,这两个问题都没有的时候,它只是多一个依赖。
- 入库时归一化,检索就是一次点积;
norms[norms == 0] = 1.0别省,零向量除零会得到静默的nan,让排序全乱且不报错。 - 一毛三。 720 个 chunk 全量向量化的实际花费。先花这一毛三跑通,再决定要不要为省它去折腾本地模型。