92ecb691de
The embedder swap left every stored vector in the old model's space, so cosine against a new query vector is noise. Add the one-shot backfill: store.ReembedAll re-embeds every note and fact text with the currently configured embedder (the passage side, which is the side stored text was written with) and rewrites both places a vector lives — the notes table embedding column and the memory_vectors rows. All of it plus the embedder marker happens in one transaction, so a failure partway changes nothing and writes no marker: re-run it. A run against a DB whose marker already names the current embedder does nothing. Triggered explicitly with `mavend -reembed`, not automatically on mismatch: ONNX on the laptop CPU makes this minutes of work, and a silent multi-minute stall on boot would look like a hang. The mismatch warning now tells the user to run it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
162 lines
5.1 KiB
Go
162 lines
5.1 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// EmbedFunc embeds one piece of stored text. The caller passes
|
|
// router.EmbedPassage — the STORE side of the query/passage asymmetry, which is
|
|
// the side every vector in the DB was written with. (Passing the query side
|
|
// would put the stored vectors in the wrong half of the space and quietly halve
|
|
// recall.) A func instead of an interface keeps this package free of any
|
|
// dependency on internal/router.
|
|
type EmbedFunc func(ctx context.Context, text string) ([]float32, error)
|
|
|
|
// BackfillResult is what the re-embed run did, for logging.
|
|
type BackfillResult struct {
|
|
Skipped bool // marker already matched — nothing to do
|
|
Notes int // rows rewritten in the notes table
|
|
Facts int // fact rows rewritten in memory_vectors
|
|
MemNotes int // note rows rewritten in memory_vectors
|
|
NoText int // memory_vectors rows with no text in their meta, left alone
|
|
Took time.Duration
|
|
}
|
|
|
|
// ReembedAll rewrites every stored vector with the currently configured
|
|
// embedder and then records that embedder as the one that owns the DB.
|
|
//
|
|
// Both places a vector lives are rewritten in the same pass: the `notes` table
|
|
// `embedding` column and the `memory_vectors` rows (notes AND facts). Doing
|
|
// only one would leave the two indexes disagreeing, which is worse than leaving
|
|
// both stale.
|
|
//
|
|
// Safe to re-run: if the marker already names the current embedder there is
|
|
// nothing to fix, so it returns immediately with Skipped set.
|
|
//
|
|
// Crash safety: everything — every vector and the marker — happens inside one
|
|
// transaction. If anything fails or the process dies partway, the transaction
|
|
// rolls back: no vectors changed and no marker written, so the next run does
|
|
// the whole job again. The marker is never set unless the full rewrite
|
|
// committed.
|
|
func (s *Store) ReembedAll(ctx context.Context, currentID string, embed EmbedFunc) (BackfillResult, error) {
|
|
start := time.Now()
|
|
var res BackfillResult
|
|
|
|
stored, err := s.Meta(ctx, metaKeyEmbedderID)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
if stored == currentID {
|
|
res.Skipped = true
|
|
res.Took = time.Since(start)
|
|
return res, nil
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return res, fmt.Errorf("reembed: begin: %w", err)
|
|
}
|
|
defer tx.Rollback() // no-op once committed
|
|
|
|
// ----- notes table -----
|
|
type noteRow struct {
|
|
id int64
|
|
text string
|
|
}
|
|
var notes []noteRow
|
|
rows, err := tx.QueryContext(ctx, `SELECT id, text FROM notes WHERE text != ''`)
|
|
if err != nil {
|
|
return res, fmt.Errorf("reembed: read notes: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
var n noteRow
|
|
if err := rows.Scan(&n.id, &n.text); err != nil {
|
|
rows.Close()
|
|
return res, fmt.Errorf("reembed: note row: %w", err)
|
|
}
|
|
notes = append(notes, n)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return res, fmt.Errorf("reembed: notes: %w", err)
|
|
}
|
|
|
|
for _, n := range notes {
|
|
vec, err := embed(ctx, n.text)
|
|
if err != nil {
|
|
return res, fmt.Errorf("reembed: embed note %d: %w", n.id, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE notes SET embedding = ? WHERE id = ?`, floatsToBlob(vec), n.id); err != nil {
|
|
return res, fmt.Errorf("reembed: write note %d: %w", n.id, err)
|
|
}
|
|
res.Notes++
|
|
}
|
|
|
|
// ----- memory_vectors (the unified index: notes AND facts) -----
|
|
// The text to re-embed is the one carried in the row's meta blob, which is
|
|
// exactly the text that was embedded when the row was written.
|
|
type vecRow struct {
|
|
id, text, kind string
|
|
}
|
|
var vecs []vecRow
|
|
rows, err = tx.QueryContext(ctx, `SELECT id, meta FROM memory_vectors`)
|
|
if err != nil {
|
|
return res, fmt.Errorf("reembed: read memory vectors: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
var id, metaJSON string
|
|
if err := rows.Scan(&id, &metaJSON); err != nil {
|
|
rows.Close()
|
|
return res, fmt.Errorf("reembed: memory row: %w", err)
|
|
}
|
|
meta := map[string]string{}
|
|
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
|
|
rows.Close()
|
|
return res, fmt.Errorf("reembed: meta for %q: %w", id, err)
|
|
}
|
|
if meta["text"] == "" {
|
|
res.NoText++
|
|
continue
|
|
}
|
|
vecs = append(vecs, vecRow{id: id, text: meta["text"], kind: meta["type"]})
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return res, fmt.Errorf("reembed: memory vectors: %w", err)
|
|
}
|
|
|
|
for _, v := range vecs {
|
|
vec, err := embed(ctx, v.text)
|
|
if err != nil {
|
|
return res, fmt.Errorf("reembed: embed %q: %w", v.id, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE memory_vectors SET vec = ? WHERE id = ?`, encodeVec(vec), v.id); err != nil {
|
|
return res, fmt.Errorf("reembed: write %q: %w", v.id, err)
|
|
}
|
|
if v.kind == "fact" {
|
|
res.Facts++
|
|
} else {
|
|
res.MemNotes++
|
|
}
|
|
}
|
|
|
|
// Same transaction as the rewrite, on purpose: the marker can only exist if
|
|
// every vector above was written.
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO meta (key, value) VALUES (?,?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
metaKeyEmbedderID, currentID); err != nil {
|
|
return res, fmt.Errorf("reembed: write marker: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return res, fmt.Errorf("reembed: commit: %w", err)
|
|
}
|
|
res.Took = time.Since(start)
|
|
return res, nil
|
|
}
|