fix(memory): 增量向量化 + 单分片逐条隔离,召回请求数降一个数量级(M61)

M15(2026-10-13)登记的两条放大源落在记忆层,本次一并收口:

1. 整理轮/入库审查**只嵌增量**:新加 vector.Index.Vectors(SQLite/PG 实现,
   按 id 取已存向量、维度不符当未命中),Store.embedIncremental 先复用索引里的
   向量、只嵌缺的那些,并把手算出的向量回填索引 ⇒ 同一分区第二轮起零请求。
   判据写明为「向量存在性 + 维度一致」(正文在本仓不可就地改写,故无需哈希)。
   维度与当前 embedder 不一致时禁用复用并 WARN(不混两种表示)。
2. 嵌入链对单个慢分片有韧性:embedIsolated 整批失败后逐条隔离,坏的那几条
   记缺口、其余照常返回;一条都没拿到才报错降级。请求数上界 1+B(B=8 ⇒ 9),
   服务整体挂死时一次即收手,不用「请求放大」换韧性。
3. 缺口/复用/回填条数进日志(缺口走 WARN),review 的被审条目拿不到向量时
   如实返回错误 ⇒ 走既有词项余弦降级(不把「算不出来」说成「没有重复」)。

请求数(单测内假 OpenAI 兼容服务端计数,生产 API embedder + 真 SQLite 索引):
  24 条存量:修复前每轮 3 → 第一轮 3、第二轮 0
  12 条分区整理轮:第一轮 2 → 第二轮 0
  20 条候选的入库审查:26(201÷8)→ 第一条 3、第二条 0
  一条坏正文:保住 7/8 条,代价 9 个请求;整体性故障 9 个请求后收手
This commit is contained in:
AskaEth committed 2026-10-30 08:30:06 +08:00
1 parent 9d28477e3a
commit 589e26c887
15 files changed
+1343 -48

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
package vector
import (
"strconv"
"strings"
)
// 本文件是「按 id 取已存向量」的共用件:增量向量化(memory 包的
// embedIncremental)靠它跳过已经算过的行,从而不再每轮把整个分区重新嵌一遍
// ——那是 M15(2026-10-13)登记的请求放大源之一。
//
// 只读、参数化:id 一律走绑定参数,查询里不出现任何拼接的取值。
// vectorsQueryChunk 是单条 IN 查询携带的 id 上限。SQLite 的绑定变量上限
// (历史默认 999)之下留出余量,使 2000 条的分区不会因为一条查询太长而
// 失败;超过它的 id 分多条查询,结果仍按入参顺序对齐。
const vectorsQueryChunk = 256
// placeholders 返回 n 个占位符:encode 决定形式(SQLite 的 "?"、PostgreSQL
// 的 "$i")。它只拼占位符,不拼任何取值。
func placeholders(n int, encode func(i int) string) string {
var b strings.Builder
for i := 0; i < n; i++ {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(encode(i))
}
return b.String()
}
// sqlitePlaceholders 返回 "?,?,?"。
func sqlitePlaceholders(n int) string {
return placeholders(n, func(int) string { return "?" })
}
// pgPlaceholders 返回 "$1,$2,..."。
func pgPlaceholders(n int) string {
return placeholders(n, func(i int) string { return "$" + strconv.Itoa(i+1) })
}
// idArgs 把 ids 转成绑定参数([]any 是 database/sql 要求的入参形态)。
func idArgs(ids []string) []any {
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
return args
}
// alignVectors 把「id → 向量」按入参顺序摊开:未命中的位置(含同一 id 在
// 入参里出现多次时)一律为 nil。因此调用方拿到的切片长度恒等于 ids。
func alignVectors(ids []string, found map[string]Vector) []Vector {
out := make([]Vector, len(ids))
if len(found) == 0 {
return out
}
pos := make(map[string][]int, len(ids))
for i, id := range ids {
pos[id] = append(pos[id], i)
}
for id, vec := range found {
for _, i := range pos[id] {
out[i] = vec
}
}
return out
}