Embeddings and Reranking with NeMo Retriever

Building RAG's retrieval core in Python — turning a corpus and a query into vectors with NeMo Retriever embedding NIMs, scoring by cosine similarity, then sharpening the shortlist with a cross-encoder reranker NIM.

Retrieval-augmented generation lives or dies on retrieval. If the passages you hand the model are the wrong ones, no amount of prompt engineering rescues the answer — the model will confidently summarize irrelevant text. The two workhorses that decide which passages reach the model are embeddings (fast, approximate recall over the whole corpus) and reranking (slow, precise scoring of a small shortlist). NVIDIA ships both as NeMo Retriever models, packaged as NIMs — self-hostable inference microservices with an OpenAI-compatible API surface — and exposes them in Python through the langchain-nvidia-ai-endpoints package.

This post builds the retrieval core end to end: embed a corpus, embed a query, score locally with a few lines of NumPy, then rerank the survivors with a cross-encoder and take the final top-k. Every code sample uses real libraries and real method signatures. Model ids are shown as examples to verify against the live catalog at build.nvidia.com — treat them as placeholders you confirm, not gospel.


Setup: one client library, one API key

Install the LangChain integration. It pulls in what you need to call the hosted NIMs on NVIDIA’s API catalog, and the same classes work against a NIM you self-host by pointing base_url at your own endpoint.

pip install langchain-nvidia-ai-endpoints numpy
export NVIDIA_API_KEY="nvapi-..."   # from build.nvidia.com

Two classes carry this whole post:

Both read NVIDIA_API_KEY from the environment by default, so you rarely pass the key explicitly.


Part 1 — Embeddings: text becomes geometry

An embedding model maps a chunk of text to a fixed-length vector of floats. The useful property is that semantic closeness becomes geometric closeness: two passages about the same topic land near each other in the vector space, even when they share no words. That is what lets you find “the document about refund windows” from a query that says “how long do I have to return this” — no keyword overlaps, but the vectors are neighbors.

NVIDIAEmbeddings gives you two methods, and the distinction between them is the single most important detail in this post:

from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings

# Example model id — confirm the exact string in the build.nvidia.com catalog.
embedder = NVIDIAEmbeddings(model="nvidia/nv-embedqa-e5-v5")

corpus = [
    "NIM microservices package a model with an OpenAI-compatible HTTP API.",
    "Cosine similarity measures the angle between two vectors, ignoring magnitude.",
    "A cross-encoder scores a query and document together in a single forward pass.",
    "GPUs accelerate the matrix multiplies at the heart of transformer inference.",
]

doc_vectors = embedder.embed_documents(corpus)   # list[list[float]]
query_vector = embedder.embed_query("What is a NIM?")  # list[float]

print(len(doc_vectors), "document vectors")
print("dimensionality:", len(query_vector))

Why passages and queries are encoded differently

Here is the part people miss. A well-designed retrieval embedding model is asymmetric: passages and queries live in the same vector space so you can compare them, but they are encoded differently on the way in. A stored passage is usually a full, self-contained statement; a query is often a short, incomplete question. Retrieval-tuned models (the “QA” family, hence names like nv-embedqa-*) are trained on query/passage pairs and internally tag each input with its role — frequently by prepending an instruction like “represent this passage for retrieval” versus “represent this question for retrieving passages.” That role tag nudges the two kinds of text so that a terse question lands near the fuller passage that answers it.

You do not construct those tags yourself. The methods encode the distinction for you: embed_documents applies the passage role, embed_query applies the query role. This is the entire reason the API has two methods instead of one generic embed(text). Calling the right method is how you opt into the asymmetric behavior the model was trained to deliver.

The gotcha: embed_documents and embed_query are not interchangeable. If you embed your search query with embed_documents (or embed your corpus with embed_query), the call still succeeds, still returns same-length vectors, and still produces plausible-looking similarity scores — but retrieval quality quietly degrades because the query got the passage role and now sits in the wrong neighborhood. There is no exception, no warning. This is the asymmetric-embedding trap, and it is handled for you only if you call the matching method for each side. Corpus goes through embed_documents; live queries go through embed_query. Always.

Scoring locally with cosine similarity

Once text is vectors, “most relevant” becomes “nearest neighbor.” The standard distance for text embeddings is cosine similarity — the cosine of the angle between two vectors, which ranges from -1 (opposite) to 1 (identical direction) and, crucially, ignores vector length so a long passage isn’t penalized for being long.

A few lines of NumPy score a query against the whole corpus:

import numpy as np

def cosine_scores(query_vec, doc_vecs):
    """Cosine similarity of one query vector against a matrix of doc vectors."""
    q = np.asarray(query_vec, dtype=np.float32)
    m = np.asarray(doc_vecs, dtype=np.float32)
    # Normalize to unit length, then a dot product IS the cosine.
    q_norm = q / (np.linalg.norm(q) + 1e-12)
    m_norm = m / (np.linalg.norm(m, axis=1, keepdims=True) + 1e-12)
    return m_norm @ q_norm   # shape: (num_docs,)

scores = cosine_scores(query_vector, doc_vectors)
ranked = np.argsort(scores)[::-1]   # indices, best first

for rank, idx in enumerate(ranked, 1):
    print(f"{rank}. score={scores[idx]:.3f}  {corpus[idx]}")

The 1e-12 guards against a divide-by-zero on a degenerate zero vector. In a real system you would not loop over every document with NumPy — you would push the vectors into a vector database (FAISS, Milvus, pgvector) that does approximate nearest-neighbor search in sub-linear time. But the math is exactly this, and for a few thousand chunks a normalized dot product in memory is perfectly fine and easy to reason about.

The gotcha: you must embed your corpus and your queries with the same embedding model. Vectors from nv-embedqa-e5-v5 and vectors from some other model do not share a coordinate system; comparing across them yields numbers that mean nothing. This also bites on upgrades — the day you switch embedding models, every stored vector is stale and the whole corpus must be re-embedded before it can be compared against new queries. Version your index by the model that produced it.

The raw openai client also works

Because a NeMo Retriever embedding NIM speaks the OpenAI embeddings API, you are not locked into LangChain. You can call the exact same model with the plain openai client by pointing base_url at the NIM endpoint:

from openai import OpenAI

client = OpenAI(
    base_url="https://integrate.api.nvidia.com/v1",
    api_key="nvapi-...",
)

resp = client.embeddings.create(
    model="nvidia/nv-embedqa-e5-v5",
    input=["NIM microservices expose an OpenAI-compatible API."],
)
vector = resp.data[0].embedding

The tradeoff: the raw client won’t apply the passage-vs-query role for you — some NeMo embedding NIMs accept an input_type field (values along the lines of "passage" / "query") in the request to express that distinction. NVIDIAEmbeddings sets it via the two methods so you don’t have to think about it, which is exactly why the LangChain wrapper is the friendlier default for retrieval. Reach for the raw client when you’re integrating into non-LangChain code or want direct control over the request body — but then it’s on you to send the right input_type.


Part 2 — Reranking: precision after recall

Embedding search is fast because it’s decoupled: every passage is embedded once, ahead of time, and at query time you compare one query vector against precomputed vectors. That decoupling is also its weakness. The model never sees the query and a passage together — it compressed each passage into a single vector before it knew what anyone would ask. Two texts can look like neighbors in the vector space while missing the specific nuance a query cares about. Embeddings give you good recall (the right answer is probably somewhere in the top 50) but mediocre precision at the very top (it might be ranked 11th, not 1st).

A reranker fixes the top. It’s a cross-encoder: instead of embedding query and passage separately, it feeds the query and one candidate passage through the model together and outputs a single relevance score. Because the two texts attend to each other inside the model, a cross-encoder judges relevance far more precisely than a dot product of two independent vectors. The cost is that it’s not precomputable — you pay a forward pass for every (query, candidate) pair at query time, so you can only afford to run it on a shortlist, never the whole corpus.

That’s the two-stage pattern, and it beats either stage alone:

  1. Retrieve a modest shortlist (say, top 20–50) cheaply with embeddings — high recall.
  2. Rerank that shortlist with the cross-encoder and keep the final top-k (say, 3–5) — high precision.

NVIDIARerank wraps the NeMo Retriever reranking NIM. Its method is compress_documents(documents, query), which takes LangChain Document objects plus the query string and returns the documents reordered by relevance, most relevant first.

from langchain_nvidia_ai_endpoints import NVIDIARerank
from langchain_core.documents import Document

# Example model id — confirm the exact string in the catalog.
reranker = NVIDIARerank(
    model="nvidia/nv-rerankqa-mistral-4b-v3",
    top_n=3,   # keep only the best 3 after reranking
)

query = "How does a cross-encoder differ from embedding search?"

# Turn a shortlist of passage strings into Document objects.
shortlist = [Document(page_content=text) for text in corpus]

reranked = reranker.compress_documents(documents=shortlist, query=query)

for rank, doc in enumerate(reranked, 1):
    score = doc.metadata.get("relevance_score")
    print(f"{rank}. score={score}  {doc.page_content}")

compress_documents returns a list of Document objects in relevance order; each carries a relevance_score in its metadata so you can threshold or log it. The top_n constructor argument caps how many survive — the “compress” in the name is literal: it hands back a shorter, sharper list than it received.

The gotcha: reranking is a separate model call that scores every candidate you pass it. Its cost and latency scale with the size of the shortlist, so over-retrieving is not free. Pull the top 500 from your vector store “to be safe” and you’ve turned one cheap embedding lookup into 500 cross-encoder forward passes per query — latency and bill both balloon. Over-retrieve modestly (tens, not hundreds), then rerank. The shortlist is a knob you tune against your latency budget, not a place to be generous.


Wiring it together

Here is the full retrieval core: embed the corpus once, embed the query, shortlist by cosine, rerank the shortlist, return the final top-k. Error handling wraps every network call, because a NIM request can fail for the ordinary reasons any HTTP call can — bad key, rate limit, transient timeout.

import numpy as np
from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings, NVIDIARerank
from langchain_core.documents import Document

EMBED_MODEL = "nvidia/nv-embedqa-e5-v5"       # verify in the catalog
RERANK_MODEL = "nvidia/nv-rerankqa-mistral-4b-v3"  # verify in the catalog


def cosine_scores(query_vec, doc_vecs):
    q = np.asarray(query_vec, dtype=np.float32)
    m = np.asarray(doc_vecs, dtype=np.float32)
    q = q / (np.linalg.norm(q) + 1e-12)
    m = m / (np.linalg.norm(m, axis=1, keepdims=True) + 1e-12)  # new array — don't mutate the caller's index
    return m @ q


def build_index(corpus, embedder):
    """Embed the corpus once. In production this vector store is persisted."""
    vectors = embedder.embed_documents(corpus)   # passage role
    return np.asarray(vectors, dtype=np.float32)


def retrieve(query, corpus, index, embedder, reranker,
             shortlist_size=20, final_k=3):
    # Stage 1: cheap recall via embeddings + cosine.
    q_vec = embedder.embed_query(query)          # query role — NOT embed_documents
    scores = cosine_scores(q_vec, index)
    top_idx = np.argsort(scores)[::-1][:shortlist_size]
    shortlist = [Document(page_content=corpus[i]) for i in top_idx]

    # Stage 2: precise reordering via the cross-encoder reranker.
    reranked = reranker.compress_documents(documents=shortlist, query=query)
    return reranked[:final_k]


def main():
    corpus = [
        "NIM microservices package a model with an OpenAI-compatible HTTP API.",
        "Cosine similarity measures the angle between two vectors, ignoring magnitude.",
        "A cross-encoder scores a query and document together in one forward pass.",
        "GPUs accelerate the matrix multiplies at the heart of transformer inference.",
        "NeMo Retriever provides embedding and reranking models as NIMs.",
        "Vector databases do approximate nearest-neighbor search in sub-linear time.",
    ]
    query = "Why add a reranker on top of embedding search?"

    try:
        embedder = NVIDIAEmbeddings(model=EMBED_MODEL)
        reranker = NVIDIARerank(model=RERANK_MODEL, top_n=3)
    except Exception as exc:
        raise SystemExit(f"Failed to initialize NIM clients: {exc}")

    try:
        index = build_index(corpus, embedder)
        results = retrieve(query, corpus, index, embedder, reranker)
    except Exception as exc:
        # Network, auth, or rate-limit failures surface here.
        raise SystemExit(f"Retrieval failed: {exc}")

    print(f"Query: {query}\n")
    for rank, doc in enumerate(results, 1):
        score = doc.metadata.get("relevance_score")
        print(f"{rank}. score={score}  {doc.page_content}")


if __name__ == "__main__":
    main()

The flow is worth internalizing because it’s the skeleton of essentially every serious RAG pipeline: embed corpus → store → embed query → shortlist by similarity → rerank → final top-k → feed to the LLM. Swap the in-memory NumPy index for a vector database and this scales to millions of chunks without changing the shape.

The gotcha: don’t skip reranking and then blame the LLM for bad RAG. When answers are subtly off-topic, the reflex is to tweak the prompt or swap the generation model — but if stage one handed the LLM the 11th-best passage instead of the 1st, the generator was doomed before it saw a token. Reranking is the cheapest, highest-leverage quality win in most RAG systems precisely because it fixes the input the LLM never gets to see you got wrong. Measure retrieval quality (is the gold passage in your final top-k?) before you touch generation.


Embeddings vs. reranking at a glance

Embeddings vs. reranking at a glance
Embeddings (bi-encoder) Reranking (cross-encoder)
Sees query + passage together No — encoded separately Yes — one joint forward pass
Precomputable Yes — embed corpus once No — runs per query at query time
Speed Fast; scales to millions Slow; run on a shortlist only
Strength Recall (finds the neighborhood) Precision (nails the top ranks)
Python method embed_documents / embed_query compress_documents(documents, query)
Role in the pipeline Stage 1: shortlist the corpus Stage 2: reorder the shortlist

Key takeaways


Further reading