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 个请求后收手
255 lines
8.0 KiB
Go
255 lines
8.0 KiB
Go
package vector
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/binary"
|
||
"fmt"
|
||
"log/slog"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
)
|
||
|
||
// createVecItemsTable 是幂等的 SQLite schema。vector 以小端
|
||
// float32 值的 BLOB 存储;dim 记录维度,因此 blob
|
||
// 无需外部信息即可解码。该表由
|
||
// NewSQLite 创建;不要求与 internal/db 的迁移兼容,因为
|
||
// 向量存储是自包含的(见 internal/runtime/memory/store.go)。
|
||
const createVecItemsTable = `
|
||
CREATE TABLE IF NOT EXISTS vec_items (
|
||
id TEXT PRIMARY KEY,
|
||
vector BLOB NOT NULL,
|
||
dim INTEGER NOT NULL
|
||
)`
|
||
|
||
// SQLiteIndex 把向量以 BLOB 存入 SQLite 的 vec_items 表,并以
|
||
// Go 侧线性扫描的余弦相似度作答相似度(M4 里程碑
|
||
// 语义:小数据集,无 ANN 索引)。
|
||
//
|
||
// 并发交由 *sql.DB,按约定它可安全并发使用,
|
||
// 因此 SQLiteIndex 不再持锁。每个查询都参数化
|
||
// (?)且列清单显式列出。
|
||
type SQLiteIndex struct {
|
||
db *sql.DB
|
||
dim int
|
||
embedder embedding.Embedder
|
||
log *slog.Logger
|
||
}
|
||
|
||
// NewSQLite 在 db 上为维度 dim 的向量构建 SQLiteIndex,确保
|
||
// vec_items schema 存在。db 必须是存活连接;nil db 返回
|
||
// ErrNilDB,dim 非正返回 ErrInvalidDim。ctx 控制 schema
|
||
// 创建。log 可为 nil。
|
||
//
|
||
// 返回的索引没有接入 embedder,因此直接调用 Embed
|
||
// 会返回 ErrNoEmbedder;用 New 构建完全接好的索引,或在调用
|
||
// Embed 前接入一个。
|
||
func NewSQLite(ctx context.Context, db *sql.DB, dim int, log *slog.Logger) (*SQLiteIndex, error) {
|
||
if db == nil {
|
||
return nil, ErrNilDB
|
||
}
|
||
if dim <= 0 {
|
||
return nil, ErrInvalidDim
|
||
}
|
||
log = sourcedLogger(log)
|
||
if _, err := db.ExecContext(ctx, createVecItemsTable); err != nil {
|
||
return nil, fmt.Errorf("memory/vector: create sqlite schema: %w", err)
|
||
}
|
||
log.Info("sqlite vector index ready", "dim", dim)
|
||
return &SQLiteIndex{db: db, dim: dim, log: log}, nil
|
||
}
|
||
|
||
// withEmbedder 把产向量的 embedder 接入索引。它由
|
||
// New 使用;测试也可直接调用。
|
||
func (s *SQLiteIndex) withEmbedder(e embedding.Embedder) { s.embedder = e }
|
||
|
||
// Dim 返回固定向量维度。
|
||
func (s *SQLiteIndex) Dim() int { return s.dim }
|
||
|
||
// Embed 用接入的 embedder 把 texts 转成向量。每段输入文本
|
||
// 对应一个按索引对齐的 Vector。未接入 embedder 时返回
|
||
// ErrNoEmbedder。
|
||
func (s *SQLiteIndex) Embed(ctx context.Context, texts []string) ([]Vector, error) {
|
||
if s.embedder == nil {
|
||
return nil, ErrNoEmbedder
|
||
}
|
||
vecs, err := s.embedder.Embed(ctx, texts)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("memory/vector: embed %d texts: %w", len(texts), err)
|
||
}
|
||
out := make([]Vector, len(vecs))
|
||
for i, v := range vecs {
|
||
out[i] = Vector(v)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// Add 持久化 (id, vector) 对,id 重复时 upsert,
|
||
// 最新的向量生效。它拒绝空 id(ErrMissingID)以及与
|
||
// 索引维度不一致的向量(ErrDimMismatch)。ctx 被取消时
|
||
// 中止写入。
|
||
func (s *SQLiteIndex) Add(ctx context.Context, id string, v Vector) error {
|
||
if strings.TrimSpace(id) == "" {
|
||
return ErrMissingID
|
||
}
|
||
if len(v) != s.dim {
|
||
return fmt.Errorf("memory/vector: %w (got %d, want %d)", ErrDimMismatch, len(v), s.dim)
|
||
}
|
||
_, err := s.db.ExecContext(ctx, `INSERT INTO vec_items (id, vector, dim)
|
||
VALUES (?, ?, ?)
|
||
ON CONFLICT(id) DO UPDATE SET vector = excluded.vector, dim = excluded.dim`,
|
||
id, encodeVector(v), s.dim)
|
||
if err != nil {
|
||
return fmt.Errorf("memory/vector: add %q: %w", id, err)
|
||
}
|
||
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
|
||
// 非正返回空结果;长度与
|
||
// 索引维度不一致的查询返回 ErrDimMismatch。
|
||
func (s *SQLiteIndex) Search(ctx context.Context, q Vector, topK int) ([]Hit, error) {
|
||
if topK <= 0 {
|
||
return []Hit{}, nil
|
||
}
|
||
if len(q) != s.dim {
|
||
return nil, fmt.Errorf("memory/vector: %w (query %d, want %d)", ErrDimMismatch, len(q), s.dim)
|
||
}
|
||
rows, err := s.db.QueryContext(ctx, `SELECT id, vector FROM vec_items`)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("memory/vector: search: %w", err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
hits := make([]Hit, 0)
|
||
for rows.Next() {
|
||
var (
|
||
id string
|
||
blob []byte
|
||
)
|
||
if err := rows.Scan(&id, &blob); err != nil {
|
||
return nil, fmt.Errorf("memory/vector: scan item: %w", err)
|
||
}
|
||
v, err := decodeVector(blob)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("memory/vector: decode %q: %w", id, err)
|
||
}
|
||
hits = append(hits, Hit{ID: id, Score: cosine(q, v)})
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("memory/vector: iterate items: %w", err)
|
||
}
|
||
|
||
sort.Slice(hits, func(i, j int) bool { return hits[i].Score > hits[j].Score })
|
||
if len(hits) > topK {
|
||
hits = hits[:topK]
|
||
}
|
||
return hits, nil
|
||
}
|
||
|
||
// Close 释放底层的 SQLite 数据库。数据库
|
||
// 已关闭时调用也是安全的。
|
||
func (s *SQLiteIndex) Close() error {
|
||
if s.db == nil {
|
||
return nil
|
||
}
|
||
err := s.db.Close()
|
||
if err != nil {
|
||
return fmt.Errorf("memory/vector: close sqlite: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// encodeVector 把 v 序列化为小端 float32 BLOB。它不会失败。
|
||
func encodeVector(v Vector) []byte {
|
||
buf := make([]byte, 4*len(v))
|
||
for i, x := range v {
|
||
binary.LittleEndian.PutUint32(buf[i*4:], math.Float32bits(x))
|
||
}
|
||
return buf
|
||
}
|
||
|
||
// decodeVector 把 BLOB 反序列化回 Vector。它拒绝空或
|
||
// 非 4 字节对齐的 blob,使损坏的行以错误暴露,而不是
|
||
// 被静默误读。
|
||
func decodeVector(blob []byte) (Vector, error) {
|
||
if len(blob) == 0 || len(blob)%4 != 0 {
|
||
return nil, fmt.Errorf("memory/vector: invalid vector blob length %d", len(blob))
|
||
}
|
||
v := make(Vector, len(blob)/4)
|
||
for i := range v {
|
||
v[i] = math.Float32frombits(binary.LittleEndian.Uint32(blob[i*4:]))
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
// cosine 返回 a 与 b 的余弦相似度,取值 [-1, 1]。长度为零或
|
||
// 不一致的向量得 0,因此不一致的存储行绝不会破坏
|
||
// 排名。
|
||
func cosine(a, b Vector) float64 {
|
||
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
|
||
return 0
|
||
}
|
||
var dot, na, nb float64
|
||
for i := range a {
|
||
dot += float64(a[i]) * float64(b[i])
|
||
na += float64(a[i]) * float64(a[i])
|
||
nb += float64(b[i]) * float64(b[i])
|
||
}
|
||
if na == 0 || nb == 0 {
|
||
return 0
|
||
}
|
||
return dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||
}
|