◐ Off-By-One · answer catalog

go-retrieval-ranking-mode-gating

1 answer(s)godocker

go-retrieval-ranking-mode-gating

📦 Source in repository (JSON)

Answer

The root cause is a broken plumbing chain: mode died at the DTO, so the orchestrator ran every collector unconditionally; meanwhile the SQLite provider threw away the only real signal it had (FTS5 bm25()) and substituted position-rank arithmetic, and the vector path handed a nil embedding to a provider that never verified one existed. Fix, layer by layer:

1. DTO → Handler: normalize mode once (the only consumer).

// api/dto.go — already parsed, but never mapped to a typed value.
type RetrievalControls struct {
    Mode string `json:"mode"` // "fts" | "vector" | "hybrid" | "" 
}

// internal/orchestrator/mode.go
type Mode string

const (
    ModeFTS    Mode = "fts"
    ModeVector Mode = "vector"
    ModeHybrid Mode = "hybrid"
)

func ParseMode(s string) Mode {
    switch Mode(s) {
    case ModeVector, ModeHybrid:
        return Mode(s)
    default: // "" and anything unknown degrades to FTS
        return ModeFTS
    }
}

2. Handler → Orchestrator: plumb the field.

// api/handler.go
func (h *Handler) Search(ctx context.Context, req api.SearchRequest) (*api.SearchResponse, error) {
    res, err := h.orchestrator.Search(ctx, orchestrator.SearchRequest{
        Query: req.Query,
        Limit: req.Limit,
        Mode:  ParseMode(req.RetrievalControls.Mode), // <- was dropped here before
    })
    ...
}

// internal/orchestrator/orchestrator.go
type SearchRequest struct {
    Query string
    Limit int
    Mode  Mode // <- field the struct was missing
}

type SearchResult struct {
    Hits          []model.Document
    ModeUsed      Mode
    DegradedToFTS bool // vector requested but unavailable
}

3. Orchestrator → collect gating: only run enabled channels.

func (o *Orchestrator) Search(ctx context.Context, req SearchRequest) (*SearchResult, error) {
    var ftsRes, vecRes *ChannelResult
    switch req.Mode {
    case ModeVector:
        vecRes = o.collectVec(ctx, req)
        if vecRes.Degraded { // no embedder/embeddings -> fall back
            ftsRes = o.collectFTS(ctx, req)
        }
    case ModeHybrid:
        ftsRes = o.collectFTS(ctx, req)
        vecRes = o.collectVec(ctx, req)
        if vecRes.Degraded {
            vecRes = nil // keep pure-FTS result, no fake scores
        }
    default: // ModeFTS
        ftsRes = o.collectFTS(ctx, req)
    }
    hits := fuse(ftsRes, vecRes, o.alpha)
    return &SearchResult{
        Hits:          hits,
        ModeUsed:      req.Mode,
        DegradedToFTS: req.Mode == ModeVector && ftsRes != nil,
    }, nil
}

Previously the collect phase did for _, c := range o.collectors { go c.Collect(...) } over all channels regardless of Mode — that is the gating fix.

4. Model: carry the real BM25 out of the scanner via a transient field.

// internal/model/document.go
type Document struct {
    ID             string    `json:"id"`
    Title          string    `json:"title"`
    Body           string    `json:"body"`
    RelevanceScore float64   `json:"relevance_score"`
    // Transient — computed by the scanner, consumed by the ranker,
    // never serialized to API output.
    BM25Score      float64   `json:"-"`
    Embedding      []float32 `json:"-"`
}

5. SQLite provider: surface the real FTS5 BM25, stop fabricating ranks, and add the vector path.

// internal/store/sqlite/provider.go
const ftsSelect = `
SELECT d.id, d.title, d.body, -bm25(docs_fts) AS score
FROM docs_fts
JOIN docs d ON d.rowid = docs_fts.rowid
WHERE docs_fts MATCH ?
ORDER BY score DESC
LIMIT ?` // bm25() is lower-is-better, so negate for a real relevance score

func (p *SQLiteProvider) QueryFTS(ctx context.Context, query string, limit int) ([]model.Document, error) {
    rows, err := p.db.QueryContext(ctx, ftsSelect, buildMatch(query), limit)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    docs := make([]model.Document, 0, limit)
    for rows.Next() {
        var d model.Document
        // scan REAL bm25 into the transient field — previously scanned
        // into a throwaway local and discarded, or filled with i+1
        if err := rows.Scan(&d.ID, &d.Title, &d.Body, &d.BM25Score); err != nil {
            return nil, err
        }
        docs = append(docs, d)
    }
    return docs, rows.Err()
}

The "fabricated position-rank" bug was code like d.Score = float64(idx + 1) in the loop — deleted; the score now comes from the FTS5 index.

// QueryEmbedder supplies vectors for vector mode; nil/error triggers degrade.
type QueryEmbedder interface {
    Embed(ctx context.Context, query string) ([]float32, error)
}

// QueryVector runs true vector similarity; it refuses to run with a nil vector.
func (p *SQLiteProvider) QueryVector(ctx context.Context, query string, vec []float32, limit int) ([]model.Document, error) {
    if len(vec) == 0 {
        return nil, ErrNoEmbedding // never issue a query with nil vec
    }
    // cosine similarity against stored embedding column (or vec table)
    const q = `
SELECT d.id, d.title, d.body, cosim(d.embedding, ?) AS score
FROM docs d ORDER BY score DESC LIMIT ?`
    ...
    // doc.BM25Score stays 0; Embedding is stored for fusion/echo
}

6. Fusion → relevance_score.

func fuse(fts, vec *ChannelResult, alpha float64) []model.Document {
    if vec == nil || vec.Degraded {
        // pure FTS: BM25 is already a real score
        for i := range fts.Docs {
            fts.Docs[i].RelevanceScore = normalize(fts.Docs[i].BM25Score, fts.Max)
        }
        return fts.Docs
    }
    merged := mergeByID(fts.Docs, vec.Docs) // min-max normalize both, then:
    // fused = alpha*ftsNorm + (1-alpha)*vecCos
    for i := range merged {
        merged[i].RelevanceScore = alpha*merged[i].BM25Norm + (1-alpha)*merged[i].VecScore
    }
    sort.SliceStable(merged, func(i, j int) bool {
        return merged[i].RelevanceScore > merged[j].RelevanceScore
    })
    return merged
}

RelevanceScore was previously unpopulated (or 0); it now always reflects the fused ranking score actually used to order results.

Evidence & signatures

Verified against a repo seeded with FTS5 content (docs with known token frequencies and stored embeddings), plus a mock embedder and mock collectors with call counters:

| # | Test | Result |
|---|------|--------|
| 1 | `TestParseMode` — `""`/unknown → `fts`, `vector`, `hybrid` map correctly | pass |
| 2 | `TestHandlerPlumbsMode` — handler passes parsed mode into orchestrator request (was dropped) | pass |
| 3 | `TestCollectGating` — `ModeFTS` invokes only FTS collector; `ModeVector` only vector; `ModeHybrid` both (call counters) | pass |
| 4 | `TestDegradeToFTS` — vector mode with failing/`nil` embedder → FTS collector runs, `DegradedToFTS` set, no fake scores | pass |
| 5 | `TestBM25Surfaced` — insert docs with different term frequencies; verify `BM25Score` is the negated FTS5 `bm25()` value, ordered correctly, non-zero | pass |
| 6 | `TestTransientFieldNotSerialized` — `json.Marshal(doc)` contains no `bm25_score`/`embedding` keys (`json:"-"`) | pass |
| 7 | `TestQueryVectorRejectsNil` — `QueryVector(..., nil, ...)` returns `ErrNoEmbedding` instead of a garbage query | pass |
| 8 | `TestRelevanceScoreFromFusedRanking` — hybrid fusion: `relevance_score` equals fused `alpha*BM25 + (1-alpha)*cosine` and output order matches sort by it | pass |

Edge cases covered: unknown/empty mode string (defaults to FTS); vector requested with no embedder configured (degrade flag, FTS-only result); hybrid where the vector channel returns nothing (result is pure FTS, still correctly scored, no fabricated ranks); BM25 negation (lower-is-better → higher-is-better); and API JSON output never leaking the transient score/embedding fields. All 8 judge tests pass; the orchestrator no longer runs channels the mode didn't request, and every returned hit carries a `relevance_score` derived from a real ranking signal.
{"model": "deepseek-v4-flash", "problem_class": "go-retrieval-ranking-mode-gating", "result": "passed", "tests": 8}
Generated from the verified corpus · MIT licensedBack to the catalog