package ai import ( "context" "errors" "github.com/jackc/pgx/v5" pgvector "github.com/pgvector/pgvector-go" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/inference" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Hit is one retrieved candidate with its fused relevance score. type Hit struct { CandidateID string `json:"candidate_id"` FullName string `json:"full_name"` Score float64 `json:"rrf_score"` } // HybridSearch is the RAG retrieval leg: it embeds the query through the MEGAMIND plane and runs the // existing hybrid query that fuses pgvector cosine kNN with full text rank via Reciprocal Rank Fusion // (docs/AI_RESEARCH.md, pgvector + RRF). It runs RLS scoped as the tenant, so it returns only the // caller's candidates, and it tunes the HNSW scan per the pgvector 0.8 guidance before the search. // // When mission is a non empty mission keyword (from MissionFromQuestion, drawn only from the fixed // mission_spine vocabulary), retrieval is additionally constrained to candidates whose full_name, headline, // or location text matches that mission, via the mission scoped query (WS6 Option A). An empty mission runs // the unscoped query byte for byte as before, so the default path is unchanged. The mission keyword is // candidate text match only, never a semantic mission tag (those are WS7); the limit is documented in // docs/AI_RESEARCH.md. // // It never touches the eeo schema (the query reads only candidates), and the query string is guarded so // a demographic reference cannot enter the retrieval path. func HybridSearch(ctx context.Context, db *tenant.DB, client *inference.Client, query, mission string, limit int) ([]Hit, error) { if db == nil || client == nil { return nil, errors.New("ai: retrieval needs a database and an inference client") } if err := ForbidEEO(query); err != nil { return nil, err } if limit <= 0 || limit > 100 { limit = 20 } emb, err := client.Embed(ctx, []string{query}) if err != nil { return nil, err } if len(emb.Embeddings) != 1 { return nil, errors.New("ai: query embedding failed") } qv := pgvector.NewVector(emb.Embeddings[0]) var hits []Hit err = db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { // pgvector 0.8 tuning: a wider ef_search lifts recall on the filtered (RLS) scan, and relaxed // iterative scan avoids overfiltering under the tenant predicate (docs/AI_RESEARCH.md, pgvector). if _, err := tx.Exec(ctx, "SET LOCAL hnsw.ef_search = 100"); err != nil { return err } if _, err := tx.Exec(ctx, "SET LOCAL hnsw.iterative_scan = 'relaxed_order'"); err != nil { return err } q := dbgen.New(tx) if mission != "" { rows, err := q.SearchCandidatesHybridMission(ctx, dbgen.SearchCandidatesHybridMissionParams{ QueryVector: qv, RowLimit: int32(limit), TextQuery: query, MissionQuery: mission, }) if err != nil { return err } hits = make([]Hit, len(rows)) for i, r := range rows { hits[i] = Hit{CandidateID: r.ID.String(), FullName: r.FullName, Score: r.RrfScore} } return nil } rows, err := q.SearchCandidatesHybrid(ctx, dbgen.SearchCandidatesHybridParams{ Column1: qv, Limit: int32(limit), WebsearchToTsquery: query, }) if err != nil { return err } hits = make([]Hit, len(rows)) for i, r := range rows { hits[i] = Hit{CandidateID: r.ID.String(), FullName: r.FullName, Score: r.RrfScore} } return nil }) return hits, err }