714 lines
25 KiB
Go
714 lines
25 KiB
Go
package knowledge_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
|
"github.com/krow/krow-backend/go-api/internal/knowledge"
|
|
"github.com/krow/krow-backend/go-api/internal/testutil"
|
|
)
|
|
|
|
// The knowledge layer's tests are almost entirely about who can see what.
|
|
//
|
|
// Retrieval quality is deliberately NOT asserted here, and it would be dishonest
|
|
// to try: these run on the lexical stand-in embedder, which hashes words into a
|
|
// vector and is not semantic. A test claiming "'time off' retrieves the annual
|
|
// leave paragraph" would pass or fail on word overlap and would tell you nothing
|
|
// about the system with a real embedder in it.
|
|
//
|
|
// What IS testable without a credential, and what actually carries the
|
|
// invariants, is everything else: that the permission predicate runs before
|
|
// scoring, that a caller cannot reach another tenant's corpus, that ingest
|
|
// refuses a document nobody can read, that fusion is deterministic, and that a
|
|
// document cannot break out of its context block. Those hold or fail
|
|
// identically whichever embedder is underneath.
|
|
|
|
/* ── Fixtures ───────────────────────────────────────────────────────────── */
|
|
|
|
type corpus struct {
|
|
orgID string
|
|
admin authctx.Identity
|
|
talent authctx.Identity
|
|
other authctx.Identity // an admin in a different tenant
|
|
}
|
|
|
|
func freshOrg(t *testing.T, h *testutil.Harness, slug string) string {
|
|
t.Helper()
|
|
var id string
|
|
if err := h.Pool.QueryRow(context.Background(),
|
|
`INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id::text`,
|
|
slug, slug).Scan(&id); err != nil {
|
|
t.Fatalf("create org %s: %v", slug, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// seedCorpus ingests four documents whose audiences differ, in two tenants.
|
|
//
|
|
// The shapes matter. Each document is reachable by exactly one interesting set
|
|
// of callers, so a leak in any direction is a specific, nameable failure rather
|
|
// than "a test went red".
|
|
func seedCorpus(t *testing.T, h *testutil.Harness, slug string) corpus {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
mine := freshOrg(t, h, slug)
|
|
theirs := freshOrg(t, h, slug+"-rival")
|
|
|
|
c := corpus{
|
|
orgID: mine,
|
|
admin: authctx.Identity{
|
|
UserID: "00000000-0000-0000-0000-000000000101",
|
|
OrgID: mine, Role: "admin", Email: "boss@example.test",
|
|
},
|
|
talent: authctx.Identity{
|
|
UserID: "00000000-0000-0000-0000-000000000102",
|
|
OrgID: mine, Role: "talent", Email: "maya@example.test",
|
|
},
|
|
other: authctx.Identity{
|
|
UserID: "00000000-0000-0000-0000-000000000103",
|
|
OrgID: theirs, Role: "admin", Email: "rival@other.test",
|
|
},
|
|
}
|
|
|
|
ing := knowledge.NewIngester(h.Pool, knowledge.NewLexical(128))
|
|
|
|
docs := []struct {
|
|
org string
|
|
doc knowledge.Document
|
|
}{
|
|
{mine, knowledge.Document{
|
|
Source: "policy_docs", ExternalID: "handbook", Title: "Staff Handbook",
|
|
Audience: knowledge.TenantWide(),
|
|
Body: "# Attendance\n\n" +
|
|
"Staff arriving more than ten minutes after the shift start are recorded as late. " +
|
|
"Three late marks in a rolling month trigger a conversation with the venue manager.\n\n" +
|
|
"# Breaks\n\n" +
|
|
"A shift over six hours carries a thirty minute unpaid break. " +
|
|
"Breaks are taken at a time agreed with the supervisor on duty.",
|
|
}},
|
|
{mine, knowledge.Document{
|
|
Source: "policy_docs", ExternalID: "pay-review", Title: "Pay Review Guidance",
|
|
Audience: knowledge.ForRoles(domain.RoleAdmin, domain.RoleEmployer),
|
|
Body: "Managers set the annual uplift band before the review window opens. " +
|
|
"The uplift budget for this year is capped at four percent of the wage bill.",
|
|
}},
|
|
{mine, knowledge.Document{
|
|
Source: "worker_notes", ExternalID: "maya-review", Title: "Maya Chen — review note",
|
|
Audience: knowledge.ForPerson("00000000-0000-0000-0000-000000000102", "maya@example.test"),
|
|
Body: "Maya has covered eleven shifts this quarter and has asked about progressing " +
|
|
"to a supervisor role. Attendance is spotless.",
|
|
}},
|
|
{theirs, knowledge.Document{
|
|
Source: "policy_docs", ExternalID: "rival-handbook", Title: "Rival Co Handbook",
|
|
Audience: knowledge.TenantWide(),
|
|
Body: "Staff arriving more than ten minutes after the shift start are recorded as late. " +
|
|
"Rival Co pays a retention bonus of nine hundred pounds after twelve months.",
|
|
}},
|
|
}
|
|
for _, d := range docs {
|
|
if _, err := ing.Ingest(ctx, d.org, d.doc); err != nil {
|
|
t.Fatalf("ingest %s: %v", d.doc.ExternalID, err)
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
func retriever(h *testutil.Harness) *knowledge.Retriever {
|
|
return knowledge.NewRetriever(h.Pool, knowledge.NewLexical(128))
|
|
}
|
|
|
|
func texts(res *knowledge.Results) string {
|
|
var b strings.Builder
|
|
for _, c := range res.Chunks {
|
|
b.WriteString(c.Title)
|
|
b.WriteString(" ")
|
|
b.WriteString(c.Text)
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
/* ── I1: an agent reads what its caller could read ──────────────────────── */
|
|
|
|
func TestRetrievalRefusesACallerWithNoTenant(t *testing.T) {
|
|
// §5: a retrieval function that accepts a query but not a caller principal
|
|
// is wrong by construction. This package has one entry point and it takes a
|
|
// principal — this asserts the run-time half, for a caller who assembled the
|
|
// struct by hand with an empty identity.
|
|
h := testutil.New(t)
|
|
seedCorpus(t, h, "no-tenant")
|
|
|
|
_, err := retriever(h).Retrieve(context.Background(), knowledge.Query{
|
|
Text: "late",
|
|
Principal: authctx.Identity{Role: "admin", Email: "x@example.test"},
|
|
Sources: []string{"policy_docs"},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("retrieval served a caller with no tenant")
|
|
}
|
|
var kErr *knowledge.Error
|
|
if !asErr(err, &kErr) || kErr.Code != knowledge.ErrNoPrincipal {
|
|
t.Errorf("want %s, got %v", knowledge.ErrNoPrincipal, err)
|
|
}
|
|
}
|
|
|
|
func TestRetrievalRefusesAnUnlistedRole(t *testing.T) {
|
|
h := testutil.New(t)
|
|
c := seedCorpus(t, h, "unlisted-role")
|
|
|
|
stranger := c.admin
|
|
stranger.Role = "superuser"
|
|
|
|
if _, err := retriever(h).Retrieve(context.Background(), knowledge.Query{
|
|
Text: "late", Principal: stranger, Sources: []string{"policy_docs"},
|
|
}); err == nil {
|
|
t.Fatal("retrieval served an unlisted role")
|
|
}
|
|
}
|
|
|
|
func TestRetrievalRefusesAnEmptySourceList(t *testing.T) {
|
|
// An agent whose spec named no knowledge has no knowledge. The dangerous
|
|
// reading of an empty list is "all of them", and that reading is exactly
|
|
// what a permissive default would ship.
|
|
h := testutil.New(t)
|
|
c := seedCorpus(t, h, "no-sources")
|
|
|
|
_, err := retriever(h).Retrieve(context.Background(), knowledge.Query{
|
|
Text: "late", Principal: c.admin,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("an empty source list retrieved something")
|
|
}
|
|
var kErr *knowledge.Error
|
|
if !asErr(err, &kErr) || kErr.Code != knowledge.ErrNoSources {
|
|
t.Errorf("want %s, got %v", knowledge.ErrNoSources, err)
|
|
}
|
|
}
|
|
|
|
func TestAnotherTenantsDocumentsAreInvisible(t *testing.T) {
|
|
// The rival handbook contains the SAME sentence about ten minutes as ours,
|
|
// so a query that matches ours matches theirs equally well. If tenancy were
|
|
// a post-filter, the rival chunk would be fetched, ranked, and then dropped
|
|
// — and its presence would still show in the result count.
|
|
h := testutil.New(t)
|
|
c := seedCorpus(t, h, "cross-tenant")
|
|
|
|
res, err := retriever(h).Retrieve(context.Background(), knowledge.Query{
|
|
Text: "arriving late after the shift start", Principal: c.admin,
|
|
Sources: []string{"policy_docs"}, K: 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
body := texts(res)
|
|
for _, forbidden := range []string{"Rival Co", "retention bonus", "nine hundred"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Errorf("another tenant's document leaked: %q appeared", forbidden)
|
|
}
|
|
}
|
|
if len(res.Chunks) == 0 {
|
|
t.Error("nothing came back at all; the query should match our own handbook")
|
|
}
|
|
}
|
|
|
|
func TestTalentCannotReadAnOperatorDocument(t *testing.T) {
|
|
h := testutil.New(t)
|
|
c := seedCorpus(t, h, "role-scoped")
|
|
|
|
res, err := retriever(h).Retrieve(context.Background(), knowledge.Query{
|
|
Text: "annual uplift band review window budget", Principal: c.talent,
|
|
Sources: []string{"policy_docs"}, K: 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
if body := texts(res); strings.Contains(body, "uplift") {
|
|
t.Errorf("a role-restricted document reached a talent caller: %s", body)
|
|
}
|
|
|
|
// And an operator DOES get it, so the test above is not passing because the
|
|
// document failed to index.
|
|
res, err = retriever(h).Retrieve(context.Background(), knowledge.Query{
|
|
Text: "annual uplift band review window budget", Principal: c.admin,
|
|
Sources: []string{"policy_docs"}, K: 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
if !strings.Contains(texts(res), "uplift") {
|
|
t.Error("the operator document is not retrievable by an operator; the fixture is broken")
|
|
}
|
|
}
|
|
|
|
func TestAPersonalDocumentReachesOnlyItsSubject(t *testing.T) {
|
|
h := testutil.New(t)
|
|
c := seedCorpus(t, h, "personal")
|
|
r := retriever(h)
|
|
ctx := context.Background()
|
|
|
|
q := func(p authctx.Identity) string {
|
|
res, err := r.Retrieve(ctx, knowledge.Query{
|
|
Text: "covered eleven shifts supervisor progression", Principal: p,
|
|
Sources: []string{"worker_notes"}, K: 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
return texts(res)
|
|
}
|
|
|
|
if !strings.Contains(q(c.talent), "eleven shifts") {
|
|
t.Error("the subject of a personal note cannot read it")
|
|
}
|
|
// The admin is an operator and sees the whole tenant elsewhere — but this
|
|
// document was addressed to a person, not to the organization, and an
|
|
// operator's reach over OPERATIONAL rows is not a reach over every document
|
|
// somebody filed about somebody.
|
|
if strings.Contains(q(c.admin), "eleven shifts") {
|
|
t.Error("a personal note reached someone it was not addressed to")
|
|
}
|
|
}
|
|
|
|
func TestAnAgentCannotReadACorpusItsSpecDidNotName(t *testing.T) {
|
|
// The source list is the agent's, not the caller's. A talent caller may
|
|
// read their own note; an agent granted only policy_docs may not fetch it
|
|
// on their behalf. Both halves have to hold, or `knowledge:` in a spec is
|
|
// decoration.
|
|
h := testutil.New(t)
|
|
c := seedCorpus(t, h, "source-scoped")
|
|
|
|
res, err := retriever(h).Retrieve(context.Background(), knowledge.Query{
|
|
Text: "covered eleven shifts supervisor progression", Principal: c.talent,
|
|
Sources: []string{"policy_docs"}, K: 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
if strings.Contains(texts(res), "eleven shifts") {
|
|
t.Error("a document from an undeclared source was retrieved")
|
|
}
|
|
}
|
|
|
|
/* ── I2: the filter runs BEFORE scoring ─────────────────────────────────── */
|
|
|
|
func TestThePermissionFilterRunsBeforeScoring(t *testing.T) {
|
|
// The distinction I2 turns on, made observable.
|
|
//
|
|
// A post-filter fetches k rows, drops the forbidden ones, and returns what
|
|
// is left — so asking for k and getting back fewer than k, while permitted
|
|
// matches still exist, is the fingerprint of post-filtering. A pre-filter
|
|
// never sees the forbidden rows at all, so it fills its k from the caller's
|
|
// own corpus.
|
|
//
|
|
// The fixture makes this sharp: 30 rival documents that match the query
|
|
// perfectly, and 12 of our own that match it too. Under a post-filter the
|
|
// rivals would crowd out the candidate window and the caller would get a
|
|
// short, wrong result. Under a pre-filter they are invisible and the caller
|
|
// gets a full k of their own.
|
|
h := testutil.New(t)
|
|
ctx := context.Background()
|
|
mine := freshOrg(t, h, "prefilter-mine")
|
|
theirs := freshOrg(t, h, "prefilter-theirs")
|
|
|
|
ing := knowledge.NewIngester(h.Pool, knowledge.NewLexical(128))
|
|
phrase := "lateness threshold ten minutes shift start recorded"
|
|
|
|
for i := 0; i < 30; i++ {
|
|
if _, err := ing.Ingest(ctx, theirs, knowledge.Document{
|
|
Source: "policy_docs", ExternalID: fmt.Sprintf("rival-%d", i),
|
|
Title: fmt.Sprintf("Rival doc %d", i), Audience: knowledge.TenantWide(),
|
|
Body: phrase + " — rival copy " + fmt.Sprint(i),
|
|
}); err != nil {
|
|
t.Fatalf("seed rival %d: %v", i, err)
|
|
}
|
|
}
|
|
for i := 0; i < 12; i++ {
|
|
if _, err := ing.Ingest(ctx, mine, knowledge.Document{
|
|
Source: "policy_docs", ExternalID: fmt.Sprintf("ours-%d", i),
|
|
Title: fmt.Sprintf("Our doc %d", i), Audience: knowledge.TenantWide(),
|
|
Body: phrase + " — our copy " + fmt.Sprint(i),
|
|
}); err != nil {
|
|
t.Fatalf("seed ours %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
admin := authctx.Identity{
|
|
UserID: "00000000-0000-0000-0000-000000000201",
|
|
OrgID: mine, Role: "admin", Email: "boss@prefilter.test",
|
|
}
|
|
res, err := retriever(h).Retrieve(ctx, knowledge.Query{
|
|
Text: phrase, Principal: admin, Sources: []string{"policy_docs"}, K: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
|
|
if len(res.Chunks) != 10 {
|
|
t.Errorf("asked for 10 and got %d — a short result set with matches still available "+
|
|
"is the fingerprint of filtering AFTER scoring", len(res.Chunks))
|
|
}
|
|
for _, c := range res.Chunks {
|
|
if strings.Contains(c.Title, "Rival") {
|
|
t.Fatalf("a rival document was returned: %s", c.Title)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── §5: ingest rejects a document nobody can read ──────────────────────── */
|
|
|
|
func TestIngestRefusesADocumentWithNoAudience(t *testing.T) {
|
|
// §5: chunks without ACL metadata are rejected at ingest. An empty ACL is
|
|
// not "private" — it is a row the array-overlap operator can never match,
|
|
// so the document reports as ingested and is silently unreachable forever.
|
|
h := testutil.New(t)
|
|
org := freshOrg(t, h, "no-audience")
|
|
|
|
_, err := knowledge.NewIngester(h.Pool, knowledge.NewLexical(128)).
|
|
Ingest(context.Background(), org, knowledge.Document{
|
|
Source: "policy_docs", ExternalID: "orphan", Title: "Orphan",
|
|
Body: "Nobody can read this.", // no Audience
|
|
})
|
|
if err == nil {
|
|
t.Fatal("a document with no audience was ingested")
|
|
}
|
|
var kErr *knowledge.Error
|
|
if !asErr(err, &kErr) || kErr.Code != knowledge.ErrNoAudience {
|
|
t.Errorf("want %s, got %v", knowledge.ErrNoAudience, err)
|
|
}
|
|
|
|
// And nothing was written. A refusal that left a half-document behind would
|
|
// be worse than no refusal, because the row would then look ingested.
|
|
var n int
|
|
if err := h.Pool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM knowledge_documents WHERE org_id = $1::uuid`, org).Scan(&n); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("%d documents written by a refused ingest", n)
|
|
}
|
|
}
|
|
|
|
func TestReIngestingAnUnchangedDocumentDoesNothing(t *testing.T) {
|
|
h := testutil.New(t)
|
|
ctx := context.Background()
|
|
org := freshOrg(t, h, "unchanged")
|
|
ing := knowledge.NewIngester(h.Pool, knowledge.NewLexical(128))
|
|
|
|
doc := knowledge.Document{
|
|
Source: "policy_docs", ExternalID: "handbook", Title: "Handbook",
|
|
Audience: knowledge.TenantWide(),
|
|
Body: "Staff arriving more than ten minutes late are recorded as late.",
|
|
}
|
|
|
|
first, err := ing.Ingest(ctx, org, doc)
|
|
if err != nil {
|
|
t.Fatalf("first ingest: %v", err)
|
|
}
|
|
if first.Unchanged {
|
|
t.Error("a first ingest reported itself unchanged")
|
|
}
|
|
|
|
second, err := ing.Ingest(ctx, org, doc)
|
|
if err != nil {
|
|
t.Fatalf("second ingest: %v", err)
|
|
}
|
|
if !second.Unchanged {
|
|
t.Error("re-ingesting identical content re-chunked and re-embedded it")
|
|
}
|
|
if second.Chunks != first.Chunks {
|
|
t.Errorf("chunk count changed on a no-op ingest: %d then %d", first.Chunks, second.Chunks)
|
|
}
|
|
}
|
|
|
|
func TestChangingOnlyTheAudienceRewritesTheChunks(t *testing.T) {
|
|
// The words did not change; who may read them did. The chunks carry a
|
|
// denormalised copy of the tags, so treating this as "unchanged" would
|
|
// leave every chunk permissioned by the OLD audience — a permission change
|
|
// that silently did not take effect.
|
|
h := testutil.New(t)
|
|
ctx := context.Background()
|
|
org := freshOrg(t, h, "audience-change")
|
|
ing := knowledge.NewIngester(h.Pool, knowledge.NewLexical(128))
|
|
|
|
doc := knowledge.Document{
|
|
Source: "policy_docs", ExternalID: "handbook", Title: "Handbook",
|
|
Audience: knowledge.TenantWide(),
|
|
Body: "The uplift budget this year is capped at four percent.",
|
|
}
|
|
if _, err := ing.Ingest(ctx, org, doc); err != nil {
|
|
t.Fatalf("first ingest: %v", err)
|
|
}
|
|
|
|
doc.Audience = knowledge.ForRoles(domain.RoleAdmin)
|
|
res, err := ing.Ingest(ctx, org, doc)
|
|
if err != nil {
|
|
t.Fatalf("second ingest: %v", err)
|
|
}
|
|
if res.Unchanged {
|
|
t.Fatal("an audience change was treated as no change; the chunks would keep the old ACL")
|
|
}
|
|
|
|
// The talent caller must now be unable to reach it.
|
|
talent := authctx.Identity{
|
|
UserID: "00000000-0000-0000-0000-000000000301",
|
|
OrgID: org, Role: "talent", Email: "maya@audience.test",
|
|
}
|
|
out, err := retriever(h).Retrieve(ctx, knowledge.Query{
|
|
Text: "uplift budget capped four percent", Principal: talent,
|
|
Sources: []string{"policy_docs"}, K: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
if strings.Contains(texts(out), "uplift") {
|
|
t.Error("the chunks kept the old audience after a permission change")
|
|
}
|
|
}
|
|
|
|
/* ── Determinism and shape ──────────────────────────────────────────────── */
|
|
|
|
func TestTheSameQueryReturnsTheSameOrder(t *testing.T) {
|
|
// A retrieval whose ordering wobbles between identical calls makes every
|
|
// downstream difference impossible to attribute — an eval that fails one
|
|
// run in five is worse than no eval.
|
|
h := testutil.New(t)
|
|
c := seedCorpus(t, h, "determinism")
|
|
r := retriever(h)
|
|
ctx := context.Background()
|
|
|
|
var previous []string
|
|
for i := 0; i < 5; i++ {
|
|
res, err := r.Retrieve(ctx, knowledge.Query{
|
|
Text: "late shift break supervisor", Principal: c.admin,
|
|
Sources: []string{"policy_docs"}, K: 5,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
var ids []string
|
|
for _, ch := range res.Chunks {
|
|
ids = append(ids, ch.ChunkID)
|
|
}
|
|
if previous != nil && strings.Join(ids, ",") != strings.Join(previous, ",") {
|
|
t.Fatalf("ordering changed between identical queries:\n %v\n %v", previous, ids)
|
|
}
|
|
previous = ids
|
|
}
|
|
}
|
|
|
|
func TestEveryResultCarriesACitation(t *testing.T) {
|
|
// §5: retrieved chunks flow to the model with source ids, so a response can
|
|
// cite — and so a claim without a citation can be told apart from a
|
|
// grounded one.
|
|
h := testutil.New(t)
|
|
c := seedCorpus(t, h, "citations")
|
|
|
|
res, err := retriever(h).Retrieve(context.Background(), knowledge.Query{
|
|
Text: "late break supervisor", Principal: c.admin,
|
|
Sources: []string{"policy_docs"}, K: 5,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
if len(res.Chunks) == 0 {
|
|
t.Fatal("nothing retrieved")
|
|
}
|
|
for _, ch := range res.Chunks {
|
|
if ch.ChunkID == "" || ch.DocumentID == "" {
|
|
t.Errorf("a chunk came back with no citable id: %+v", ch)
|
|
}
|
|
if ch.Title == "" {
|
|
t.Errorf("chunk %s has no document title to cite", ch.ChunkID)
|
|
}
|
|
if ch.Score <= 0 {
|
|
t.Errorf("chunk %s has a non-positive fused score", ch.ChunkID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKeywordOnlyRetrievalSaysSo(t *testing.T) {
|
|
// A retrieval that silently halved its own recall presents as the agent
|
|
// getting worse for no reason anyone can find. With no embedder, results
|
|
// still come back — and they say why they are only half the story.
|
|
h := testutil.New(t)
|
|
c := seedCorpus(t, h, "no-embedder")
|
|
|
|
res, err := knowledge.NewRetriever(h.Pool, nil).Retrieve(context.Background(), knowledge.Query{
|
|
Text: "late", Principal: c.admin, Sources: []string{"policy_docs"}, K: 5,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
if res.DenseSkipped == "" {
|
|
t.Error("keyword-only results did not report that the dense half was skipped")
|
|
}
|
|
if len(res.Chunks) == 0 {
|
|
t.Error("keyword-only retrieval returned nothing; it should still work")
|
|
}
|
|
}
|
|
|
|
func TestVectorsFromAnotherModelAreNotSearched(t *testing.T) {
|
|
// Vectors from two embedding models are not comparable — the numbers have
|
|
// no shared meaning — so a corpus half-migrated returns confident nonsense
|
|
// rather than failing. The model name on the row is what prevents it.
|
|
h := testutil.New(t)
|
|
ctx := context.Background()
|
|
c := seedCorpus(t, h, "model-mismatch")
|
|
|
|
// A retriever whose embedder produces a DIFFERENT model name over the same
|
|
// corpus. Its dense half must match nothing.
|
|
other := knowledge.NewRetriever(h.Pool, knowledge.NewLexical(64)) // different dims → different model name
|
|
|
|
res, err := other.Retrieve(ctx, knowledge.Query{
|
|
Text: "late shift break", Principal: c.admin,
|
|
Sources: []string{"policy_docs"}, K: 5,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
// Keyword still works, so results come back — but none of them was ranked
|
|
// by the dense half, because no row carries this model's vectors.
|
|
for _, ch := range res.Chunks {
|
|
if ch.DenseRank != 0 {
|
|
t.Errorf("chunk %s was dense-ranked against a different model's vectors", ch.ChunkID)
|
|
}
|
|
}
|
|
if len(res.Chunks) == 0 {
|
|
t.Error("nothing came back; the keyword half should be unaffected")
|
|
}
|
|
}
|
|
|
|
func asErr(err error, target **knowledge.Error) bool {
|
|
if e, ok := err.(*knowledge.Error); ok {
|
|
*target = e
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/* ── Re-embedding ───────────────────────────────────────────────────────── */
|
|
|
|
func TestReembeddingRestoresDenseSearchAfterAModelChange(t *testing.T) {
|
|
// The silent failure this exists for.
|
|
//
|
|
// Vectors from two models are not comparable, so every chunk records which
|
|
// model produced it and retrieval only searches matching ones. Change model
|
|
// and the old vectors are not wrong — they are simply not looked at.
|
|
// Retrieval keeps working, keeps citing, and quietly drops to keyword-only.
|
|
// Nothing errors, and the only symptom is answers getting worse.
|
|
h := testutil.New(t)
|
|
ctx := context.Background()
|
|
c := seedCorpus(t, h, "reembed")
|
|
|
|
// A different embedder over the same corpus: same rows, incomparable
|
|
// vectors. Its dense half matches nothing.
|
|
other := knowledge.NewLexical(64)
|
|
before, err := knowledge.NewRetriever(h.Pool, other).Retrieve(ctx, knowledge.Query{
|
|
Text: "late shift break supervisor", Principal: c.admin,
|
|
Sources: []string{"policy_docs"}, K: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
for _, ch := range before.Chunks {
|
|
if ch.DenseRank != 0 {
|
|
t.Fatalf("chunk %s was dense-ranked before re-embedding; the fixture is wrong", ch.ChunkID)
|
|
}
|
|
}
|
|
|
|
// Re-embed with the new model.
|
|
done, err := knowledge.NewIngester(h.Pool, other).Reembed(ctx, c.orgID, 8, nil)
|
|
if err != nil {
|
|
t.Fatalf("reembed: %v", err)
|
|
}
|
|
if done == 0 {
|
|
t.Fatal("re-embedding reported no work; the corpus should have needed it")
|
|
}
|
|
|
|
after, err := knowledge.NewRetriever(h.Pool, other).Retrieve(ctx, knowledge.Query{
|
|
Text: "late shift break supervisor", Principal: c.admin,
|
|
Sources: []string{"policy_docs"}, K: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("retrieve: %v", err)
|
|
}
|
|
var ranked int
|
|
for _, ch := range after.Chunks {
|
|
if ch.DenseRank != 0 {
|
|
ranked++
|
|
}
|
|
}
|
|
if ranked == 0 {
|
|
t.Error("dense search is still dead after re-embedding")
|
|
}
|
|
}
|
|
|
|
func TestReembeddingTwiceDoesNothingTheSecondTime(t *testing.T) {
|
|
// A corpus already carrying this model's vectors needs no work, and saying
|
|
// so beats re-embedding it — which on a hosted provider is a bill for
|
|
// nothing.
|
|
h := testutil.New(t)
|
|
ctx := context.Background()
|
|
c := seedCorpus(t, h, "reembed-idempotent")
|
|
|
|
e := knowledge.NewLexical(128) // the model the fixture already used
|
|
done, err := knowledge.NewIngester(h.Pool, e).Reembed(ctx, c.orgID, 8, nil)
|
|
if err != nil {
|
|
t.Fatalf("reembed: %v", err)
|
|
}
|
|
if done != 0 {
|
|
t.Errorf("%d chunks re-embedded with the model they already carried", done)
|
|
}
|
|
}
|
|
|
|
func TestReembeddingKeepsTheHeadingInTheEmbeddedText(t *testing.T) {
|
|
// Ingest embeds "heading\n\ntext". A re-embed that dropped the heading
|
|
// would produce vectors subtly different from the ones ingest makes, and
|
|
// the difference would surface as retrieval quality drifting after a
|
|
// reindex — with nothing to point at.
|
|
h := testutil.New(t)
|
|
ctx := context.Background()
|
|
c := seedCorpus(t, h, "reembed-heading")
|
|
|
|
var heading, text string
|
|
if err := h.Pool.QueryRow(ctx, `
|
|
SELECT heading, text FROM knowledge_chunks
|
|
WHERE org_id = $1::uuid AND heading <> '' LIMIT 1`, c.orgID,
|
|
).Scan(&heading, &text); err != nil {
|
|
t.Skipf("no headed chunk in the fixture: %v", err)
|
|
}
|
|
|
|
e := knowledge.NewLexical(64)
|
|
if _, err := knowledge.NewIngester(h.Pool, e).Reembed(ctx, c.orgID, 8, nil); err != nil {
|
|
t.Fatalf("reembed: %v", err)
|
|
}
|
|
|
|
// The stored vector must equal what the embedder produces for
|
|
// heading+text, not for text alone.
|
|
want, err := e.Embed(ctx, []string{heading + "\n\n" + text}, knowledge.KindDocument)
|
|
if err != nil {
|
|
t.Fatalf("embed: %v", err)
|
|
}
|
|
var stored []float32
|
|
if err := h.Pool.QueryRow(ctx, `
|
|
SELECT embedding FROM knowledge_chunks
|
|
WHERE org_id = $1::uuid AND heading = $2 AND text = $3`,
|
|
c.orgID, heading, text).Scan(&stored); err != nil {
|
|
t.Fatalf("read back: %v", err)
|
|
}
|
|
if len(stored) != len(want[0]) {
|
|
t.Fatalf("stored %d dims, embedder produces %d", len(stored), len(want[0]))
|
|
}
|
|
for i := range stored {
|
|
if stored[i] != want[0][i] {
|
|
t.Fatalf("the re-embedded vector does not match heading+text; "+
|
|
"the heading was dropped (first difference at %d)", i)
|
|
}
|
|
}
|
|
}
|