seriffic's picture
Backend evolution: Phases 1-10 specialists + agentic FSM + Mellea + LiteLLM router
6a82282

Phase 3 — Granite Embedding Reranker R2 (cross-encoder, 149 M)

Status

Working end-to-end on the existing 5-PDF corpus, both backends.

Model

  • Model: ibm-granite/granite-embedding-reranker-english-r2
  • Type: cross-encoder reranker (149 M params)
  • License: Apache-2.0 (verified, HF cardData)
  • Loader: sentence_transformers.CrossEncoder — sidecar pattern, no vLLM --task score per project decision
  • Library declared: sentence-transformers

Pipeline

  1. rerank.py — loads the cross-encoder, scores [query, candidate] pairs, returns ranked top-K.
  2. run_double_gate.py — calls the existing app.rag.retrieve_top_k-equivalent (with the per-doc dedup bypassed), gathers top-20, reranks to top-3, and runs both backends' reconciler against the top-1 passage.

Validation

Hand-crafted query + 5 candidate paragraphs

Query: "What are flood risks in Hollis, Queens?"

The reranker correctly ranked the Hollis-Ida paragraph #1 (score 0.93), Sandy/Brighton #2 (0.77), and Rockaways #3 (0.73). The Newtown Creek WWTP and Bluebelt operations paragraphs (off-topic for a Hollis flood-risk query) were correctly demoted out of the top-3.

Real corpus end-to-end

Query: "What flood risk does Hollis, Queens face from heavy rainfall?"

Retriever (Granite Embedding 278 M) top-3:

Rank Retriever score Doc Excerpt
1 0.760 rag_mta "Urgent Call for Action 7 Climate Resilience Roadmap…"
2 0.749 rag_comptroller "Forecast & Emergency Plan Activation Flash flooding…"
3 0.749 rag_comptroller "Is New York City Ready for Rain? An Investigation…"

Reranker top-3 (from retriever's top-20):

Rank Reranker score Was retriever rank Doc Excerpt
1 0.886 6 rag_comptroller "Is New York City Ready for Rain?… (preparedness section)"
2 0.869 4 rag_comptroller "Heavy rains persisted for more than an hour in southern Brooklyn…"
3 0.869 1 rag_mta "Urgent Call for Action 7 Climate Resilience Roadmap…"

The reranker is doing its job: it surfaced a query-specific preparedness paragraph (originally rank 6 — buried by the retriever) and demoted a generic MTA boilerplate paragraph (originally rank 1) to position 3.

Honesty under uncertainty

Neither selected paragraph specifically mentions Hollis. Both backends correctly refused to invent a Hollis-specific answer and said so plainly with a citation:

Backend Latency Output
Ollama (M-series MPS) 10.56 s "The provided document…does not specifically mention Hollis, Queens…I cannot determine the flood risk for Hollis, Queens from heavy rainfall…[rag_comptroller]"
vLLM (AMD MI300X) 0.68 s "The provided document does not contain specific information about the flood risk faced by Hollis, Queens from heavy rainfall. [rag_comptroller]"

This is the desired silence-over-confabulation behavior. The reranker

  • reconciler combination did not surface a false claim despite there being a temptation (the document discusses a 2024 storm in NYC generally).

Latency budget

Stage Latency Notes
Retriever (Granite Embedding 278 M) cold load + index 52.7 s One-time at app boot; amortized in production
Retriever per-query < 0.1 s Already in production
Reranker cold load (149 M) 1.8 s One-time at app boot
Reranker score 20 candidates 0.93 s M3 Pro CPU, batched
Reconcile (Ollama, M-series) 10.6 s
Reconcile (vLLM, AMD MI300X) 0.7 s ~15× faster

The reranker adds ~1 s to the user-visible path on CPU. Negligible relative to the existing reconciler latency, well under the brief's demo budget.

Findings worth remembering

  1. The retriever's per-doc dedup is in the wrong place. Currently app/rag.py:retrieve() keeps "at most 1 chunk per doc" and then returns top-K. For the reranker integration, this should be inverted: gather top-20 with duplicates, rerank, then dedup to top-3. Otherwise we're throwing away high-relevance chunks before the rerank ever sees them.

  2. Cross-encoder cache_dir arg is deprecated in current sentence-transformers; passes through with a warning. Move to model_kwargs={"cache_dir": ...} when integrating to silence it.

  3. Reranker disagrees with the retriever in interesting ways. On the test query the retriever's rank-1 (a generic MTA roadmap intro) was a content-light string that scored high on lexical/embedding surface similarity to "flood risk heavy rainfall". The reranker correctly surfaced more specific content. This is the canonical reason cross-encoder reranking matters.

  4. Sidecar deployment story. No GPU needed for the reranker; ~600 MB resident on CPU; loads in ~2 s after first download. Fits trivially in the HF Spaces T4 image. The vLLM-served alternative was explicitly out-of-scope per the project decision and isn't needed for these latencies.

Files

03_granite_reranker/
  rerank.py           CrossEncoder load + predict wrapper
  run_double_gate.py  retriever -> reranker -> reconciler probe
  RESULTS.md          (this file)
  .cache/             reranker weights, double_gate_*.json

Conclusion

Specialist works on both backends with the expected behavior change (reranker reorders top-3 in a query-relevant way; reconciler refuses to fabricate when source content doesn't address the query).

Recommended path forward: integrate as a one-line addition to app/rag.py:retrieve(): take retriever top-K=20 (drop the existing per-doc dedup), call the reranker, then dedup to top-3. Load the cross-encoder once at app boot in warm(). Single env var RIPRAP_RERANKER_ENABLE=1 to gate the new behavior so the existing production path is unchanged by default.