f99010fae1
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
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
319 lines
12 KiB
Go
319 lines
12 KiB
Go
//go:build pgtest
|
|
|
|
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"go.kenn.io/agentsview/internal/db"
|
|
)
|
|
|
|
// searchMaxInputChars is the chunk size the searcher re-splits content with to
|
|
// resolve anchors/snippets. It is small so the run fixture's short content
|
|
// re-splits into multiple chunks (see runContent), exercising member anchoring.
|
|
const searchMaxInputChars = 20
|
|
|
|
// fixedEncoder returns a QueryEncodeFunc that always emits vec, ignoring the
|
|
// query text: the fixture's chunk vectors are chosen so a fixed query yields a
|
|
// deterministic cosine ranking.
|
|
func fixedEncoder(vec []float32) QueryEncodeFunc {
|
|
return func(context.Context, string) ([]float32, error) { return vec, nil }
|
|
}
|
|
|
|
// newVectorSearchTestPG drops and recreates the schema with the base vector
|
|
// tables and skips when pgvector is unavailable.
|
|
func newVectorSearchTestPG(t *testing.T, pgURL, schema string) *sql.DB {
|
|
t.Helper()
|
|
pg, err := Open(pgURL, schema, true)
|
|
require.NoError(t, err, "Open")
|
|
t.Cleanup(func() { _ = pg.Close() })
|
|
|
|
ctx := context.Background()
|
|
_, err = pg.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`)
|
|
require.NoError(t, err, "drop schema")
|
|
require.NoError(t, EnsureSchema(ctx, pg, schema), "EnsureSchema")
|
|
|
|
unavailable, err := ensureVectorBaseSchemaPG(ctx, pg)
|
|
require.NoError(t, err, "ensureVectorBaseSchemaPG")
|
|
if unavailable != "" {
|
|
t.Skip(unavailable)
|
|
}
|
|
return pg
|
|
}
|
|
|
|
func insertSearchDoc(
|
|
t *testing.T, pg *sql.DB,
|
|
docKey, sessionID string, ordinal, ordinalEnd int,
|
|
offsets, content string,
|
|
) {
|
|
t.Helper()
|
|
_, err := pg.Exec(`
|
|
INSERT INTO vector_documents (
|
|
doc_key, session_id, source_uuid, ordinal, ordinal_end,
|
|
subordinate, offsets, content, content_hash)
|
|
VALUES ($1, $2, '', $3, $4, FALSE, $5, $6, 'h')`,
|
|
docKey, sessionID, ordinal, ordinalEnd, offsets, content)
|
|
require.NoError(t, err, "insert doc "+docKey)
|
|
}
|
|
|
|
func insertSearchChunk(
|
|
t *testing.T, pg *sql.DB, table, halfvecType, docKey string,
|
|
chunkIndex int, emb []float32,
|
|
) {
|
|
t.Helper()
|
|
literal, err := halfvecLiteral(emb)
|
|
require.NoError(t, err, "halfvecLiteral "+docKey)
|
|
_, err = pg.Exec(
|
|
`INSERT INTO `+table+` (doc_key, chunk_index, embedding)
|
|
VALUES ($1, $2, $3::`+halfvecType+`)`,
|
|
docKey, chunkIndex, literal)
|
|
require.NoError(t, err, "insert chunk "+docKey)
|
|
}
|
|
|
|
// seedVectorSearchFixture builds a deterministic KNN fixture for query
|
|
// [1,0,0,0]. Cosine similarity ignores magnitude, so the chunk directions are
|
|
// spread across the x/y plane to give distinct, unambiguous scores by doc's
|
|
// best chunk:
|
|
//
|
|
// u1 [1,0,0,0] 1.000 user doc, ordinal 5
|
|
// r1 [1,0.2,0,0] 0.981 run doc chunk 1 (chunk 0 is [0,0,1,0] -> 0)
|
|
// tomb [0.95,0.3122,0,0] 0.950 parked at negative ordinal (tombstone)
|
|
// gone [0.9,0.4359,0,0] 0.900 doc row deleted, chunk kept
|
|
// u2 [0,1,0,0] 0.000 user doc, ordinal 20
|
|
//
|
|
// tomb and gone must be dropped during hydration, leaving [u1, r1, u2].
|
|
func seedVectorSearchFixture(t *testing.T, pg *sql.DB) (genID int64, table string) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
genID, err := ensureVectorGeneration(ctx, pg, "fp-search", "m", 4)
|
|
require.NoError(t, err, "ensureVectorGeneration")
|
|
require.NoError(t, ensureVectorChunkTable(ctx, pg, genID, 4), "ensureVectorChunkTable")
|
|
extSchema, err := vectorExtensionSchema(ctx, pg)
|
|
require.NoError(t, err, "vectorExtensionSchema")
|
|
halfvec := extSchema + ".halfvec"
|
|
table = vectorChunkTable(genID)
|
|
|
|
insertSearchDoc(t, pg, "u1", "S", 5, 5, "[]", "alpha user content")
|
|
insertSearchDoc(t, pg, "r1", "S", 10, 12, runOffsets, runContent)
|
|
insertSearchDoc(t, pg, "u2", "S", 20, 20, "[]", "gamma")
|
|
insertSearchDoc(t, pg, "gone", "S", 30, 30, "[]", "vanished")
|
|
insertSearchDoc(t, pg, "tomb", "S", -1, -1, "[]", "parked tombstone")
|
|
|
|
insertSearchChunk(t, pg, table, halfvec, "u1", 0, []float32{1, 0, 0, 0})
|
|
insertSearchChunk(t, pg, table, halfvec, "r1", 0, []float32{0, 0, 1, 0})
|
|
insertSearchChunk(t, pg, table, halfvec, "r1", 1, []float32{1, 0.2, 0, 0})
|
|
insertSearchChunk(t, pg, table, halfvec, "u2", 0, []float32{0, 1, 0, 0})
|
|
insertSearchChunk(t, pg, table, halfvec, "gone", 0, []float32{0.9, 0.4359, 0, 0})
|
|
insertSearchChunk(t, pg, table, halfvec, "tomb", 0, []float32{0.95, 0.3122, 0, 0})
|
|
|
|
// gone's chunk stays; its document row disappears between KNN and hydrate.
|
|
_, err = pg.Exec(`DELETE FROM vector_documents WHERE doc_key = 'gone'`)
|
|
require.NoError(t, err, "delete gone doc")
|
|
return genID, table
|
|
}
|
|
|
|
// runContent joins two run members with "\n\n"; runOffsets records their rune
|
|
// starts (ordinals 10 and 12). With searchMaxInputChars=20 the content
|
|
// re-splits so chunk index 1's center falls inside member B (ordinal 12).
|
|
var (
|
|
runMemberA = strings.Repeat("a", 17)
|
|
runMemberB = strings.Repeat("b", 17)
|
|
runContent = runMemberA + "\n\n" + runMemberB
|
|
runOffsets = `[{"o":10,"r":0,"b":0},{"o":12,"r":19,"b":19}]`
|
|
)
|
|
|
|
func TestPGVectorSearcher(t *testing.T) {
|
|
pgURL := testPGURL(t)
|
|
pg := newVectorSearchTestPG(t, pgURL, "agentsview_vector_search_test")
|
|
ctx := context.Background()
|
|
|
|
genID, _ := seedVectorSearchFixture(t, pg)
|
|
searcher := NewVectorSearcher(
|
|
pg, genID, 4, searchMaxInputChars, fixedEncoder([]float32{1, 0, 0, 0}))
|
|
|
|
t.Run("ranking rollup hydration tombstone", func(t *testing.T) {
|
|
hits, err := searcher.SemanticSearch(ctx, "query", 10)
|
|
require.NoError(t, err, "SemanticSearch")
|
|
|
|
keys := make([]string, len(hits))
|
|
for i, h := range hits {
|
|
keys[i] = h.SessionID
|
|
}
|
|
require.Len(t, hits, 3, "gone and tomb dropped, r1 rolled to one hit")
|
|
|
|
// Ranking order: u1 (1.0) > r1 (0.992) > u2 (0.0).
|
|
assert.Equal(t, 5, hits[0].Ordinal, "u1 first")
|
|
assert.Equal(t, "alpha user content", hits[0].Snippet)
|
|
assert.InDelta(t, 1.0, hits[0].Score, 1e-3, "u1 cosine ~1.0")
|
|
assert.Greater(t, hits[0].Score, hits[1].Score, "scores descend")
|
|
assert.Greater(t, hits[1].Score, hits[2].Score, "scores descend")
|
|
|
|
// r1 is the run doc: anchor to member B (ordinal 12), member-local snippet.
|
|
r1 := hits[1]
|
|
assert.Equal(t, 12, r1.Ordinal, "run hit anchors to member B")
|
|
assert.Equal(t, 10, r1.OrdinalStart)
|
|
assert.Equal(t, 12, r1.OrdinalEnd)
|
|
assert.Equal(t, runMemberB, r1.Snippet, "member-local snippet")
|
|
assert.False(t, r1.Subordinate)
|
|
|
|
// u2 last; tombstone and vanished doc absent.
|
|
assert.Equal(t, 20, hits[2].Ordinal)
|
|
for _, h := range hits {
|
|
assert.GreaterOrEqual(t, h.Ordinal, 0, "no negative-ordinal tombstone hydrated")
|
|
}
|
|
})
|
|
|
|
t.Run("limit truncates documents", func(t *testing.T) {
|
|
hits, err := searcher.SemanticSearch(ctx, "query", 1)
|
|
require.NoError(t, err)
|
|
require.Len(t, hits, 1)
|
|
assert.Equal(t, 5, hits[0].Ordinal, "only top-ranked u1")
|
|
})
|
|
|
|
t.Run("encoder error is transient", func(t *testing.T) {
|
|
bad := NewVectorSearcher(pg, genID, 4, searchMaxInputChars,
|
|
func(context.Context, string) ([]float32, error) {
|
|
return nil, errors.New("embeddings endpoint down")
|
|
})
|
|
_, err := bad.SemanticSearch(ctx, "query", 5)
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, db.ErrSemanticTransient),
|
|
"encoder failure surfaces db.ErrSemanticTransient")
|
|
})
|
|
|
|
t.Run("dimension mismatch is rejected", func(t *testing.T) {
|
|
wrongDim := NewVectorSearcher(pg, genID, 4, searchMaxInputChars,
|
|
fixedEncoder([]float32{1, 0, 0}))
|
|
_, err := wrongDim.SemanticSearch(ctx, "query", 5)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "dimensions")
|
|
})
|
|
|
|
t.Run("resolve message units", func(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ordinal int
|
|
wantDoc string // "" means zero UnitRef
|
|
wantStart int
|
|
wantEnd int
|
|
}{
|
|
{"inside unit", 11, "r1", 10, 12},
|
|
{"exact ordinal_end boundary", 12, "r1", 10, 12},
|
|
{"gap between units", 15, "", 0, 0},
|
|
{"before first unit", 2, "", 0, 0},
|
|
{"tombstone guard", 3, "", 0, 0}, // without ordinal>=0, tomb(-1) matches
|
|
{"user unit", 20, "u2", 20, 20},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
units, err := searcher.ResolveMessageUnits(ctx,
|
|
[]db.MessageRef{{SessionID: "S", Ordinal: tt.ordinal}})
|
|
require.NoError(t, err)
|
|
require.Len(t, units, 1)
|
|
u := units[0]
|
|
assert.Equal(t, tt.wantDoc, u.DocKey)
|
|
if tt.wantDoc == "" {
|
|
assert.Equal(t, db.UnitRef{}, u, "zero UnitRef")
|
|
return
|
|
}
|
|
assert.Equal(t, "S", u.SessionID)
|
|
assert.Equal(t, tt.wantStart, u.OrdinalStart)
|
|
assert.Equal(t, tt.wantEnd, u.OrdinalEnd)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("resolve preserves ref order and handles empty", func(t *testing.T) {
|
|
empty, err := searcher.ResolveMessageUnits(ctx, nil)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, empty)
|
|
|
|
units, err := searcher.ResolveMessageUnits(ctx, []db.MessageRef{
|
|
{SessionID: "S", Ordinal: 15}, // gap -> zero
|
|
{SessionID: "S", Ordinal: 11}, // r1
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, units, 2)
|
|
assert.Equal(t, db.UnitRef{}, units[0])
|
|
assert.Equal(t, "r1", units[1].DocKey)
|
|
})
|
|
}
|
|
|
|
// TestPGVectorSearcherExactK pins the exact-k chunk fetch: a single doc whose
|
|
// chunks occupy all top-k chunk ranks rolls up to one hit, and a next-best doc
|
|
// whose only chunk sits at rank k+1 never enters the result. Over-fetching more
|
|
// than k chunks would surface the outside-k doc, so this guards the LIMIT k
|
|
// candidate pool that keeps PG parity with the local sqlite-vec searcher.
|
|
func TestPGVectorSearcherExactK(t *testing.T) {
|
|
pgURL := testPGURL(t)
|
|
pg := newVectorSearchTestPG(t, pgURL, "agentsview_vector_search_exactk_test")
|
|
ctx := context.Background()
|
|
|
|
genID, err := ensureVectorGeneration(ctx, pg, "fp-exactk", "m", 4)
|
|
require.NoError(t, err, "ensureVectorGeneration")
|
|
require.NoError(t, ensureVectorChunkTable(ctx, pg, genID, 4), "ensureVectorChunkTable")
|
|
extSchema, err := vectorExtensionSchema(ctx, pg)
|
|
require.NoError(t, err, "vectorExtensionSchema")
|
|
halfvec := extSchema + ".halfvec"
|
|
table := vectorChunkTable(genID)
|
|
|
|
// "multi" contributes the two nearest chunks (ranks 1 and 2 for query
|
|
// [1,0,0,0]); "outside" is next-best at rank 3, beyond k=2.
|
|
insertSearchDoc(t, pg, "multi", "S", 1, 1, "[]", "multi doc content")
|
|
insertSearchDoc(t, pg, "outside", "S", 2, 2, "[]", "outside doc content")
|
|
insertSearchChunk(t, pg, table, halfvec, "multi", 0, []float32{1, 0, 0, 0})
|
|
insertSearchChunk(t, pg, table, halfvec, "multi", 1, []float32{1, 0.1, 0, 0})
|
|
insertSearchChunk(t, pg, table, halfvec, "outside", 0, []float32{0, 1, 0, 0})
|
|
|
|
searcher := NewVectorSearcher(
|
|
pg, genID, 4, searchMaxInputChars, fixedEncoder([]float32{1, 0, 0, 0}))
|
|
hits, err := searcher.SemanticSearch(ctx, "query", 2)
|
|
require.NoError(t, err, "SemanticSearch")
|
|
require.Len(t, hits, 1, "both top-2 chunks belong to multi -> one rolled-up doc")
|
|
assert.Equal(t, 1, hits[0].Ordinal, "only multi survives; outside sits beyond k")
|
|
}
|
|
|
|
// TestPGVectorSearcherEfSearchRecall pins the HNSW recall contract: with a
|
|
// candidate pool larger than pgvector's default hnsw.ef_search (40), a search
|
|
// for k neighbors must return k docs, not ~40. The searcher raises ef_search to
|
|
// k inside the query transaction; without that the KNN silently caps the pool
|
|
// below k, diverging from the local backend's exact scan.
|
|
func TestPGVectorSearcherEfSearchRecall(t *testing.T) {
|
|
pgURL := testPGURL(t)
|
|
pg := newVectorSearchTestPG(t, pgURL, "agentsview_vector_search_efsearch_test")
|
|
ctx := context.Background()
|
|
|
|
genID, err := ensureVectorGeneration(ctx, pg, "fp-efsearch", "m", 4)
|
|
require.NoError(t, err, "ensureVectorGeneration")
|
|
require.NoError(t, ensureVectorChunkTable(ctx, pg, genID, 4), "ensureVectorChunkTable")
|
|
extSchema, err := vectorExtensionSchema(ctx, pg)
|
|
require.NoError(t, err, "vectorExtensionSchema")
|
|
halfvec := extSchema + ".halfvec"
|
|
table := vectorChunkTable(genID)
|
|
|
|
// 100 distinct docs (> the ef_search default of 40), one chunk each. The
|
|
// vectors spread across the plane so all are neighbors of the query.
|
|
const docCount = 100
|
|
for i := 0; i < docCount; i++ {
|
|
key := fmt.Sprintf("d%d", i)
|
|
insertSearchDoc(t, pg, key, "S", i, i, "[]", fmt.Sprintf("doc %d", i))
|
|
emb := []float32{1, float32(i) / float32(docCount), 0, 0}
|
|
insertSearchChunk(t, pg, table, halfvec, key, 0, emb)
|
|
}
|
|
|
|
searcher := NewVectorSearcher(
|
|
pg, genID, 4, searchMaxInputChars, fixedEncoder([]float32{1, 0, 0, 0}))
|
|
hits, err := searcher.SemanticSearch(ctx, "query", docCount)
|
|
require.NoError(t, err, "SemanticSearch")
|
|
assert.Len(t, hits, docCount,
|
|
"ef_search raised to k returns all %d docs, not the ~40 default cap", docCount)
|
|
}
|