Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

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) }
}