Hybrid Search and Reranking skill for Codex

BM25 plus vectors, fused by rank and proven on qrels. Codex builds retrieval that pairs full-text search with embeddings, fuses the two lists by reciprocal rank instead of mixing incomparable scores, and reranks only the top candidates within a latency budget. A bundled script scores BM25, dense and fused runs against judged queries, so every tuning change comes with Recall@10 and nDCG@10 numbers.

  • AI & LLM Engineering
  • $hybrid-search-reranking
  • 207 lines
  • 3 files
  • ~2,350 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- hybrid-search-reranking

Installs to ~/.agents/skills/hybrid-search-reranking/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $hybrid-search-reranking, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds and tunes hybrid retrieval that combines BM25 or full-text search with vector search, fuses results with Reciprocal Rank Fusion or weighted scores, and reranks the candidates with cross-encoders or hosted rerank APIs. Use when semantic search misses exact terms such as SKUs, error codes or names, when RAG answers cite the wrong chunks, when adding a reranker, or when choosing fusion weights for Elasticsearch, OpenSearch, Qdrant, Weaviate or Postgres.

Use it when

  • Semantic search misses exact identifiers, names or codes
  • RAG answers cite plausible but wrong chunks
  • You are adding a cross-encoder or hosted rerank API
  • You must pick fusion weights or alpha for a hybrid query

Use something else when

  • You still need to choose or operate the vector store itself: use vector-databases
  • The problem is chunk boundaries rather than ranking: use rag-chunking

How to check the result

Run scripts/rrf_eval.py on judged queries: fused Recall@10 should match or beat the best single retriever, and reranked nDCG@10 should beat fused nDCG@10 within the stated p95 latency.

The mistake it prevents

Applying tenant or permission filters only to the vector side, so restricted documents leak in through the BM25 results.

~/.agents/skills/hybrid-search-reranking/SKILL.md

--- name: hybrid-search-reranking description: Builds and tunes hybrid retrieval that combines BM25 or full-text search with vector search, fuses results with Reciprocal Rank Fusion or weighted… license: MIT ---

Done well means: lexical and dense retrievers each return a deep candidate list, fusion is rank-based unless score weights were tuned on labelled queries, a reranker reorders only the top candidates within a latency budget, and every change is justified by Recall@k and nDCG@k on a judged query set rather than by eyeballing three searches.

Why hybrid, and where each half fails

Query shapeBM25 / full-textDense vectors
Exact identifiers (ERR_4012, SKU-88-B, function names)StrongWeak: tokens get smeared
Paraphrase, synonyms, “how do I…”WeakStrong
Rare proper nouns, new product namesStrongWeak if the embedding model never saw them
Multilingual, query and doc in different languagesFailsWorks with a multilingual model
Very short queries (1-2 words)StrongNoisy

Hybrid wins when your query log mixes these shapes, which is almost always true for product, support and code search. If the log is pure natural-language questions over prose, measure before adding the lexical half; it can add noise.

Workflow

- [ ] 1. Collect 50-200 real queries with judged relevant docs (qrels); include ID-style queries
- [ ] 2. Baseline: run lexical and dense separately, save TREC runs, score them
- [ ] 3. Fuse with RRF (k=60, depth 50-100 per side); score the fused run
- [ ] 4. Add a reranker over the fused top 50-100; score again and record p95 latency
- [ ] 5. Tune one knob at a time (depth, k, weights, rerank top_n) against the same qrels
- [ ] 6. Ship behind a flag, log both rankings, compare click/answer metrics online

Read AGENTS.md first for the search stack, test command and any existing eval harness. If the repo already has a RAG eval set, reuse its queries: see $rag-evaluation.

Measure with the bundled script

Export each retriever’s results as TREC run lines (qid Q0 doc_id rank score tag) and the judgments as qrels (qid 0 doc_id relevance), then:

python3 scripts/rrf_eval.py --qrels qrels.txt bm25.run dense.run --fuse --k 60 --depth 100 \
  --cutoff 10 --write-fused fused.run

It prints Recall@10, MRR@10 and nDCG@10 for each run and the RRF fusion, and writes the fused run so you can feed it to a reranker and score that too. Standard library only.

Fusion: pick RRF first

Reciprocal Rank Fusion scores each document as sum(1 / (k + rank_i)) over the retrievers that returned it. It ignores raw scores, so BM25’s unbounded scores and cosine similarities never need calibrating.

SituationFusion
No labelled data, or score scales differRRF, k = 60
Labelled queries and one side clearly strongerWeighted RRF (multiply each term by a weight)
Need a tunable dial for product ownersConvex combination a * norm(dense) + (1-a) * norm(bm25) with min-max normalisation per query, a tuned on qrels
One retriever returns nothing for most queriesDo not fuse blindly; route by query type instead

Retrieval depth matters more than k: fusing two top-10 lists cannot recover a document ranked 30th by both. Pull 50-100 per side, fuse, then cut.

Postgres: full-text + pgvector in one query

WITH dense AS (
  SELECT id, row_number() OVER (ORDER BY embedding <=> $1) AS r
  FROM chunks
  ORDER BY embedding <=> $1
  LIMIT 100
), lexical AS (
  SELECT c.id, row_number() OVER (ORDER BY ts_rank_cd(c.tsv, q) DESC) AS r
  FROM chunks c, websearch_to_tsquery('english', $2) AS q
  WHERE c.tsv @@ q
  ORDER BY ts_rank_cd(c.tsv, q) DESC
  LIMIT 100
)
SELECT id,
       COALESCE(1.0 / (60 + dense.r), 0) + COALESCE(1.0 / (60 + lexical.r), 0) AS rrf
FROM dense FULL OUTER JOIN lexical USING (id)
ORDER BY rrf DESC
LIMIT 20;

ts_rank_cd is not BM25 (no IDF saturation). If lexical quality matters, use a BM25 extension such as ParadeDB pg_search, or an external engine. Index tuning for the dense half is covered in $pgvector-search.

Qdrant: dense + sparse with server-side fusion

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")
hits = client.query_points(
    collection_name="docs",
    prefetch=[
        models.Prefetch(query=models.SparseVector(indices=s_idx, values=s_val), using="sparse", limit=100),
        models.Prefetch(query=dense_vec, using="dense", limit=100),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=20,
    with_payload=True,
).points

The sparse vectors come from BM25-style or SPLADE encoders computed at ingest; Qdrant can apply IDF on sparse vectors when the sparse config sets the IDF modifier.

Elasticsearch / OpenSearch

  • Elasticsearch: an rrf retriever wrapping a standard (match query) retriever and a knn retriever; set rank_window_size to your fusion depth. Check that your license tier includes RRF before designing around it.
  • OpenSearch: a hybrid query plus a search pipeline with a normalization-processor (min-max + arithmetic mean weights) or the RRF processor in recent versions.
  • Weaviate: collection.query.hybrid(query=..., alpha=0.5); alpha=1 is pure vector, alpha=0 pure BM25. Tune alpha on qrels, do not leave it at the default by habit.

Reranking

A reranker scores (query, passage) pairs jointly, so it catches relevance the bi-encoder missed. It is the single biggest precision gain in most RAG stacks, and the most expensive step.

OptionWhenNotes
Hosted API (Cohere Rerank, Voyage, Jina)Fastest to ship, multilingualNetwork call per query; send only passage text you may share
Open cross-encoder (BAAI/bge-reranker-v2-m3, cross-encoder/ms-marco-MiniLM-L-6-v2)Data must stay local, steady trafficGPU for > ~20 QPS at 100 candidates
LLM-as-reranker (listwise prompt)Small candidate sets, complex relevance rulesSlow and costly; cache aggressively
No rerankerLatency budget < ~50 ms or fused nDCG already near ceilingMeasure first
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)

def rerank(query: str, candidates: list[dict], top_n: int = 8) -> list[dict]:
    pairs = [(query, c["text"]) for c in candidates]
    scores = reranker.predict(pairs, batch_size=32)
    ranked = sorted(zip(candidates, scores), key=lambda t: float(t[1]), reverse=True)
    return [dict(c, rerank_score=float(s)) for c, s in ranked[:top_n]]
import cohere

co = cohere.ClientV2()  # reads CO_API_KEY; needs network
res = co.rerank(model="rerank-v3.5", query=query, documents=[c["text"] for c in cands], top_n=8)
top = [cands[r.index] | {"rerank_score": r.relevance_score} for r in res.results]

Downloading model weights or calling a hosted API needs network; in a Codex sandbox with network off, ask for approval or use weights already cached in the project.

Rerank rules:

  • Rerank 50-100 candidates, return 5-10. Reranking 1,000 burns latency for little gain.
  • Truncate passages to the reranker’s max length deliberately (title + first N tokens), not by silent tokenizer truncation in the middle of a table.
  • Rerank scores are not calibrated probabilities across queries. A fixed cutoff like score > 0.3 needs validation on your qrels before it gates what the LLM sees.
  • Keep the fused rank as a tie-breaker and log both orders for debugging.

Gotchas

  • Tokenisation mismatch: BM25 with a stemming English analyzer mangles code identifiers. Index a second field with a keyword or whitespace analyzer for IDs and paths.
  • Filters must apply to both retrievers. Filtering only the dense side lets forbidden or out-of-tenant documents in through the lexical side. Treat this as a security bug.
  • Chunk-level vs document-level: dedupe by parent document after fusion if the UI shows documents, otherwise one long doc floods the top 10.
  • Score-based fusion without per-query normalisation is wrong: BM25 scores vary by query length.
  • Empty lexical results (stop-word-only queries) are normal; RRF handles them, weighted sums with fixed normalisation often do not.
  • Rerankers trained on web search (MS MARCO) can underperform on legal or code text. Compare two rerankers on your qrels before committing.

Verification

  • scripts/rrf_eval.py shows fused Recall@k at or above the better single retriever. If fusion is worse, check depth, ID mismatches between runs, and filter parity.
  • Reranked nDCG@10 beats fused nDCG@10 on the same qrels; if not, the reranker or its truncation is wrong for the domain.
  • p95 end-to-end retrieval latency is inside the budget (state it, e.g. 300 ms) under the expected concurrency.
  • Spot-check the 10 queries where the new pipeline lost to the baseline; write down why.

Report template

**Query set:** 140 judged queries (38 identifier-style), qrels v3
| Pipeline | Recall@10 | nDCG@10 | p95 ms |
|---|---|---|---|
| BM25 (english + keyword field) | 0.61 | 0.48 | 18 |
| Dense (text-embedding-3-small, HNSW) | 0.66 | 0.51 | 22 |
| RRF k=60, depth 100 | 0.78 | 0.58 | 41 |
| RRF + bge-reranker-v2-m3 top 60 -> 8 | 0.78 | 0.69 | 165 |
**Decision:** ship RRF + rerank behind `search.hybrid`; tenant filter applied to both sides
**Regressions:** 6 queries, all long natural-language questions where BM25 added noise

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Hybrid Search and Reranking"
  short_description: "BM25 plus vectors, fused by rank and proven on qrels"
  default_prompt: "$hybrid-search-reranking Our RAG search can't find error codes like ERR_4012; add BM25 alongside the vectors"
scripts/rrf_eval.py119 lines, 5.2 KB
#!/usr/bin/env python3
"""Fuse retrieval runs with Reciprocal Rank Fusion and score them against qrels.

Inputs use the TREC formats every IR toolkit can emit:
  qrels:  <query_id> <ignored> <doc_id> <relevance>      (relevance > 0 = relevant)
  run:    <query_id> Q0 <doc_id> <rank> <score> <tag>

Usage:
  python3 rrf_eval.py --qrels qrels.txt bm25.run dense.run
  python3 rrf_eval.py --qrels qrels.txt bm25.run dense.run --fuse --k 60 --depth 100 \
      --write-fused fused.run --cutoff 10

Prints Recall@k, MRR@k and nDCG@k per run (and for the fused run with --fuse).
Exits 1 on malformed input or when no query in the runs has judgments.
"""
from __future__ import annotations

import argparse
import math
import sys
from collections import defaultdict


def read_qrels(path: str) -> dict[str, dict[str, int]]:
    qrels: dict[str, dict[str, int]] = defaultdict(dict)
    with open(path, encoding="utf-8") as f:
        for n, line in enumerate(f, 1):
            parts = line.split()
            if not parts:
                continue
            if len(parts) != 4:
                sys.exit(f"error: {path}:{n}: expected 4 columns, got {len(parts)}")
            qid, _, doc, rel = parts
            qrels[qid][doc] = int(rel)
    return qrels


def read_run(path: str) -> dict[str, list[str]]:
    scored: dict[str, list[tuple[float, str]]] = defaultdict(list)
    with open(path, encoding="utf-8") as f:
        for n, line in enumerate(f, 1):
            parts = line.split()
            if not parts:
                continue
            if len(parts) < 5:
                sys.exit(f"error: {path}:{n}: expected TREC run line (qid Q0 doc rank score tag)")
            qid, _, doc, _rank, score = parts[:5]
            scored[qid].append((float(score), doc))
    # Rank by score descending; ties broken by doc id for determinism.
    return {q: [d for _, d in sorted(v, key=lambda t: (-t[0], t[1]))] for q, v in scored.items()}


def rrf(runs: list[dict[str, list[str]]], k: int, depth: int) -> dict[str, list[tuple[str, float]]]:
    fused: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
    for run in runs:
        for qid, docs in run.items():
            for rank, doc in enumerate(docs[:depth], 1):
                fused[qid][doc] += 1.0 / (k + rank)
    return {q: sorted(d.items(), key=lambda t: (-t[1], t[0])) for q, d in fused.items()}


def metrics(ranked: list[str], judged: dict[str, int], cutoff: int) -> tuple[float, float, float]:
    relevant = {d for d, r in judged.items() if r > 0}
    top = ranked[:cutoff]
    recall = len(relevant.intersection(top)) / len(relevant) if relevant else 0.0
    mrr = next((1.0 / i for i, d in enumerate(top, 1) if d in relevant), 0.0)
    dcg = sum((2 ** judged.get(d, 0) - 1) / math.log2(i + 1) for i, d in enumerate(top, 1))
    ideal = sorted((r for r in judged.values() if r > 0), reverse=True)[:cutoff]
    idcg = sum((2 ** r - 1) / math.log2(i + 1) for i, r in enumerate(ideal, 1))
    return recall, mrr, (dcg / idcg if idcg else 0.0)


def evaluate(name: str, run: dict[str, list[str]], qrels: dict[str, dict[str, int]], cutoff: int) -> int:
    qids = [q for q in qrels if any(r > 0 for r in qrels[q].values())]
    if not qids:
        sys.exit("error: qrels contain no relevant judgments")
    totals = [0.0, 0.0, 0.0]
    for q in qids:  # queries missing from the run score 0, as they should
        for i, v in enumerate(metrics(run.get(q, []), qrels[q], cutoff)):
            totals[i] += v
    n = len(qids)
    covered = sum(1 for q in qids if q in run)
    print(f"{name:<28} {totals[0]/n:>10.4f} {totals[1]/n:>8.4f} {totals[2]/n:>8.4f} {covered:>5}/{n}")
    return covered


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("runs", nargs="+", help="TREC run files")
    ap.add_argument("--qrels", required=True)
    ap.add_argument("--cutoff", type=int, default=10)
    ap.add_argument("--fuse", action="store_true", help="also evaluate an RRF fusion of all runs")
    ap.add_argument("--k", type=int, default=60, help="RRF constant (default 60)")
    ap.add_argument("--depth", type=int, default=100, help="ranks per run fed into fusion")
    ap.add_argument("--write-fused", help="write the fused run in TREC format")
    a = ap.parse_args()

    qrels = read_qrels(a.qrels)
    runs = [read_run(p) for p in a.runs]
    c = a.cutoff
    print(f"{'run':<28} {'Recall@'+str(c):>10} {'MRR@'+str(c):>8} {'nDCG@'+str(c):>8} {'q':>11}")
    covered = [evaluate(p, r, qrels, c) for p, r in zip(a.runs, runs)]
    if a.fuse:
        fused = rrf(runs, a.k, a.depth)
        evaluate(f"rrf(k={a.k},depth={a.depth})", {q: [d for d, _ in v] for q, v in fused.items()}, qrels, c)
        if a.write_fused:
            with open(a.write_fused, "w", encoding="utf-8") as f:
                for q, docs in fused.items():
                    for rank, (d, s) in enumerate(docs, 1):
                        f.write(f"{q} Q0 {d} {rank} {s:.6f} rrf\n")
            print(f"wrote {a.write_fused}")
    if max(covered) == 0:
        print("error: no judged query appears in any run; check query ids", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc