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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- hybrid-search-reranking Installs to ~/.agents/skills/hybrid-search-reranking/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install hybrid-search-reranking The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project hybrid-search-reranking Installs to .agents/skills/hybrid-search-reranking/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/hybrid-search-reranking.zipunzip -o hybrid-search-reranking.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) hybrid-search-reranking PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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 shape | BM25 / full-text | Dense vectors |
|---|---|---|
Exact identifiers (ERR_4012, SKU-88-B, function names) | Strong | Weak: tokens get smeared |
| Paraphrase, synonyms, “how do I…” | Weak | Strong |
| Rare proper nouns, new product names | Strong | Weak if the embedding model never saw them |
| Multilingual, query and doc in different languages | Fails | Works with a multilingual model |
| Very short queries (1-2 words) | Strong | Noisy |
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.
| Situation | Fusion |
|---|---|
| No labelled data, or score scales differ | RRF, k = 60 |
| Labelled queries and one side clearly stronger | Weighted RRF (multiply each term by a weight) |
| Need a tunable dial for product owners | Convex combination a * norm(dense) + (1-a) * norm(bm25) with min-max normalisation per query, a tuned on qrels |
| One retriever returns nothing for most queries | Do 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
rrfretriever wrapping astandard(match query) retriever and aknnretriever; setrank_window_sizeto 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=1is pure vector,alpha=0pure BM25. Tunealphaon 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.
| Option | When | Notes |
|---|---|---|
| Hosted API (Cohere Rerank, Voyage, Jina) | Fastest to ship, multilingual | Network 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 traffic | GPU for > ~20 QPS at 100 candidates |
| LLM-as-reranker (listwise prompt) | Small candidate sets, complex relevance rules | Slow and costly; cache aggressively |
| No reranker | Latency budget < ~50 ms or fused nDCG already near ceiling | Measure 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.3needs 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.pyshows 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.yaml
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.py
#!/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.