//go:build integration // Retrieval recall regression gate. This is the FIRST evaluation of whether the hybrid search RECALLS the // right people, not just whether the engine bands score. It loads a versioned gold set (testdata // retrieval_qrels.json), seeds a fresh per run org with those candidates embedded through the REAL inference // plane (so the vector leg is meaningful, a deterministic hash embedding would make recall arbitrary), then // for each query embeds the query through the SAME plane, runs the production ai.HybridSearch under RLS, maps // the returned CandidateIDs back to the seed keys, and computes recall at k, MRR, and nDCG against the qrels // with the pure internal/platform/eval metrics. It logs every metric per query and the mean as a readable // regression baseline, and asserts conservative floors a correct hybrid search clears comfortably while a real // regression (in embeddings, HNSW tuning, the mission gate, or the RRF k constant) would breach. // // This is MEASUREMENT ONLY. It does not touch retrieval.go, the RRF math, or the frozen matching engine. It is // gated on MM_MEGAMIND_URL and skips cleanly when it is unset, exactly like the sibling brain tests, so the // orchestrator must set the gateway URL to run it on staging. package integration import ( "context" "encoding/json" "math" "os" "path/filepath" "testing" "time" "github.com/pgvector/pgvector-go" "github.com/missionworkforce/missionmatch/internal/modules/ai" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/eval" "github.com/missionworkforce/missionmatch/internal/platform/inference" ) // qrelsCandidate is one gold set candidate with the stable seed key the harness maps a returned id back to. type qrelsCandidate struct { Key string `json:"key"` FullName string `json:"full_name"` ClearanceLevel string `json:"clearance_level"` ClearancePoly string `json:"clearance_poly"` ClearanceStatus string `json:"clearance_status"` Location string `json:"location"` Headline string `json:"headline"` } // qrelsQuery is one query with the seed keys judged relevant to it. type qrelsQuery struct { ID string `json:"id"` Text string `json:"text"` Relevant []string `json:"relevant"` } type qrelsFixture struct { Version string `json:"version"` Candidates []qrelsCandidate `json:"candidates"` Queries []qrelsQuery `json:"queries"` } func loadQrels(t *testing.T) qrelsFixture { t.Helper() raw, err := os.ReadFile(filepath.Join("testdata", "retrieval_qrels.json")) if err != nil { t.Fatalf("read qrels fixture: %v", err) } var fx qrelsFixture if err := json.Unmarshal(raw, &fx); err != nil { t.Fatalf("parse qrels fixture: %v", err) } if fx.Version == "" || len(fx.Candidates) == 0 || len(fx.Queries) == 0 { t.Fatalf("qrels fixture is incomplete: version=%q candidates=%d queries=%d", fx.Version, len(fx.Candidates), len(fx.Queries)) } return fx } // retrievalPlane builds an inference client to the live MEGAMIND plane, or skips when MM_MEGAMIND_URL is unset. // The model and dimension match the production embedder so stored and query vectors share one space. func retrievalPlane(t *testing.T) *inference.Client { t.Helper() url := os.Getenv("MM_MEGAMIND_URL") if url == "" { t.Skip("MM_MEGAMIND_URL not set; skipping the retrieval recall harness that needs the real embedding plane") } c, err := inference.New(inference.Config{ BaseURL: url, EmbedModel: "qllama/bge-small-en-v1.5", GenerateModel: "llama3.1:8b", Dimension: 384, Timeout: 60 * time.Second, MaxRetries: 1, // The gateway requires a bearer on every route but the health check. Omitting it worked only while // MM_MEGAMIND_URL pointed at the token injecting proxy, so the suite proved the PROXY authenticates. Token: os.Getenv("MM_MEGAMIND_TOKEN"), }) if err != nil { t.Fatalf("inference client: %v", err) } return c } // normalizeVec L2 normalizes a vector, exactly as embed backfill does before storing, so a seeded vector // matches the backfill convention and cosine is a clean dot product. func normalizeVec(v []float32) []float32 { var s float64 for _, x := range v { s += float64(x) * float64(x) } if s == 0 { return v } inv := float32(1.0 / math.Sqrt(s)) out := make([]float32, len(v)) for i, x := range v { out[i] = x * inv } return out } func embedOne(t *testing.T, client *inference.Client, text string) []float32 { t.Helper() resp, err := client.Embed(context.Background(), []string{text}) if err != nil { t.Fatalf("embed %q: %v", text, err) } if len(resp.Embeddings) != 1 || len(resp.Embeddings[0]) != 384 { t.Fatalf("embed %q returned wrong shape", text) } return normalizeVec(resp.Embeddings[0]) } // TestRetrievalRecallGate is the regression gate. It seeds the gold set candidates into a fresh org with real // plane embeddings, runs HybridSearch per query, and asserts the mean ranking metrics clear conservative // floors. The gold set is constructed so each query's relevant candidates are both lexically present (the full // text leg finds them) and semantically near the query (the vector leg finds them), so a correct hybrid search // clears the floors comfortably and this is a gate, not a flaky test. func TestRetrievalRecallGate(t *testing.T) { client := retrievalPlane(t) fx := loadQrels(t) org := seedOrg(t) ctx := ctxOrg(org) // Seed every gold set candidate with its real plane embedding, mapping each seeded id to its stable key so // a returned CandidateID can be resolved back to the key the qrels speak in. Seeding is RLS scoped to this // fresh org (the candidates rows carry organization_id = org; the search runs in the same org context). idToKey := make(map[string]string, len(fx.Candidates)) for _, c := range fx.Candidates { // Embed the SAME text the candidate is findable by (name plus headline), the field the search_tsv and // the resume vector both draw from, so the vector leg recalls the candidate the qrels expects. vec := embedOne(t, client, c.FullName+" "+c.Headline) pv := pgvector.NewVector(vec) hn, loc := c.Headline, c.Location created, err := dbgen.New(migrator).CreateCandidate(context.Background(), dbgen.CreateCandidateParams{ OrganizationID: org, FullName: c.FullName, Headline: &hn, ClearanceLevel: c.ClearanceLevel, ClearancePoly: c.ClearancePoly, ClearanceStatus: c.ClearanceStatus, Location: &loc, Available: true, ResumeEmbedding: &pv, }) if err != nil { t.Fatalf("seed gold candidate %q: %v", c.Key, err) } idToKey[created.ID.String()] = c.Key } const ( k5 = 5 k10 = 10 // limit pulls a generous depth so recall at 10 is measured over the real ranking, not truncated by a // tight limit. The gold set has twelve candidates, so twenty surfaces them all. searchLimit = 20 ) var sumRecall5, sumRecall10, sumMRR, sumNDCG10 float64 nQueries := 0 for _, q := range fx.Queries { relevant := make(map[string]bool, len(q.Relevant)) for _, key := range q.Relevant { relevant[key] = true } // Run the PRODUCTION hybrid search with an empty mission (the default unscoped path, byte for byte the // shipped behavior), RLS scoped to this org. The query embeds through the SAME plane as the seed // vectors, so the vector leg compares like with like. hits, err := ai.HybridSearch(ctx, appDB, client, q.Text, "", searchLimit) if err != nil { t.Fatalf("query %q hybrid search: %v", q.ID, err) } // Map the returned CandidateIDs back to seed keys, preserving rank order. A returned id that is not a // gold candidate (there is none here, the org holds only the gold set) maps to an empty key, which is // never relevant, so it neither helps nor hurts the metrics. ranked := make([]string, 0, len(hits)) for _, h := range hits { ranked = append(ranked, idToKey[h.CandidateID]) } r5 := eval.RecallAtK(ranked, relevant, k5) r10 := eval.RecallAtK(ranked, relevant, k10) mrr := eval.MRR(ranked, relevant) ndcg10 := eval.NDCGAtK(ranked, relevant, k10) t.Logf("query %-26s recall@5=%.3f recall@10=%.3f mrr=%.3f ndcg@10=%.3f (relevant=%d, hits=%d)", q.ID, r5, r10, mrr, ndcg10, len(q.Relevant), len(hits)) sumRecall5 += r5 sumRecall10 += r10 sumMRR += mrr sumNDCG10 += ndcg10 nQueries++ } meanRecall5 := sumRecall5 / float64(nQueries) meanRecall10 := sumRecall10 / float64(nQueries) meanMRR := sumMRR / float64(nQueries) meanNDCG10 := sumNDCG10 / float64(nQueries) t.Logf("MEAN over %d queries (gold set v%s): recall@5=%.3f recall@10=%.3f mrr=%.3f ndcg@10=%.3f", nQueries, fx.Version, meanRecall5, meanRecall10, meanMRR, meanNDCG10) // Floors. Each gold query's relevant candidates are lexically present AND semantically near the query, and // the org holds only twelve candidates, so a correct hybrid search ranks the relevant ones at or near the // top and recall at ten is essentially complete. The floors sit well below the expected healthy values so // a passing run is unambiguous, while a real regression (embeddings drift, HNSW ef_search cut, a broken // mission gate, or an RRF k constant change that scrambles the fusion) drops a metric below its floor. // // meanRecall@10 >= 0.8: a healthy search recalls nearly every relevant id within the top ten of a twelve // candidate pool, so 0.8 is a conservative floor that a correct search clears comfortably and a recall // regression breaks. if meanRecall10 < 0.8 { t.Fatalf("REGRESSION: mean recall@10 = %.3f is below the 0.8 floor; the hybrid search is no longer recalling the relevant candidates", meanRecall10) } // meanMRR >= 0.5: a healthy search puts a relevant candidate in the top two on average (rank one is 1.0, // rank two is 0.5), so 0.5 is a conservative floor on first relevant rank that a ranking regression breaks. if meanMRR < 0.5 { t.Fatalf("REGRESSION: mean MRR = %.3f is below the 0.5 floor; the first relevant candidate is ranking too deep", meanMRR) } // meanNDCG@10 >= 0.6: nDCG rewards ranking the relevant ids high, not merely present. 0.6 is a conservative // floor that a correct ordering clears while a fusion or ranking regression that buries relevant ids breaks. if meanNDCG10 < 0.6 { t.Fatalf("REGRESSION: mean nDCG@10 = %.3f is below the 0.6 floor; relevant candidates are ranking too low in the fused order", meanNDCG10) } // recall@5 is logged as a baseline and sanity checked but not floored as tightly, since a five deep cutoff // over a twelve candidate pool is the harder bar; it must still recall a clear majority on average. if meanRecall5 < 0.6 { t.Fatalf("REGRESSION: mean recall@5 = %.3f is below the 0.6 floor; the top five no longer surfaces the relevant candidates", meanRecall5) } // Guard the fixture itself against silent identity collisions: every seeded id resolved to a distinct key. if len(idToKey) != len(fx.Candidates) { t.Fatalf("seed key mapping is inconsistent: %d ids for %d candidates", len(idToKey), len(fx.Candidates)) } }