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

430 lines
16 KiB
Go

package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sync"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/vector"
)
// QueryEncodeFunc embeds a single query string into the generation's vector
// space. It is the read-side counterpart of the build-time encoder, supplied
// by pg serve when it wires the searcher; a returned error means the
// embeddings endpoint failed for this request (transient), not that semantic
// search is unconfigured.
type QueryEncodeFunc func(ctx context.Context, text string) ([]float32, error)
// vectorSearcher is the PG-backed db.VectorSearcher: chunk-level KNN over one
// generation's pgvector chunk table, doc-level rollup, and hydration against
// the shared vector_documents mirror. It mirrors internal/vector's Index
// searcher semantics (Search + hydrateHits + resolveHit) for the PG backend.
type vectorSearcher struct {
pg *sql.DB
genID int64
dimension int
maxInputChars int
encode QueryEncodeFunc
chunkTable string
// schemaMu guards the lazily resolved, quoted pgvector extension schema.
// The constructor takes no context, so it is resolved on first query
// (mirroring vector_push.go's vectorGeneration.halfvecType): both the
// ::halfvec cast and the `<=>` cosine operator must be schema-qualified,
// or they fail to resolve when pgvector lives in a schema off search_path.
schemaMu sync.Mutex
extSchema string
}
// NewVectorSearcher builds a PG vector searcher for generation genID. The
// chunk table (vector_chunks_g<genID>) and dimension must match what the push
// phase created; maxInputChars is the build-time chunk size, threaded into
// vector.DocAnchor so anchor/snippet re-splitting matches how chunks were cut.
func NewVectorSearcher(
pg *sql.DB, genID int64, dimension, maxInputChars int, encode QueryEncodeFunc,
) db.VectorSearcher {
return &vectorSearcher{
pg: pg,
genID: genID,
dimension: dimension,
maxInputChars: maxInputChars,
encode: encode,
chunkTable: vectorChunkTable(genID),
}
}
// resolveExtSchema returns the quoted schema pgvector's types and operators
// live in, resolving it once from pg_extension. It is cached because the
// pgvector schema cannot change for a live connection pool.
func (v *vectorSearcher) resolveExtSchema(ctx context.Context) (string, error) {
v.schemaMu.Lock()
defer v.schemaMu.Unlock()
if v.extSchema != "" {
return v.extSchema, nil
}
schema, err := vectorExtensionSchema(ctx, v.pg)
if err != nil {
return "", fmt.Errorf("resolving pgvector schema: %w", err)
}
v.extSchema = schema
return v.extSchema, nil
}
// SemanticSearch embeds query, runs chunk-level KNN over the generation's
// chunk table, rolls chunks up to one hit per document (best chunk wins,
// score order preserved), truncates to limit documents, and hydrates each
// against vector_documents. It mirrors vector.Index.Search + searcherAdapter:
// an encoder failure is wrapped in db.ErrSemanticTransient; a document that
// vanished (or was parked at a negative-ordinal tombstone) between KNN and
// hydration is dropped rather than erroring.
func (v *vectorSearcher) SemanticSearch(
ctx context.Context, query string, limit int,
) ([]db.VectorHit, error) {
vec, err := v.encode(ctx, query)
if err != nil {
return nil, fmt.Errorf("%w: %v", db.ErrSemanticTransient, err)
}
if len(vec) != v.dimension {
return nil, fmt.Errorf(
"query embedding has %d dimensions, generation expects %d",
len(vec), v.dimension)
}
extSchema, err := v.resolveExtSchema(ctx)
if err != nil {
return nil, err
}
chunks, err := v.knnChunks(ctx, vec, extSchema, limit)
if err != nil {
return nil, err
}
docs := rollupChunkHits(chunks, limit)
if len(docs) == 0 {
return nil, nil
}
return v.hydrateHits(ctx, docs)
}
// chunkHit is one chunk-level KNN neighbor: the document it belongs to, which
// chunk matched, and its cosine similarity (1 - cosine distance, higher is
// better).
type chunkHit struct {
docKey string
chunkIndex int
score float32
}
// knnChunks runs the chunk-level KNN, returning up to exactly limit neighbors
// ordered best (nearest) first. Fetching exactly `limit` chunks matches the
// local sqlite-vec searcher's candidate pool (kit's queryGenerationSQL also
// runs its KNN with LIMIT k before rollup), per the backend-parity rule: after
// rollup the doc pool can be smaller than `limit` when a high-ranking run doc
// contributes several neighbors, and both backends must shrink identically.
// The query vector binds once as a halfvec-cast parameter; both the cast and
// the cosine `<=>` operator are schema-qualified (::schema.halfvec,
// OPERATOR(schema.<=>)) so they resolve when pgvector lives in a schema off
// the connection's search_path.
//
// Recall contract: results are approximate ANN (HNSW), not the local backend's
// exact brute-force scan — that divergence is the accepted halfvec/HNSW spec
// trade-off. But the candidate pool must not silently cap below k: pgvector's
// default hnsw.ef_search (40) would return only ~40 rows regardless of LIMIT k.
// The KNN therefore runs inside a transaction with hnsw.ef_search set to k
// clamped to [40, 1000] (see tuneHNSWRecall / hnswEfSearch): small-k searches
// keep the stock 40 floor, larger k widens the pool up to pgvector's 1000
// ceiling, and past that ceiling iterative scan keeps filling.
func (v *vectorSearcher) knnChunks(
ctx context.Context, vec []float32, extSchema string, limit int,
) ([]chunkHit, error) {
k := max(limit, 0)
literal, err := halfvecLiteral(vec)
if err != nil {
return nil, fmt.Errorf("query embedding: %w", err)
}
dist := fmt.Sprintf("embedding OPERATOR(%s.<=>) $1::%s.halfvec", extSchema, extSchema)
q := fmt.Sprintf(`
SELECT doc_key, chunk_index, 1 - (%s) AS score
FROM %s
ORDER BY %s
LIMIT $2`, dist, v.chunkTable, dist)
tx, err := v.pg.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
if err != nil {
return nil, fmt.Errorf("begin chunk knn tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := tuneHNSWRecall(ctx, tx, k); err != nil {
return nil, err
}
rows, err := tx.QueryContext(ctx, q, literal, k)
if err != nil {
return nil, fmt.Errorf("chunk knn query: %w", err)
}
defer func() { _ = rows.Close() }()
var hits []chunkHit
for rows.Next() {
var h chunkHit
var score float64
if err := rows.Scan(&h.docKey, &h.chunkIndex, &score); err != nil {
return nil, fmt.Errorf("scanning chunk knn row: %w", err)
}
h.score = float32(score)
hits = append(hits, h)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating chunk knn rows: %w", err)
}
return hits, nil
}
// tuneHNSWRecall raises pgvector's per-scan HNSW candidate pool for the current
// transaction so a KNN with LIMIT k returns k neighbors instead of silently
// capping at the default hnsw.ef_search (40). ef_search is set to k clamped to
// [40, 1000] — the recall knob is never tuned below its default, so small-k
// searches keep stock recall — and never above pgvector's ceiling. k is an int
// derived from the caller's limit and interpolated only after clamping, so the
// SET LOCAL statement carries no unvalidated input. SET LOCAL requires a
// transaction and resets on commit/rollback, so this never leaks into a pooled
// connection.
//
// For k above the 1000 ceiling, ef_search alone cannot fill the pool, so
// hnsw.iterative_scan = 'relaxed_order' (pgvector >= 0.8) lets the scan keep
// going past ef_search. Older pgvector lacks that GUC and would abort the tx on
// an unknown-GUC error, so it is probed first with current_setting(..., true)
// (missing_ok), which yields NULL instead of erroring; a NULL result skips the
// setting and leaves the 1000-row cap in place.
// hnswEfSearch clamps the KNN candidate-pool size k to pgvector's usable
// hnsw.ef_search range [40, 1000]: never below the stock default 40 (so small-k
// searches keep default recall) and never above pgvector's 1000 ceiling (past
// which ef_search alone cannot widen the pool — tuneHNSWRecall falls back to
// iterative scan there).
func hnswEfSearch(k int) int {
return min(max(k, 40), 1000)
}
func tuneHNSWRecall(ctx context.Context, tx *sql.Tx, k int) error {
efSearch := hnswEfSearch(k)
if _, err := tx.ExecContext(ctx,
fmt.Sprintf("SET LOCAL hnsw.ef_search = %d", efSearch)); err != nil {
return fmt.Errorf("setting hnsw.ef_search: %w", err)
}
if k <= 1000 {
return nil
}
var current sql.NullString
if err := tx.QueryRowContext(ctx,
`SELECT current_setting('hnsw.iterative_scan', true)`,
).Scan(&current); err != nil {
return fmt.Errorf("probing hnsw.iterative_scan: %w", err)
}
if !current.Valid {
return nil
}
if _, err := tx.ExecContext(ctx,
"SET LOCAL hnsw.iterative_scan = 'relaxed_order'"); err != nil {
return fmt.Errorf("setting hnsw.iterative_scan: %w", err)
}
return nil
}
// rollupChunkHits collapses chunk hits to one hit per document, keeping the
// first (best) chunk seen per doc_key and preserving order, then truncates to
// limit documents. hits must already be ordered best-first (the KNN query
// orders by distance), so first-seen equals best-scoring — matching kit's
// RollupByDocument intent without a re-sort.
func rollupChunkHits(hits []chunkHit, limit int) []chunkHit {
seen := make(map[string]struct{}, len(hits))
out := make([]chunkHit, 0, len(hits))
for _, h := range hits {
if _, ok := seen[h.docKey]; ok {
continue
}
seen[h.docKey] = struct{}{}
out = append(out, h)
}
if limit >= 0 && len(out) > limit {
out = out[:limit]
}
return out
}
// vectorDoc is the subset of a vector_documents row needed to hydrate a chunk
// hit into a db.VectorHit. offsets is empty for user documents and carries one
// entry per member for run documents.
type vectorDoc struct {
sessionID string
ordinal int
ordinalEnd int
subordinate bool
offsets []db.UnitOffset
content string
}
// hydrateHits looks up each hit's document row and builds its db.VectorHit,
// resolving the anchor ordinal and display snippet via vector.DocAnchor (a run
// hit anchors to the member whose rune span contains the matched chunk's
// center, with a member-local snippet; a user hit anchors to its own ordinal).
// A hit whose document vanished from vector_documents mid-flight, or is parked
// at a negative-ordinal tombstone, is absent from the lookup and dropped.
func (v *vectorSearcher) hydrateHits(
ctx context.Context, hits []chunkHit,
) ([]db.VectorHit, error) {
docKeys := make([]string, len(hits))
for i, h := range hits {
docKeys[i] = h.docKey
}
docs, err := v.lookupDocs(ctx, docKeys)
if err != nil {
return nil, err
}
out := make([]db.VectorHit, 0, len(hits))
for _, h := range hits {
doc, ok := docs[h.docKey]
if !ok {
continue
}
anchorOrdinal, snippet := vector.DocAnchor(
doc.content, doc.offsets, doc.ordinal, h.chunkIndex, v.maxInputChars)
out = append(out, db.VectorHit{
SessionID: doc.sessionID,
Ordinal: anchorOrdinal,
OrdinalStart: doc.ordinal,
OrdinalEnd: doc.ordinalEnd,
Subordinate: doc.subordinate,
Score: h.score,
Snippet: snippet,
})
}
return out, nil
}
// lookupDocs reads the document rows for docKeys, keyed by doc_key. The whole
// key set binds as one array parameter (pgx expands ANY natively), so no IN
// chunking is needed. The `ordinal >= 0` guard excludes tombstone rows parked
// at a negative sentinel by the vector push's slot replacement, mirroring
// internal/vector's lookupMirrorDocs: a mid-refresh tombstone must never
// hydrate into a hit carrying a negative ordinal.
func (v *vectorSearcher) lookupDocs(
ctx context.Context, docKeys []string,
) (map[string]vectorDoc, error) {
docs := make(map[string]vectorDoc, len(docKeys))
if len(docKeys) == 0 {
return docs, nil
}
rows, err := v.pg.QueryContext(ctx, `
SELECT doc_key, session_id, ordinal, ordinal_end, subordinate, offsets, content
FROM vector_documents
WHERE ordinal >= 0 AND doc_key = ANY($1)`, docKeys)
if err != nil {
return nil, fmt.Errorf("looking up search hit documents: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var key, offsets string
var doc vectorDoc
if err := rows.Scan(&key, &doc.sessionID, &doc.ordinal,
&doc.ordinalEnd, &doc.subordinate, &offsets, &doc.content); err != nil {
return nil, fmt.Errorf("scanning search hit document: %w", err)
}
if err := json.Unmarshal([]byte(offsets), &doc.offsets); err != nil {
return nil, fmt.Errorf("parsing offsets for search hit %s: %w", key, err)
}
docs[key] = doc
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating search hit documents: %w", err)
}
return docs, nil
}
// ResolveMessageUnits maps each ref to the vector_documents unit containing
// it, returning a slice parallel to refs; a ref with no containing unit (its
// message lies outside the embeddable universe, or in a gap between units)
// yields a zero db.UnitRef. Each ref is a point lookup — greatest unit ordinal
// <= ref ordinal, then a containment check against ordinal_end — via one
// prepared statement, so a batch of any size never approaches PG's bind limit.
// The `ordinal >= 0` guard skips tombstone rows parked at a negative sentinel,
// so a ref can never resolve into mid-refresh state (parity with
// internal/vector's ResolveMessageUnits).
func (v *vectorSearcher) ResolveMessageUnits(
ctx context.Context, refs []db.MessageRef,
) ([]db.UnitRef, error) {
out := make([]db.UnitRef, len(refs))
if len(refs) == 0 {
return out, nil
}
stmt, err := v.pg.PrepareContext(ctx, `
SELECT doc_key, ordinal, ordinal_end, subordinate
FROM vector_documents
WHERE session_id = $1 AND ordinal >= 0 AND ordinal <= $2
ORDER BY ordinal DESC LIMIT 1`)
if err != nil {
return nil, fmt.Errorf("resolve message units: %w", err)
}
defer func() { _ = stmt.Close() }()
for i, ref := range refs {
var unit db.UnitRef
err := stmt.QueryRowContext(ctx, ref.SessionID, ref.Ordinal).Scan(
&unit.DocKey, &unit.OrdinalStart, &unit.OrdinalEnd, &unit.Subordinate)
if errors.Is(err, sql.ErrNoRows) {
continue
}
if err != nil {
return nil, fmt.Errorf(
"resolve message unit (%s, %d): %w", ref.SessionID, ref.Ordinal, err)
}
if ref.Ordinal > unit.OrdinalEnd {
continue
}
unit.SessionID = ref.SessionID
out[i] = unit
}
return out, nil
}
// SetVectorSearcher wires (or, with nil, clears) the PG semantic search
// backend. Safe to call concurrently with SearchContent/HasSemantic.
func (s *Store) SetVectorSearcher(searcher db.VectorSearcher) {
s.vectorMu.Lock()
defer s.vectorMu.Unlock()
s.vectorSearcher = searcher
}
// getVectorSearcher returns the currently wired PG vector searcher, or nil.
func (s *Store) getVectorSearcher() db.VectorSearcher {
s.vectorMu.RLock()
defer s.vectorMu.RUnlock()
return s.vectorSearcher
}
// SetSemanticUnavailableReason records a human explanation for why semantic
// search could not be wired (extension missing, no matching generation, stale
// build), surfaced by semanticUnavailableError. Safe to call concurrently.
func (s *Store) SetSemanticUnavailableReason(reason string) {
s.vectorMu.Lock()
defer s.vectorMu.Unlock()
s.semanticUnavailableReason = reason
}
// semanticUnavailableError wraps db.ErrSemanticUnavailable with the recorded
// reason when one is set, so a caller (SearchContent's semantic/hybrid modes)
// can explain why semantic search is off; without a reason it returns the
// bare sentinel.
func (s *Store) semanticUnavailableError() error {
s.vectorMu.RLock()
reason := s.semanticUnavailableReason
s.vectorMu.RUnlock()
if reason == "" {
return db.ErrSemanticUnavailable
}
return fmt.Errorf("%w: %s", db.ErrSemanticUnavailable, reason)
}