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:
1 parent
9d28477e3a
commit
589e26c887
15 files changed
+1343
-48
No files matched your search
@@ -147,6 +147,56 @@ func (p *PGVectorIndex) Add(ctx context.Context, id string, v Vector) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Vectors 返回与 ids 对齐的已存向量:命中按位置给出,未命中(含维度与
|
||||
// 本索引不符的行、解析不了的行)为 nil。它是「增量向量化」的读口 ——
|
||||
// 调用方据此只嵌缺的那些,而不是每轮重算整个分区。它只读,不写任何行。
|
||||
//
|
||||
// 向量列显式 ::text 取出:pgvector 的 OID 不在 database/sql 的标准类型里,
|
||||
// 直接 scan 进 string 依赖驱动猜测格式;显式转型把「取什么格式」写在 SQL 里。
|
||||
func (p *PGVectorIndex) Vectors(ctx context.Context, ids []string) ([]Vector, error) {
|
||||
if len(ids) == 0 {
|
||||
return []Vector{}, nil
|
||||
}
|
||||
found := make(map[string]Vector, len(ids))
|
||||
for start := 0; start < len(ids); start += vectorsQueryChunk {
|
||||
end := min(start+vectorsQueryChunk, len(ids))
|
||||
if err := p.lookupVectors(ctx, ids[start:end], found); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return alignVectors(ids, found), nil
|
||||
}
|
||||
|
||||
// lookupVectors 查一批 id 并把命中的向量写进 found。维度与本索引不符
|
||||
// (或解析不了)的行当作未命中 —— 它不能参与本索引的余弦,重算一次才是对的。
|
||||
func (p *PGVectorIndex) lookupVectors(ctx context.Context, ids []string, found map[string]Vector) error {
|
||||
query := `SELECT id, vector::text FROM vec_items WHERE id IN (` + pgPlaceholders(len(ids)) + `)`
|
||||
rows, err := p.db.QueryContext(ctx, query, idArgs(ids)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory/vector: lookup pg vectors: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
id string
|
||||
text string
|
||||
)
|
||||
if err := rows.Scan(&id, &text); err != nil {
|
||||
return fmt.Errorf("memory/vector: scan pg vector: %w", err)
|
||||
}
|
||||
v, err := pgParseVector(text)
|
||||
if err != nil || len(v) != p.dim {
|
||||
continue
|
||||
}
|
||||
found[id] = v
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("memory/vector: iterate pg vectors: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search 返回与 q 最相似的最多 topK 个 id,按余弦相似度
|
||||
// (1 - 余弦距离)降序排列,由 pgvector 在 SQL 中计算。topK 非正
|
||||
// 返回空结果;长度与索引维度不一致的查询
|
||||
@@ -211,3 +261,27 @@ func pgVectorLiteral(v Vector) string {
|
||||
b.WriteByte(']')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// pgParseVector 把 pgvector 的文本形式 "[a,b,c]" 解析回 Vector ——
|
||||
// pgVectorLiteral 的逆运算。它拒绝空串与任何非浮点的分量,使损坏的行以
|
||||
// 错误暴露(调用方据此把它当未命中),而不是被静默读成一串零向量。
|
||||
func pgParseVector(text string) (Vector, error) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if len(trimmed) < 2 || trimmed[0] != '[' || trimmed[len(trimmed)-1] != ']' {
|
||||
return nil, fmt.Errorf("memory/vector: invalid pgvector literal of length %d", len(trimmed))
|
||||
}
|
||||
body := trimmed[1 : len(trimmed)-1]
|
||||
if body == "" {
|
||||
return Vector{}, nil
|
||||
}
|
||||
parts := strings.Split(body, ",")
|
||||
out := make(Vector, len(parts))
|
||||
for i, part := range parts {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(part), 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory/vector: parse pgvector component %d: %w", i, err)
|
||||
}
|
||||
out[i] = float32(f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in new issue
Block a user