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

+8 -1
View File
@@ -46,12 +46,19 @@ type Hit struct {
// Index 是建立在已存 (id, vector) 对上的稠密向量相似度索引。
//
// Embed 用接入的 embedder 把一批文本转成向量。Add
// 持久化一个 (id, vector) 对,id 重复时 upsert。Search 返回与 q
// 持久化一个 (id, vector) 对,id 重复时 upsert。Vectors 按 id 取回
// 已存向量(增量向量化的读口)。Search 返回与 q
// 最相似的前 topK 个 id,按相似度降序排列。Dim 报告
// 固定向量维度。Close 释放底层数据库。
type Index interface {
Embed(ctx context.Context, texts []string) ([]Vector, error)
Add(ctx context.Context, id string, v Vector) error
// Vectors 返回与 ids 对齐的已存向量:命中按位置给出,未命中(含维度与
// 本索引不符的行)为 nil。ids 为空时返回同长度的空切片。
//
// 它是「增量向量化」的读口(memory 包的 embedIncremental):调用方据此
// 只嵌缺的那些,而不是每轮把整个分区重新算一遍。它只读,不写任何行。
Vectors(ctx context.Context, ids []string) ([]Vector, error)
Search(ctx context.Context, q Vector, topK int) ([]Hit, error)
Dim() int
Close() error
+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
}
+74
View File
@@ -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
}
+47
View File
@@ -108,6 +108,53 @@ func (s *SQLiteIndex) Add(ctx context.Context, id string, v Vector) error {
return nil
}
// Vectors 返回与 ids 对齐的已存向量:命中按位置给出,未命中(含维度与
// 本索引不符的行)为 nil。它是「增量向量化」的读口 —— 调用方据此只嵌
// 缺的那些,而不是每轮重算整个分区。它只读,不写任何行。
func (s *SQLiteIndex) 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 := s.lookupVectors(ctx, ids[start:end], found); err != nil {
return nil, err
}
}
return alignVectors(ids, found), nil
}
// lookupVectors 查一批 id 并把命中的向量写进 found。维度与本索引不符的行
// 当作未命中(它不能参与本索引的余弦,重算一次才是对的)。
func (s *SQLiteIndex) lookupVectors(ctx context.Context, ids []string, found map[string]Vector) error {
query := `SELECT id, vector FROM vec_items WHERE id IN (` + sqlitePlaceholders(len(ids)) + `)`
rows, err := s.db.QueryContext(ctx, query, idArgs(ids)...)
if err != nil {
return fmt.Errorf("memory/vector: lookup vectors: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
id string
blob []byte
)
if err := rows.Scan(&id, &blob); err != nil {
return fmt.Errorf("memory/vector: scan vector: %w", err)
}
v, err := decodeVector(blob)
if err != nil || len(v) != s.dim {
continue
}
found[id] = v
}
if err := rows.Err(); err != nil {
return fmt.Errorf("memory/vector: iterate vectors: %w", err)
}
return nil
}
// Search 返回与 q 最相似的最多 topK 个 id,按余弦相似度
// 降序排列。它在 Go 侧扫描每个已持久化向量(线性扫描,无 ANN)。topK
// 非正返回空结果;长度与
@@ -0,0 +1,197 @@
package vector
import (
"context"
"testing"
)
// 本文件验「按 id 取已存向量」这个读口(增量向量化的判据:向量存在性 + 维度)。
// 它是 M61(2026-10-30)用来把「整理轮每轮全量重嵌」变成「只嵌缺的那些」的那一层,
// 因此这里把它自己的对齐/未命中/维度不符/分块行为逐条钉死。
// TestSQLite_Vectors_按入参顺序对齐 覆盖命中、未命中、重复 id 三种位置。
func TestSQLite_Vectors_按入参顺序对齐(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newInMemorySQLite(t, 3)
for _, e := range []struct {
id string
v Vector
}{
{id: "cat", v: Vector{1, 0, 0}},
{id: "dog", v: Vector{0, 1, 0}},
} {
if err := s.Add(ctx, e.id, e.v); err != nil {
t.Fatalf("Add(%s): %v", e.id, err)
}
}
got, err := s.Vectors(ctx, []string{"dog", "missing", "cat", "dog"})
if err != nil {
t.Fatalf("Vectors: %v", err)
}
if len(got) != 4 {
t.Fatalf("Vectors 返回 %d 条, want 4(必须与入参等长)", len(got))
}
if !equalVector(got[0], Vector{0, 1, 0}) {
t.Fatalf("got[0] = %v, want dog 的向量", got[0])
}
if len(got[1]) != 0 {
t.Fatalf("未命中的 id 返回了向量:%v", got[1])
}
if !equalVector(got[2], Vector{1, 0, 0}) {
t.Fatalf("got[2] = %v, want cat 的向量", got[2])
}
if !equalVector(got[3], Vector{0, 1, 0}) {
t.Fatalf("重复 id 的第 4 个位置 = %v, want 与第 1 个位置相同", got[3])
}
}
// TestSQLite_Vectors_空入参 返回同长度的空切片(调用方可以无条件 range)。
func TestSQLite_Vectors_空入参(t *testing.T) {
t.Parallel()
s := newInMemorySQLite(t, 2)
got, err := s.Vectors(context.Background(), nil)
if err != nil {
t.Fatalf("Vectors(nil): %v", err)
}
if got == nil || len(got) != 0 {
t.Fatalf("Vectors(nil) = %v, want 空切片", got)
}
}
// TestSQLite_Vectors_维度不符当未命中:索引维度是硬门,别的维度的行不能参与本
// 索引的余弦,因此它必须被当成「没有向量」(于是调用方会重算一次)。
func TestSQLite_Vectors_维度不符当未命中(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newInMemorySQLite(t, 3)
// 绕开 Add 的维度校验,直接写一条 2 维的行(模拟换维度之后留下的旧行)。
if _, err := s.db.ExecContext(ctx,
`INSERT INTO vec_items (id, vector, dim) VALUES (?, ?, ?)`,
"legacy", encodeVector(Vector{1, 0}), 2); err != nil {
t.Fatalf("insert legacy row: %v", err)
}
got, err := s.Vectors(ctx, []string{"legacy"})
if err != nil {
t.Fatalf("Vectors: %v", err)
}
if len(got) != 1 || len(got[0]) != 0 {
t.Fatalf("got = %v, want 单个未命中(维度不符的行不能当命中)", got)
}
// 损坏的 blob 同样当未命中,绝不静默读成零向量。
if _, err := s.db.ExecContext(ctx,
`INSERT INTO vec_items (id, vector, dim) VALUES (?, ?, ?)`,
"broken", []byte{1, 2, 3}, 3); err != nil {
t.Fatalf("insert broken row: %v", err)
}
got, err = s.Vectors(ctx, []string{"broken"})
if err != nil {
t.Fatalf("Vectors(broken): %v", err)
}
if len(got[0]) != 0 {
t.Fatalf("损坏 blob 被当成了向量:%v", got[0])
}
}
// TestSQLite_Vectors_超过单查询分块上限 让 id 数越过 vectorsQueryChunk:
// 结果仍必须与入参逐位对齐(分块只是 SQL 的绑定变量上限,不是语义)。
func TestSQLite_Vectors_超过单查询分块上限(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newInMemorySQLite(t, 2)
ids := make([]string, 0, vectorsQueryChunk+8)
want := make(map[string]Vector, vectorsQueryChunk+8)
for i := 0; i < vectorsQueryChunk+8; i++ {
id := "id-" + string(rune('a'+i%26)) + "-" + itoaTest(i)
v := Vector{float32(i % 7), 1}
if err := s.Add(ctx, id, v); err != nil {
t.Fatalf("Add(%s): %v", id, err)
}
ids = append(ids, id)
want[id] = v
}
got, err := s.Vectors(ctx, ids)
if err != nil {
t.Fatalf("Vectors: %v", err)
}
if len(got) != len(ids) {
t.Fatalf("返回 %d 条, want %d", len(got), len(ids))
}
for i, id := range ids {
if !equalVector(got[i], want[id]) {
t.Fatalf("第 %d 条(%s)= %v, want %v", i, id, got[i], want[id])
}
}
}
// TestPGParseVector 验 pgvector 文本形式的解析(与 pgVectorLiteral 互逆)。
func TestPGParseVector(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
text string
want Vector
}{
{name: "single", text: "[1]", want: Vector{1}},
{name: "three", text: "[1.5,2,-0.25]", want: Vector{1.5, 2, -0.25}},
{name: "empty", text: "[]", want: Vector{}},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := pgParseVector(tt.text)
if err != nil {
t.Fatalf("pgParseVector(%q): %v", tt.text, err)
}
if !equalVector(got, tt.want) {
t.Fatalf("pgParseVector(%q) = %v, want %v", tt.text, got, tt.want)
}
})
}
for _, bad := range []string{"", "1,2,3", "[1,2", "[a,b]", "[1,,2]"} {
if _, err := pgParseVector(bad); err == nil {
t.Fatalf("pgParseVector(%q) 没报错(损坏行必须暴露)", bad)
}
}
// 往返:Vector → 文本 → Vector 必须一致(精度按 float32 取)。
original := Vector{0.5, -1, 3.25, 1024}
round, err := pgParseVector(pgVectorLiteral(original))
if err != nil {
t.Fatalf("round-trip parse: %v", err)
}
if !equalVector(round, original) {
t.Fatalf("round-trip = %v, want %v", round, original)
}
}
// equalVector 比较两个向量(nil 与空视为相等)。
func equalVector(a, b Vector) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// itoaTest 是测试里的小整数格式化(避免为一处用法引入 strconv 的第二种写法)。
func itoaTest(i int) string {
if i == 0 {
return "0"
}
out := make([]byte, 0, 4)
for i > 0 {
out = append([]byte{byte('0' + i%10)}, out...)
i /= 10
}
return string(out)
}