Files
Cyrene_v2/internal/runtime/memory/vector/pg.go
T
AskaEth 589e26c887 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 个请求后收手
2026-10-30 08:30:06 +08:00

288 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package vector
import (
"context"
"database/sql"
"fmt"
"log/slog"
"strconv"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
_ "github.com/jackc/pgx/v5/stdlib" // Postgres 驱动,注册名为 "pgx"
)
// pgMaxOpenConns 是 DevDocs/02-development-constraints.md §2.2 为
// pgvector 增强规定的 PostgreSQL 连接池大小。
const pgMaxOpenConns = 25
// PGVectorIndex 把向量存入 PostgreSQL 的 pgvector vector 列,并
// 用 SQL 中的余弦距离运算符 <=> 作答相似度。它是配置了
// DB_PG_URL 时选中的可选增强;启用时,
// 语义检索真正由 pgvector 支撑,不会退化为
// 关键词匹配(DevDocs/design/03-记忆.md §3)。
//
// schema 由 NewPG 准备(vector 扩展 + vec_items 表)。Search
// 发出 ORDER BY vector <=> $1 LIMIT n,返回精确的余弦距离
// 排名结果。没有 ANN 索引时,查询规划器做顺序扫描,
// 这正是 M4 里程碑(小数据集)期望的线性扫描行为。
// HNSW 索引(CREATE INDEX ... USING hnsw (vector vector_cosine_ops))是
// 大数据集下既定的 ANN 升级方案,且无需改动 Search。
type PGVectorIndex struct {
db *sql.DB
dim int
embedder embedding.Embedder
log *slog.Logger
}
// NewPG 用 pgx 驱动连接到 pgURL 的 PostgreSQL+pgvector 数据库
// 并准备 schema:vector 扩展与一张带固定维度 vector 列的
// vec_items 表。它刻意不创建 ANN 索引:
// M4 里程碑下 <=> 查询走精确顺序扫描,而 HNSW
// 索引是既定的大数据量可选升级。pgURL 为空
// 返回 ErrInvalidPGURL,dim 非正返回 ErrInvalidDim,两者都在
// 任何网络 I/O 之前。ctx 控制连接 ping 与 schema 准备。
//
// 返回的索引没有接入 embedder;用 New 构建完全接好的
// 索引,或在调用 Embed 前接入一个。
func NewPG(ctx context.Context, pgURL string, dim int, log *slog.Logger) (*PGVectorIndex, error) {
if strings.TrimSpace(pgURL) == "" {
return nil, ErrInvalidPGURL
}
if dim <= 0 {
return nil, ErrInvalidDim
}
log = sourcedLogger(log)
db, err := sql.Open("pgx", pgURL)
if err != nil {
return nil, fmt.Errorf("memory/vector: open pg: %w", err)
}
db.SetMaxOpenConns(pgMaxOpenConns)
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("memory/vector: ping pg: %w", err)
}
idx := &PGVectorIndex{db: db, dim: dim, log: log}
if err := idx.setup(ctx); err != nil {
_ = db.Close()
return nil, err
}
log.Info("pgvector index ready", "dim", dim)
return idx, nil
}
// setup 幂等地创建 pgvector 扩展与 vec_items 表。
// M4 里程碑不创建 ANN 索引 —— <->/<=> 查询退化为
// 精确顺序扫描,与 SQLite 路径的线性扫描语义一致。
// 要扩容,可添加:
//
// CREATE INDEX IF NOT EXISTS idx_vec_items_vector
// ON vec_items USING hnsw (vector vector_cosine_ops)
//
// 且无需改动 Search。每条语句在取输入处都参数化;
// 表 DDL 在维度替换后是静态的。
func (p *PGVectorIndex) setup(ctx context.Context) error {
statements := []string{
"CREATE EXTENSION IF NOT EXISTS vector",
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS vec_items (
id TEXT PRIMARY KEY,
vector vector(%d) NOT NULL,
dim INTEGER NOT NULL
)`, p.dim),
}
for _, stmt := range statements {
if _, err := p.db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("memory/vector: setup pg schema: %w", err)
}
}
return nil
}
// withEmbedder 把产向量的 embedder 接入索引。New 使用它;
// 测试也可直接调用。
func (p *PGVectorIndex) withEmbedder(e embedding.Embedder) { p.embedder = e }
// Dim 返回固定向量维度。
func (p *PGVectorIndex) Dim() int { return p.dim }
// Embed 用接入的 embedder 把 texts 转成向量,每段输入文本
// 对应一个按索引对齐的 Vector。未接入 embedder 时返回
// ErrNoEmbedder。
func (p *PGVectorIndex) Embed(ctx context.Context, texts []string) ([]Vector, error) {
if p.embedder == nil {
return nil, ErrNoEmbedder
}
vecs, err := p.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。向量以
// 绑定到 $n::vector 参数的向量字面量传给 Postgres。它
// 拒绝空 id(ErrMissingID)以及与索引维度不一致的
// 向量(ErrDimMismatch)。
func (p *PGVectorIndex) Add(ctx context.Context, id string, v Vector) error {
if strings.TrimSpace(id) == "" {
return ErrMissingID
}
if len(v) != p.dim {
return fmt.Errorf("memory/vector: %w (got %d, want %d)", ErrDimMismatch, len(v), p.dim)
}
_, err := p.db.ExecContext(ctx, `INSERT INTO vec_items (id, vector, dim)
VALUES ($1, $2::vector, $3)
ON CONFLICT (id) DO UPDATE SET vector = EXCLUDED.vector, dim = EXCLUDED.dim`,
id, pgVectorLiteral(v), p.dim)
if err != nil {
return fmt.Errorf("memory/vector: add %q: %w", id, err)
}
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 非正
// 返回空结果;长度与索引维度不一致的查询
// 返回 ErrDimMismatch。
func (p *PGVectorIndex) Search(ctx context.Context, q Vector, topK int) ([]Hit, error) {
if topK <= 0 {
return []Hit{}, nil
}
if len(q) != p.dim {
return nil, fmt.Errorf("memory/vector: %w (query %d, want %d)", ErrDimMismatch, len(q), p.dim)
}
rows, err := p.db.QueryContext(ctx, `SELECT id, 1.0 - (vector <=> $1::vector) AS score
FROM vec_items
ORDER BY vector <=> $1::vector
LIMIT $2`, pgVectorLiteral(q), topK)
if err != nil {
return nil, fmt.Errorf("memory/vector: search pg: %w", err)
}
defer func() { _ = rows.Close() }()
hits := make([]Hit, 0)
for rows.Next() {
var (
id string
score float64
)
if err := rows.Scan(&id, &score); err != nil {
return nil, fmt.Errorf("memory/vector: scan pg hit: %w", err)
}
hits = append(hits, Hit{ID: id, Score: score})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory/vector: iterate pg hits: %w", err)
}
return hits, nil
}
// Close 释放底层的 PostgreSQL 连接池。
func (p *PGVectorIndex) Close() error {
if p.db == nil {
return nil
}
err := p.db.Close()
if err != nil {
return fmt.Errorf("memory/vector: close pg: %w", err)
}
return nil
}
// pgVectorLiteral 把 v 渲染成 pgvector 的文本形式 "[a,b,c]",
// 以便绑定为向量参数($n::vector)。取值的格式化精度足以
// 往返一个 float32。
func pgVectorLiteral(v Vector) string {
var b strings.Builder
b.WriteByte('[')
for i, x := range v {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(strconv.FormatFloat(float64(x), 'f', -1, 32))
}
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
}