Skip to content

Cross-Encoder Reranking ​

Two-Stage Retrieval for Higher Accuracy

Overview ​

Cross-encoder reranking is an optional Stage 2 retrieval step that improves search quality by re-scoring candidates with a more accurate (but slower) model. Reranking is independent of Heimdall and can use a local GGUF model (like embeddings) or an external API (Cohere, HuggingFace TEI, Ollama adapter), similar to how embeddings and Heimdall support multiple providers.

How It Works ​

┌─────────────────────────────────────────────────────────────────┐
│                     Two-Stage Retrieval                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Stage 1 (Fast)          Stage 2 (Accurate)                     │
│  ─────────────           ─────────────────                      │
│                                                                 │
│  ┌─────────────┐         ┌─────────────────┐                   │
│  │ Vector+BM25 │  ──→    │ Cross-Encoder   │  ──→  Top 10     │
│  │ RRF Fusion  │         │ Reranking       │       Results     │
│  └─────────────┘         └─────────────────┘                   │
│        ↓                        ↓                               │
│   100 candidates           Re-scored                            │
│   (fast lookup)            (query+doc together)                 │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Why Cross-Encoder? ​

Bi-encoders (embeddings) encode query and document separately:

query_embedding  = model.encode(query)
doc_embedding    = model.encode(document)  // Pre-computed!
score            = cosine(query_embedding, doc_embedding)

Cross-encoders encode them together:

score = model.cross_encode(query, document)  // Sees interaction!

The cross-encoder can capture fine-grained semantic relationships that bi-encoders miss, but it's O(N) vs O(log N).

Server Configuration ​

When reranking is enabled, the server loads the configured reranker at startup (local GGUF asynchronously, external API immediately). Search requests then use Stage-2 reranking when opts.RerankEnabled is true (set from config for HTTP and gRPC search).

Environment Variables ​

VariableDefaultDescription
NORNICDB_SEARCH_RERANK_ENABLEDfalseEnable Stage-2 reranking for vector/hybrid search
NORNICDB_SEARCH_RERANK_PROVIDERlocalBackend: local (GGUF), ollama, openai, or http
NORNICDB_SEARCH_RERANK_MODEL(see below)For local: GGUF filename (e.g. bge-reranker-v2-m3-Q4_K_M.gguf). For API: model name/id (e.g. rerank-english-v3.0)
NORNICDB_SEARCH_RERANK_API_URL(see below)Rerank API endpoint for non-local providers (required when provider ≠ local; default for ollama: http://localhost:11434/rerank)
NORNICDB_SEARCH_RERANK_API_KEY(empty)API key for authenticated providers (e.g. Cohere, OpenAI)
NORNICDB_SEARCH_RERANK_MAX_DOCUMENT_CHARS2048Maximum characters (Unicode code points, not bytes) sent for one candidate (identifying properties + passage), so the same setting gives the same amount of text for Latin, Cyrillic or CJK content. Managed vector hits send the matched chunk extended with its neighbouring chunks up to this budget; lexical-only hits send a query-centered window. NORNICDB_SEARCH_RERANK_MAX_DOCUMENT_BYTES is deprecated: it is honoured when the new variable is unset and its value is read as a character count

The byte ceiling is enforced before calling any local or hosted reranker, so request size scales with candidate count rather than source-document length. rerankTopK bounds the provider work and the returned rows of one search. A ranked continuation reranks after its chunk fusion instead: the continuation producer reranks the fused order it retrieved - the chunked owner's outer RRF for a multi-chunk query, that query's own fused list for a single chunk - in rerankTopK-sized windows, and the chunk searches themselves don't rerank. One stream keys its retained scores by the original query, not by a chunk's text, and keeps the provider's order inside each window.

A window is the batch its candidates were first admitted in, so a deeper pull adds windows for the newly retrieved rows instead of re-forming an earlier one over a longer prefix. That keeps an accepted window's membership, order and scores stable for the life of the stream, and every candidate reaches the provider once per query. Retained decisions - ranked, filtered, omitted by a valid subset, or in a batch that failed - are rebuilt without provider input, so a deeper pull neither repeats the work nor turns a wholly omitted window into a failed rerank. rerankMinScore applies inside each window: excluded rows do not consume page slots, so page counts and the reported total include only rows above the threshold.

Local GGUF rerankers default to llama.cpp rank pooling (4), non-causal attention (1), and disabled flash attention (0). Override these with NORNICDB_RERANK_POOLING_TYPE, NORNICDB_RERANK_ATTENTION_TYPE, and NORNICDB_RERANK_FLASH_ATTN when a model requires different context settings. Tensor loading defaults to upstream's automatic mode and can be changed with NORNICDB_RERANK_LAZY_MODE (0=off, 1=auto, 2=on).

Models directory for local provider: NORNICDB_MODELS_DIR (default ./models). Download the default reranker with:

bash
make download-bge-reranker

YAML Configuration ​

yaml
search_rerank:
  enabled: true
  provider: local # local | ollama | openai | http
  model: bge-reranker-v2-m3-Q4_K_M.gguf # GGUF filename (local) or API model name
  api_url: "" # For ollama/openai/http (e.g. https://api.cohere.ai/v1/rerank)
  api_key: "" # For Cohere, OpenAI, etc.
  lazy_mode: 1 # 0=off, 1=auto, 2=on (local provider only)

Local GGUF (BGE-Reranker-v2-m3) ​

With provider: local, NornicDB loads a BGE-style reranker GGUF from the models directory (same pattern as the embedding model). Default model: bge-reranker-v2-m3-Q4_K_M.gguf.

bash
# Download the default reranker model
make download-bge-reranker

# Enable and run (uses ./models by default)
export NORNICDB_SEARCH_RERANK_ENABLED=true
export NORNICDB_SEARCH_RERANK_PROVIDER=local
# Optional: NORNICDB_SEARCH_RERANK_MODEL=bge-reranker-v2-m3-Q4_K_M.gguf
# Optional: NORNICDB_MODELS_DIR=./models
./nornicdb serve

Tip: If you set env vars on the same line as the command (without export), every variable must be on one logical line using backslashes. Otherwise the shell runs each line as a separate command and only the last line’s vars are passed to nornicdb:

bash
# ✅ Correct: all vars passed to nornicdb
NORNICDB_SEARCH_RERANK_ENABLED=true \
NORNICDB_SEARCH_RERANK_PROVIDER=local \
./bin/nornicdb serve

# ❌ Wrong: NORNICDB_SEARCH_RERANK_* are not passed (they run as separate commands)
NORNICDB_SEARCH_RERANK_ENABLED=true
NORNICDB_SEARCH_RERANK_PROVIDER=local
NORNICDB_HEIMDALL_ENABLED=true \
./bin/nornicdb serve

GGUF output dimension and quantization ​

  • Output dimension: NornicDB requires the reranker GGUF to expose a single relevance logit through rank pooling. Model loading fails if the selected pooling mode does not return exactly one classifier output; NornicDB never substitutes the first coordinate of a pooled embedding. The logit is mapped through sigmoid to a score in [0,1].
  • Quantization: Reranker GGUFs are commonly quantized (Q4_K_M, Q8_0, F16). Q4_K_M is typical for CPU/small GPU; Q8_0 or F16 for higher accuracy. The default make download-bge-reranker target uses a Q4_K_M variant.

Debugging flat or poor rerank scores ​

If every result has the same score (e.g. 0.49) or ordering is worse than with reranking off:

  1. Check model output dimension: Set NORNICDB_RERANK_DEBUG=1 and run a search. Logs show dims=1, raw_logit, and score per candidate. A non-scalar classifier head is rejected during model loading with its selected pooling type in the error.
  2. Fallback: When the reranker produces nearly identical scores (range < 0.05), NornicDB automatically falls back to RRF order and scores so search quality matches "reranking off" until you fix the model or config.

External Providers (ollama / openai / http) ​

Use an HTTP rerank API (Cohere, HuggingFace TEI, or a custom/Ollama adapter). Set provider and API URL; for authenticated APIs, set the API key.

Cohere:

bash
export NORNICDB_SEARCH_RERANK_ENABLED=true
export NORNICDB_SEARCH_RERANK_PROVIDER=http
export NORNICDB_SEARCH_RERANK_API_URL=https://api.cohere.ai/v1/rerank
export NORNICDB_SEARCH_RERANK_API_KEY=your_cohere_key
export NORNICDB_SEARCH_RERANK_MODEL=rerank-english-v3.0

HuggingFace TEI (e.g. local container):

bash
export NORNICDB_SEARCH_RERANK_ENABLED=true
export NORNICDB_SEARCH_RERANK_PROVIDER=http
export NORNICDB_SEARCH_RERANK_API_URL=http://localhost:8080/rerank
export NORNICDB_SEARCH_RERANK_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2

Ollama (if you run a rerank adapter on the Ollama port):

bash
export NORNICDB_SEARCH_RERANK_ENABLED=true
export NORNICDB_SEARCH_RERANK_PROVIDER=ollama
# NORNICDB_SEARCH_RERANK_API_URL defaults to http://localhost:11434/rerank

Quick Start ​

Enable via Search Options ​

go
opts := search.DefaultSearchOptions()
opts.RerankEnabled = true
opts.RerankTopK = 100     // Rerank top 100 candidates
opts.RerankMinScore = 0.3 // Filter low-confidence results

results, err := svc.Search(ctx, query, embedding, opts)

Configure the Reranker Programmatically ​

When you build the server yourself (e.g. tests or custom binary), you can set the reranker via db.SetSearchReranker(...). For HTTP/API rerankers use CrossEncoder:

go
// Configure cross-encoder (HTTP API) service
svc.SetReranker(search.NewCrossEncoder(&search.CrossEncoderConfig{
    Enabled:  true,
    APIURL:   "http://localhost:8081/rerank",
    Model:    "cross-encoder/ms-marco-MiniLM-L-6-v2",
    TopK:     100,
    Timeout:  30 * time.Second,
    MinScore: 0.0,
}))

For local GGUF, load the model with localllm.DefaultRerankerOptions, then pass it to search.NewLocalReranker. The options expose context size, batch size, threads, GPU layers, context type, pooling type, attention type, and flash attention. The standard server does this automatically when NORNICDB_SEARCH_RERANK_ENABLED=true and NORNICDB_SEARCH_RERANK_PROVIDER=local.

go
opts := localllm.DefaultRerankerOptions("models/bge-reranker-v2-m3.gguf")
opts.Features.PoolingType = 4 // Override only when the model requires it.
model, err := localllm.LoadRerankerModel(opts)

Go embedding migration: Service.SetCrossEncoder is retired. Pass a *search.CrossEncoder to Service.SetReranker, as in the example above. Provider replacement and removal advance the revision used by search and Cypher result caches; reinstalling the same comparable provider preserves cached results.

CrossEncoderConfig Options (HTTP/API) ​

OptionDefaultDescription
EnabledfalseEnable cross-encoder reranking
APIURLhttp://localhost:8081/rerankReranking service endpoint
APIKey""Authentication token (if required)
Modelcross-encoder/ms-marco-MiniLM-L-6-v2Model name
TopK100How many candidates to rerank
Timeout30sRequest timeout
MinScore0.0Minimum score threshold

Supported Reranking Services ​

Local GGUF (BGE-Reranker-v2-m3) ​

Configure via server config (env or YAML). No code required: set NORNICDB_SEARCH_RERANK_ENABLED=true, NORNICDB_SEARCH_RERANK_PROVIDER=local, and ensure the model is in NORNICDB_MODELS_DIR (e.g. make download-bge-reranker). See Server Configuration above.

Cohere Rerank API ​

go
ce := search.NewCrossEncoder(&search.CrossEncoderConfig{
    Enabled: true,
    APIURL:  "https://api.cohere.ai/v1/rerank",
    APIKey:  "your-api-key",
    Model:   "rerank-english-v3.0",
})

HuggingFace Text Embeddings Inference (TEI) ​

bash
# Start TEI with reranking model
docker run -p 8081:80 ghcr.io/huggingface/text-embeddings-inference:latest \
    --model-id cross-encoder/ms-marco-MiniLM-L-6-v2
go
ce := search.NewCrossEncoder(&search.CrossEncoderConfig{
    Enabled: true,
    APIURL:  "http://localhost:8081/rerank",
    Model:   "cross-encoder/ms-marco-MiniLM-L-6-v2",
})

Local GGUF (in-process) ​

Use server config with provider: local and a GGUF path (e.g. BGE-Reranker-v2-m3). The server loads the model into memory like the embedding model. For an external HTTP service that runs a reranker (e.g. HuggingFace TEI or a custom adapter), use provider: http and set api_url to the rerank endpoint.

Response Format ​

The cross-encoder integration supports multiple response formats:

Cohere Format ​

json
{
  "results": [
    { "index": 0, "relevance_score": 0.95 },
    { "index": 2, "relevance_score": 0.82 },
    { "index": 1, "relevance_score": 0.71 }
  ]
}

HuggingFace TEI Format ​

json
{
  "scores": [0.95, 0.71, 0.82]
}

Simple Format ​

json
{
  "rankings": [
    { "index": 0, "score": 0.95 },
    { "index": 2, "score": 0.82 }
  ]
}

Performance Considerations ​

Latency Trade-offs ​

MethodLatencyAccuracy
Vector only~5msGood
RRF Hybrid~10msBetter
RRF + Cross-Encoder~50-200msBest

When to Use ​

Use cross-encoder when:

  • Accuracy is more important than latency
  • Users are willing to wait for better results
  • Search volume is low to moderate
  • High-stakes decisions based on search

Skip cross-encoder when:

  • Low latency is critical (<50ms)
  • High query volume (>1000 QPS)
  • Results are "good enough" with bi-encoders
  • Cost is a concern (API calls)

Optimization Tips ​

  1. Limit TopK: Rerank fewer candidates for faster response

    go
    opts.RerankTopK = 50  // Instead of 100
  2. Use MinScore: Filter low-confidence results early

    go
    opts.RerankMinScore = 0.5  // Skip weak matches
  3. Batch Requests: Cross-encoder processes all candidates in one call

  4. Cache Results: For repeated queries, cache the reranked results

Combining with MMR ​

Cross-encoder reranking can be combined with MMR diversification:

go
opts := search.DefaultSearchOptions()
opts.MMREnabled = true
opts.MMRLambda = 0.7
opts.RerankEnabled = true

// Pipeline: Vector+BM25 → RRF → MMR → Cross-Encoder → Results

The search method will show: rrf_hybrid+mmr+rerank

Monitoring ​

Check if any Stage-2 reranker is available (local GGUF or cross-encoder):

go
if svc.RerankerAvailable(ctx) {
    log.Println("Reranker ready")
}

The search response includes the method used:

json
{
  "search_method": "rrf_hybrid+rerank",
  "message": "RRF + Cross-Encoder Reranking"
}

Error Handling ​

The cross-encoder gracefully falls back to original rankings on errors:

  • API timeout → Use original RRF scores
  • Server unavailable → Use original RRF scores
  • Invalid response → Use original RRF scores

No error is returned to the caller - the search continues with best-effort results.

ModelProviderQualitySpeed
bge-reranker-v2-m3-Q4_K_M.ggufLocal (default)ExcellentMedium
cross-encoder/ms-marco-MiniLM-L-6-v2HuggingFace TEIGoodFast
cross-encoder/ms-marco-TinyBERT-L-6HuggingFace TEIGoodFastest
BAAI/bge-reranker-baseTEI / HTTPBetterMedium
Cohere rerank-english-v3.0Cohere APIBestAPI

Cross-Encoder Reranking v1.1 - January 2026 (local GGUF + external providers)

Analytics privacy