f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
471 lines
18 KiB
Go
471 lines
18 KiB
Go
package vector
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
kitvec "go.kenn.io/kit/vector"
|
|
"go.kenn.io/kit/vector/sqlitevec"
|
|
)
|
|
|
|
// progressInterval bounds how often BuildOptions.Progress is invoked during
|
|
// an embedding pass. A final call always fires once Fill returns,
|
|
// regardless of this interval, so callers see a Done total that matches
|
|
// the completed (or aborted) run. Tests lower it to 0 for determinism.
|
|
var progressInterval = 2 * time.Second
|
|
|
|
// BuildOptions configures one Build pass.
|
|
type BuildOptions struct {
|
|
// FullRebuild forces every document to be re-embedded under the target
|
|
// generation's fingerprint, even if it is already the active one.
|
|
FullRebuild bool
|
|
// Backstop forces a full mirror reconciliation scan (ignoring the
|
|
// refresh watermark) without forcing a re-embed.
|
|
Backstop bool
|
|
// IncludeAutomated controls whether automated sessions' units are
|
|
// scanned into the mirror at all (see UnitSource.ScanEmbeddableUnits).
|
|
// It is part of the mirror's identity: Build compares it against the
|
|
// scope the mirror was last refreshed under (the metadata table) and
|
|
// forces a full reconciliation scan on any change, so now-out-of-scope
|
|
// rows (and their vectors) are removed and newly-in-scope sessions older
|
|
// than the refresh watermark are picked up. It does not force a re-embed
|
|
// of documents that stay in scope.
|
|
IncludeAutomated bool
|
|
// BatchSize is the encode batch size (config batch_size).
|
|
BatchSize int
|
|
// Concurrency is the number of documents encoded in parallel (config
|
|
// concurrency). Values <= 0 encode sequentially.
|
|
Concurrency int
|
|
// Progress, if non-nil, is called at most ~every 2s with incremental
|
|
// embedding progress, plus once more after the run completes.
|
|
Progress func(BuildProgress)
|
|
}
|
|
|
|
// BuildProgress reports incremental embedding progress during Build. Done
|
|
// and Total are both counted in chunks (not documents): kit's Fill hands the
|
|
// wrapped encoder each document's chunks, sometimes split across several
|
|
// sub-batches when BatchSize is smaller than a document's chunk count, so
|
|
// there is no seam to count completed documents from the encoder wrapper
|
|
// alone. Counting chunks on both sides keeps the percentage bounded at 100%
|
|
// regardless of how many chunks a message splits into.
|
|
type BuildProgress struct {
|
|
Phase string // "scanning" | "embedding"
|
|
Done int64 // chunks encoded so far
|
|
Total int64 // pending chunks at start (approximate denominator)
|
|
}
|
|
|
|
// BuildResult summarizes one Build call.
|
|
type BuildResult struct {
|
|
Fingerprint string
|
|
Activated bool // building generation auto-activated on completion
|
|
Refresh RefreshStats
|
|
Fill kitvec.FillStats
|
|
}
|
|
|
|
// Build runs one embedding pass against gen (the desired vector space, from
|
|
// config: Model, Dimensions, and the fingerprinted Params — max_input_chars,
|
|
// doc_unit_scheme, and chunk_overlap_chars; see vectorGeneration in
|
|
// cmd/agentsview/embeddings.go). It
|
|
// refreshes the mirror, resolves which generation to fill
|
|
// (top-up the active one, start a new building generation, or reset and
|
|
// refill the active one for FullRebuild), fills pending documents, and
|
|
// auto-activates a building generation once it fully covers the mirror.
|
|
func (ix *Index) Build(
|
|
ctx context.Context, src UnitSource, enc kitvec.EncodeFunc,
|
|
gen kitvec.Generation, o BuildOptions,
|
|
) (BuildResult, error) {
|
|
if err := ix.requireWritable(); err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
|
|
firstEver, err := ix.noWatermarkYet(ctx)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
storedScope, hasScope, err := ix.storedIncludeAutomatedScope(ctx)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
// A mirror refreshed before the scope key existed (a refresh watermark
|
|
// is stamped, but scope_include_automated is not) predates this scope
|
|
// feature entirely. Treat that the same as a genuine scope change: force
|
|
// one full reconciliation now so any automated rows that were never
|
|
// meant to be in scope (or, if the config default is true, newly
|
|
// in-scope automated sessions older than the watermark) get resolved,
|
|
// then setIncludeAutomatedScope below stamps the key so every later
|
|
// build compares against a real stored scope again.
|
|
scopeChanged := !hasScope || storedScope != o.IncludeAutomated
|
|
full := o.FullRebuild || o.Backstop || firstEver || scopeChanged
|
|
// Report the scanning phase before the mirror refresh: on a large archive
|
|
// the refresh (and the pending count below) can run for a while with no
|
|
// chunk totals yet, and without a phase report a progress consumer can
|
|
// only render a misleading "0/0 chunks".
|
|
if o.Progress != nil {
|
|
o.Progress(BuildProgress{Phase: "scanning"})
|
|
}
|
|
refreshStats, err := ix.Refresh(ctx, src, full, o.IncludeAutomated)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
if err := ix.setIncludeAutomatedScope(ctx, o.IncludeAutomated); err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
|
|
fp := gen.Fingerprint()
|
|
target, wasBuilding, err := ix.resolveBuildTarget(ctx, gen, fp, o.FullRebuild)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
|
|
total, err := ix.countPending(ctx, target)
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
|
|
wrapped, finish := ix.wrapProgress(enc, total, o.Progress)
|
|
fillStats, fillErr := kitvec.Fill[string, string](ctx, ix.store, target, wrapped, kitvec.FillOptions[string]{
|
|
Split: ix.split,
|
|
Batch: kitvec.BatchOptions{BatchSize: o.BatchSize, Concurrency: 1},
|
|
Concurrency: o.Concurrency,
|
|
OnEncodeError: skipPermanentEncodeError,
|
|
})
|
|
finish()
|
|
result := BuildResult{Fingerprint: target, Refresh: refreshStats, Fill: fillStats}
|
|
if fillErr != nil {
|
|
return result, fillErr
|
|
}
|
|
|
|
activated, err := ix.maybeActivate(ctx, target, wasBuilding)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.Activated = activated
|
|
return result, nil
|
|
}
|
|
|
|
// skipPermanentEncodeError implements kitvec.FillOptions.OnEncodeError: a
|
|
// document the embeddings endpoint permanently rejects for input-specific
|
|
// reasons (e.g. a token-window overflow, whitespace-only content some servers
|
|
// refuse, or a content-policy rejection) is skipped — kit stamps it for the
|
|
// generation with no vectors so it stops being pending — instead of aborting
|
|
// the whole fill. Without this, one poison document would wedge every future
|
|
// build at the same doc_key-ordered scan position: later documents would never
|
|
// embed, a first build would never reach Missing==0, and auto-activation would
|
|
// never fire.
|
|
//
|
|
// Every other failure (5xx, network, timeout, 429 rate-limiting, auth, route,
|
|
// model, media-type, or other config/API failures) still aborts the fill, since
|
|
// the next scheduled build should retry the document rather than silently
|
|
// giving up on it.
|
|
func skipPermanentEncodeError(doc string, err error) bool {
|
|
var statusErr *HTTPStatusError
|
|
if !errors.As(err, &statusErr) || !statusErr.Permanent() {
|
|
return false
|
|
}
|
|
log.Printf("vector build: skipping document %s: permanently rejected by embeddings endpoint: %v",
|
|
doc, err)
|
|
return true
|
|
}
|
|
|
|
// noWatermarkYet reports whether Refresh has never advanced the stored
|
|
// refresh watermark, i.e. this would be the mirror's first scan.
|
|
func (ix *Index) noWatermarkYet(ctx context.Context) (bool, error) {
|
|
watermark, err := ix.refreshWatermark(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return watermark == "", nil
|
|
}
|
|
|
|
// resolveBuildTarget decides which generation fingerprint Build should fill
|
|
// and whether it is a newly (or still) building generation that should be
|
|
// auto-activated once it fully covers the mirror. See the package's build
|
|
// brief for the exact decision table.
|
|
//
|
|
// FullRebuild resets the target generation whenever it already exists —
|
|
// active, building, or retired — not only when it happens to be the active
|
|
// one: a fingerprint that already exists as a retired (or still building)
|
|
// generation carries stamps and vectors from its earlier life, and without
|
|
// a reset EnsureGeneration would reuse them, letting Fill skip every
|
|
// document and silently reactivate stale embeddings instead of performing
|
|
// the requested full rebuild.
|
|
func (ix *Index) resolveBuildTarget(
|
|
ctx context.Context, gen kitvec.Generation, fp string, fullRebuild bool,
|
|
) (target string, wasBuilding bool, err error) {
|
|
active, hasActive, err := ix.ActiveFingerprint(ctx)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
|
|
if hasActive && active == fp {
|
|
if fullRebuild {
|
|
if err := ix.resetGeneration(ctx, fp); err != nil {
|
|
return "", false, err
|
|
}
|
|
}
|
|
// fp is already the active generation, so anything else still in
|
|
// state building was abandoned by an earlier failed first build
|
|
// (the config since reverted back to this active fingerprint) and
|
|
// would otherwise stay building forever: this is the only path
|
|
// through resolveBuildTarget that reaches an active fp without
|
|
// going through EnsureGeneration, which is where the abandoned-gen
|
|
// retirement normally happens.
|
|
if err := ix.retireAbandonedBuildingGenerations(ctx, fp); err != nil {
|
|
return "", false, err
|
|
}
|
|
return fp, false, nil
|
|
}
|
|
|
|
existed, err := ix.generationExists(ctx, fp)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
|
|
target, err = ix.EnsureGeneration(ctx, gen, sqlitevec.StateBuilding)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if err := ix.retireAbandonedBuildingGenerations(ctx, target); err != nil {
|
|
return "", false, err
|
|
}
|
|
if fullRebuild && existed {
|
|
if err := ix.resetGeneration(ctx, target); err != nil {
|
|
return "", false, err
|
|
}
|
|
}
|
|
return target, true, nil
|
|
}
|
|
|
|
// retireAbandonedBuildingGenerations transitions every generation still in
|
|
// state building other than keep to retired. A generation is abandoned
|
|
// when the embedding config (model, dimensions, or params) changes mid
|
|
// first-build: resolveBuildTarget starts a fresh building generation under
|
|
// the new fingerprint, but the old one never got the chance to activate or
|
|
// retire itself and would otherwise stay in state building forever —
|
|
// fingerprintByState's ORDER BY ordinal LIMIT 1 could then report the
|
|
// abandoned generation's stale coverage as BuildingError's percent instead
|
|
// of the generation actually being built.
|
|
//
|
|
// This only changes state; kit's store has no API to drop a generation's
|
|
// vec0 table, chunk map, or stamps, so an abandoned generation's rows stay
|
|
// on disk (bloating vectors.db) until an operator rebuilds vectors.db from
|
|
// scratch or a future kit API adds reclamation.
|
|
func (ix *Index) retireAbandonedBuildingGenerations(ctx context.Context, keep string) error {
|
|
if _, err := ix.db.ExecContext(ctx,
|
|
`UPDATE `+ix.spec.generationsTable()+` SET state = ? WHERE state = ? AND gen_key != ?`,
|
|
string(sqlitevec.StateRetired), string(sqlitevec.StateBuilding), keep,
|
|
); err != nil {
|
|
return fmt.Errorf("retire abandoned building generations: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// generationExists reports whether a generation with fingerprint fp has
|
|
// already been registered, in any state.
|
|
func (ix *Index) generationExists(ctx context.Context, fp string) (bool, error) {
|
|
var ordinal int64
|
|
err := ix.db.QueryRowContext(ctx,
|
|
`SELECT ordinal FROM `+ix.spec.generationsTable()+` WHERE gen_key = ?`, fp,
|
|
).Scan(&ordinal)
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("check generation exists for fingerprint %s: %w", fp, err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// countPending returns the total number of chunks the documents not yet
|
|
// stamped at their current content_hash (for fp's generation) would produce
|
|
// under the index's split configuration — the denominator BuildProgress.Total
|
|
// reports. It counts chunks rather than documents so the denominator stays
|
|
// in the same unit as BuildProgress.Done (chunks encoded so far); see
|
|
// BuildProgress's doc comment for why a per-document count isn't reachable
|
|
// from the encoder wrapper. It applies the same s.revision = d.content_hash
|
|
// predicate generationCoverageQuery's Missing column uses, so a document
|
|
// whose content changed since it was last stamped (a stale revision) counts
|
|
// as pending rather than complete — kit's Fill treats it as pending re-embed
|
|
// for the same reason.
|
|
func (ix *Index) countPending(ctx context.Context, fp string) (int64, error) {
|
|
ordinal, err := ix.ordinalForFingerprint(ctx, fp)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
rows, err := ix.db.QueryContext(ctx, `
|
|
SELECT content FROM `+ix.spec.DocsTable+` d WHERE NOT EXISTS (
|
|
SELECT 1 FROM `+ix.spec.stampsTable()+` s WHERE s.ordinal = ? AND s.doc_key = d.doc_key
|
|
AND s.revision = d.content_hash)`,
|
|
ordinal,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("count pending documents: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var total int64
|
|
for rows.Next() {
|
|
var content string
|
|
if err := rows.Scan(&content); err != nil {
|
|
return 0, fmt.Errorf("scanning pending document content: %w", err)
|
|
}
|
|
total += int64(len(kitvec.Split(content, ix.split)))
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return 0, fmt.Errorf("iterating pending documents: %w", err)
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
// ordinalForFingerprint looks up a generation's generations-table ordinal
|
|
// from its fingerprint (kit's store uses the fingerprint as gen_key).
|
|
func (ix *Index) ordinalForFingerprint(ctx context.Context, fp string) (int64, error) {
|
|
var ordinal int64
|
|
if err := ix.db.QueryRowContext(ctx,
|
|
`SELECT ordinal FROM `+ix.spec.generationsTable()+` WHERE gen_key = ?`, fp,
|
|
).Scan(&ordinal); err != nil {
|
|
return 0, fmt.Errorf("lookup generation ordinal for fingerprint %s: %w", fp, err)
|
|
}
|
|
return ordinal, nil
|
|
}
|
|
|
|
// resetGeneration clears fp's generation of all embedded state (its vec0
|
|
// vectors, chunk map, and stamps) in a single transaction, so a subsequent
|
|
// Fill call re-embeds every document from scratch. It leaves the
|
|
// generation row itself (and its state) untouched.
|
|
func (ix *Index) resetGeneration(ctx context.Context, fp string) error {
|
|
tx, err := ix.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin reset generation: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
var ordinal int64
|
|
if err := tx.QueryRowContext(ctx,
|
|
`SELECT ordinal FROM `+ix.spec.generationsTable()+` WHERE gen_key = ?`, fp,
|
|
).Scan(&ordinal); err != nil {
|
|
return fmt.Errorf("lookup generation ordinal for fingerprint %s: %w", fp, err)
|
|
}
|
|
|
|
vecTable := fmt.Sprintf("%s_v%d", ix.spec.VectorsPrefix, ordinal)
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM `+vecTable); err != nil {
|
|
return fmt.Errorf("clearing vec0 table: %w", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM `+ix.spec.chunksTable()+` WHERE ordinal = ?`, ordinal); err != nil {
|
|
return fmt.Errorf("clearing chunk map: %w", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM `+ix.spec.stampsTable()+` WHERE ordinal = ?`, ordinal); err != nil {
|
|
return fmt.Errorf("clearing stamps: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit reset generation: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// maybeActivate activates target (retiring the previous active generation)
|
|
// when it was a building generation whose fill just brought its coverage
|
|
// of the mirror to zero Missing documents. It is a no-op, returning false,
|
|
// for the active-generation top-up and full-rebuild-in-place cases, which
|
|
// never pass wasBuilding=true.
|
|
func (ix *Index) maybeActivate(ctx context.Context, target string, wasBuilding bool) (bool, error) {
|
|
if !wasBuilding {
|
|
return false, nil
|
|
}
|
|
ordinal, err := ix.ordinalForFingerprint(ctx, target)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
info, err := ix.GenerationByID(ctx, ordinal)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if info.Missing != 0 {
|
|
return false, nil
|
|
}
|
|
if err := ix.activateGeneration(ctx, target); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// activateGeneration retires whichever generation is currently active
|
|
// (other than target, a no-op when there is none) and activates target,
|
|
// in one transaction.
|
|
func (ix *Index) activateGeneration(ctx context.Context, target string) error {
|
|
tx, err := ix.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin activate generation: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE `+ix.spec.generationsTable()+` SET state = ? WHERE state = ? AND gen_key != ?`,
|
|
string(sqlitevec.StateRetired), string(sqlitevec.StateActive), target,
|
|
); err != nil {
|
|
return fmt.Errorf("retire old active generation: %w", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE `+ix.spec.generationsTable()+` SET state = ? WHERE gen_key = ?`,
|
|
string(sqlitevec.StateActive), target,
|
|
); err != nil {
|
|
return fmt.Errorf("activate generation: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit activate generation: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// wrapProgress wraps enc so every successful encode call atomically adds
|
|
// its chunk count to a running total and, when onProgress is non-nil,
|
|
// reports it at most once per progressInterval. The returned finish func
|
|
// always reports the final count once, regardless of the interval, so the
|
|
// caller sees a Done total matching the completed (or aborted) run.
|
|
func (ix *Index) wrapProgress(
|
|
enc kitvec.EncodeFunc, total int64, onProgress func(BuildProgress),
|
|
) (kitvec.EncodeFunc, func()) {
|
|
if onProgress == nil {
|
|
return enc, func() {}
|
|
}
|
|
|
|
var (
|
|
done atomic.Int64
|
|
mu sync.Mutex
|
|
last time.Time
|
|
)
|
|
report := func(force bool) {
|
|
mu.Lock()
|
|
if !force && time.Since(last) < progressInterval {
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
last = time.Now()
|
|
mu.Unlock()
|
|
onProgress(BuildProgress{
|
|
Phase: "embedding",
|
|
Done: done.Load(),
|
|
Total: total,
|
|
})
|
|
}
|
|
|
|
wrapped := func(ctx context.Context, texts []string) ([][]float32, error) {
|
|
vectors, err := enc(ctx, texts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
done.Add(int64(len(texts)))
|
|
report(false)
|
|
return vectors, nil
|
|
}
|
|
return wrapped, func() { report(true) }
|
|
}
|