← Back to DevBytes

Advanced RAG: Re-ranking, Query Expansion, and HyDE

Introduction to Advanced RAG Techniques

Retrieval-Augmented Generation (RAG) has become the standard pattern for grounding large language models in external knowledge. A naive RAG pipeline—embed a query, retrieve top-k chunks, feed them into the LLM prompt—works well for simple question-answering but often fails when queries are ambiguous, short, or require reasoning across multiple documents. Three advanced techniques address these shortcomings: re-ranking to prioritize the most relevant chunks, query expansion to capture different facets of a user's intent, and HyDE (Hypothetical Document Embeddings) to bridge the gap between query language and document language. This tutorial covers each technique with complete, runnable code examples and best practices drawn from production RAG systems.

1. Re-ranking for Better Retrieval Precision

What is Re-ranking?

Re-ranking is a two-stage retrieval strategy. The first stage retrieves a larger set of candidate documents (say, 50–100) using fast, approximate methods like vector similarity search or BM25. The second stage applies a more computationally expensive but more accurate model—typically a cross-encoder—to score and reorder the candidates, keeping only the top-k (say, 5–10) for the final prompt. Cross-encoders process the query and document together through a transformer, capturing nuanced relevance signals that bi-encoder embedding models miss.

Why Re-ranking Matters

Embedding-based retrieval treats relevance as a symmetric similarity problem: it computes a cosine between two vectors. But relevance is often asymmetric. A query like "How do I fix a leaking pipe?" and a document titled "Plumbing 101" may have low cosine similarity if their vocabulary differs, yet the document is highly relevant. Cross-encoders model the exact interaction between query and document tokens, dramatically improving precision. In practice, adding re-ranking can boost hit-rate@5 by 10–30%.

How to Implement Re-ranking

The example below uses a lightweight cross-encoder from the sentence-transformers library to re-rank candidates retrieved by a bi-encoder. You can swap in Cohere's Rerank API or a custom model as needed.

# pip install sentence-transformers chromadb numpy
import numpy as np
from sentence_transformers import SentenceTransformer, CrossEncoder
import chromadb

# --- Step 1: Set up the vector store with sample documents ---
documents = [
    "To fix a leaking pipe, first turn off the main water valve. Then cut out the damaged section and solder a replacement piece using a propane torch.",
    "Plumbing basics: pipes, fittings, and valves. Understanding water pressure and flow rates.",
    "Baking a sourdough loaf requires a mature starter, high-protein flour, and a dutch oven for steam retention.",
    "Sourdough starter maintenance: feed equal parts flour and water daily, keep at room temperature.",
    "Water valve types: gate valves, ball valves, and butterfly valves explained with diagrams.",
    "How to solder copper pipes: clean the joint, apply flux, heat evenly, and feed solder wire.",
]

# Bi-encoder for first-stage retrieval
bi_encoder = SentenceTransformer("all-MiniLM-L6-v2")
doc_embeddings = bi_encoder.encode(documents, normalize_embeddings=True)

# In-memory vector store using Chroma
client = chromadb.Client()
collection = client.create_collection(name="docs", embedding_function=None)
for i, doc in enumerate(documents):
    collection.add(
        ids=[str(i)],
        documents=[doc],
        embeddings=[doc_embeddings[i].tolist()],
    )

# Cross-encoder for second-stage re-ranking
cross_encoder = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

# --- Step 2: Two-stage retrieval function ---
def retrieve_and_rerank(query: str, initial_k: int = 5, final_k: int = 3):
    """
    Stage 1: Retrieve initial_k candidates using bi-encoder similarity.
    Stage 2: Re-rank with cross-encoder and return final_k results.
    """
    # Stage 1: bi-encoder retrieval
    query_embedding = bi_encoder.encode(query, normalize_embeddings=True).tolist()
    results = collection.query(query_embeddings=[query_embedding], n_results=initial_k)
    
    candidates = []
    for doc_id, doc_text, distance in zip(
        results["ids"][0], results["documents"][0], results["distances"][0]
    ):
        candidates.append({"id": doc_id, "text": doc_text, "distance": distance})
    
    # Stage 2: cross-encoder re-ranking
    pairs = [(query, cand["text"]) for cand in candidates]
    scores = cross_encoder.predict(pairs)  # Higher score = more relevant
    
    # Sort by cross-encoder score descending
    for cand, score in zip(candidates, scores):
        cand["rerank_score"] = float(score)
    
    candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
    
    return candidates[:final_k]

# --- Step 3: Test it ---
query = "How do I repair a leaky pipe?"
ranked = retrieve_and_rerank(query, initial_k=5, final_k=3)

print("Re-ranked results for query:", query)
for i, doc in enumerate(ranked):
    print(f"  Rank {i+1}: [{doc['rerank_score']:.2f}] {doc['text'][:80]}...")

Running this yields the exact soldering document first, followed by the water valve document, even though the bi-encoder alone might rank "Plumbing basics" higher due to vocabulary overlap. The cross-encoder correctly identifies the procedural repair content as most relevant.

Best Practices for Re-ranking

2. Query Expansion: Broadening the Search

What is Query Expansion?

Query expansion transforms a short, ambiguous user query into multiple richer queries that capture different facets of the user's intent. A query like "apple stock" could mean the fruit's inventory levels, Apple Inc.'s share price, or even a broth made from apples. Expansion techniques generate alternative formulations—synonyms, related terms, paraphrases, or decomposed sub-questions—so that retrieval has multiple chances to hit relevant documents.

Why Query Expansion Matters

Users are notoriously terse. The average search query is 2–3 words. Embedding models struggle with such sparse input because there is little context to disambiguate meaning. By expanding the query, you effectively cast a wider net. Multi-query retrieval—where you retrieve documents for each expanded query and union/deduplicate the results—often improves recall by 15–25% without sacrificing precision when paired with re-ranking.

How to Implement Query Expansion

Below are three practical methods: LLM-based paraphrasing, keyword extraction + synonym boosting, and using retrieved documents themselves to expand the query (a technique sometimes called "query2doc"). We'll focus on the LLM-based approach as it's the most powerful.

# pip install openai sentence-transformers chromadb numpy
import os
from openai import OpenAI
from sentence_transformers import SentenceTransformer
import chromadb
import numpy as np

# --- Setup ---
client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
bi_encoder = SentenceTransformer("all-MiniLM-L6-v2")

# Sample document corpus (same as before)
documents = [
    "Apple Inc. stock (AAPL) rose 2.3% today after strong iPhone 15 sales figures were released.",
    "Apple fruit storage: keep apples in a cool, dark place. Refrigeration extends shelf life to 2 months.",
    "How to make apple stock broth: simmer apple peels, cores, cinnamon, and water for 1 hour.",
    "AAPL quarterly earnings: revenue hit $89.5B, up 4% year-over-year, driven by services growth.",
    "Apple orchard management: pruning techniques for maximum yield in Honeycrisp trees.",
    "Investing in tech stocks: analyzing P/E ratios, growth metrics, and market sentiment for FAANG companies.",
]

# Build vector store
doc_embeddings = bi_encoder.encode(documents, normalize_embeddings=True)
client_chroma = chromadb.Client()
collection = client_chroma.create_collection(name="docs_expanded")
for i, (doc, emb) in enumerate(zip(documents, doc_embeddings)):
    collection.add(ids=[str(i)], documents=[doc], embeddings=[emb.tolist()])

# --- Query Expansion via LLM ---
def expand_query_llm(original_query: str, num_expansions: int = 3) -> list[str]:
    """
    Use an LLM to generate multiple rephrased/disambiguated versions of the query.
    Returns the original query plus the expanded versions.
    """
    prompt = f"""Given the user query below, generate {num_expansions} alternative search queries 
that capture different possible interpretations or facets. Each query should be self-contained 
and suitable for vector search. Return them as a JSON array of strings.

Original query: "{original_query}"

Example:
Original: "apple stock"
Output: ["Apple Inc. stock price and market performance", "how to store apples and maintain freshness", "recipe for homemade apple stock broth"]

Output only the JSON array, nothing else."""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.3,
        max_tokens=300,
    )
    
    import json
    expanded = json.loads(response.choices[0].message.content.strip())
    return [original_query] + expanded

# --- Multi-query retrieval ---
def retrieve_with_expansion(query: str, expansions_per_query: int = 3, top_k_per_query: int = 5) -> list[str]:
    """
    Expand the query, retrieve for each expansion, and deduplicate results.
    Returns a deduplicated list of document texts.
    """
    all_queries = expand_query_llm(query, num_expansions=expansions_per_query)
    print(f"Original + expanded queries: {all_queries}")
    
    seen_ids = set()
    all_docs = []
    
    for q in all_queries:
        q_emb = bi_encoder.encode(q, normalize_embeddings=True).tolist()
        results = collection.query(query_embeddings=[q_emb], n_results=top_k_per_query)
        for doc_id, doc_text in zip(results["ids"][0], results["documents"][0]):
            if doc_id not in seen_ids:
                seen_ids.add(doc_id)
                all_docs.append({"id": doc_id, "text": doc_text, "source_query": q})
    
    return all_docs

# --- Test ---
query = "apple stock"
retrieved = retrieve_with_expansion(query, expansions_per_query=3, top_k_per_query=3)

print("\nRetrieved documents (deduplicated):")
for doc in retrieved:
    print(f"  [{doc['id']}] from query '{doc['source_query'][:40]}...': {doc['text'][:80]}...")

Running this on "apple stock" retrieves documents about Apple Inc. shares, apple fruit storage, and apple broth—ensuring coverage of all possible intents. In a real system, you would then pass these documents through a re-ranker (as covered in Section 1) to surface only the ones matching the user's actual intent.

Best Practices for Query Expansion

3. HyDE: Hypothetical Document Embeddings

What is HyDE?

HyDE (Hypothetical Document Embeddings) flips the retrieval paradigm: instead of embedding the user's query directly, you first ask an LLM to generate a hypothetical document that would answer the query. You then embed that generated document and use its embedding to search your actual document store. The insight is that generated documents, even if factually incorrect, tend to match the structure and vocabulary of real documents in your corpus far better than short queries do. This technique was introduced by Gao et al. in 2022 and has proven especially effective for technical and knowledge-heavy domains.

Why HyDE Matters

There is often a language gap between how users ask questions and how documents are written. Users type "fix leaky pipe" while documents say "copper pipe soldering procedure for water leak repair." Embedding the short query yields a vector that may be closer to general plumbing FAQs than to the specific repair manual you need. HyDE bridges this gap by first generating a document-like passage—"To fix a leaky pipe, first shut off the water valve, then..."—whose embedding naturally clusters near your actual repair documents. The technique can improve retrieval accuracy by 10–20% in domains with sparse or jargon-heavy queries.

How to Implement HyDE

The implementation has three steps: (1) generate a hypothetical document using an LLM, (2) embed that document, (3) use the embedding for similarity search. You can optionally generate multiple hypothetical documents and average their embeddings for stability.

# pip install openai sentence-transformers chromadb numpy
import os
import numpy as np
from openai import OpenAI
from sentence_transformers import SentenceTransformer
import chromadb

# --- Setup ---
client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
bi_encoder = SentenceTransformer("all-MiniLM-L6-v2")

# Technical document corpus
documents = [
    "Section 4.2: Soldering Copper Pipes. Clean the pipe ends with emery cloth. Apply flux evenly to both surfaces. Heat the joint with a propane torch until the flux sizzles. Feed 60/40 solder wire into the joint. The solder will wick into the joint via capillary action. Allow to cool before testing.",
    "Water Leak Detection Guide: Use a moisture meter to pinpoint the source. Check around pipe joints and valve connections first. Stains on ceiling drywall indicate an overhead leak.",
    "Plumbing Permit Requirements: Residential plumbing work involving new pipe installation requires a permit from the city building department. Repairs to existing fixtures do not require permits.",
    "Copper vs PEX Piping: Copper is durable and antimicrobial but expensive. PEX is flexible and cheaper but degrades under UV exposure.",
    "Soldering Safety: Always wear heat-resistant gloves. Keep a fire extinguisher nearby. Never solder near flammable materials. Ensure adequate ventilation.",
]
doc_embeddings = bi_encoder.encode(documents, normalize_embeddings=True)

chroma_client = chromadb.Client()
collection = chroma_client.create_collection(name="hyde_docs")
for i, (doc, emb) in enumerate(zip(documents, doc_embeddings)):
    collection.add(ids=[str(i)], documents=[doc], embeddings=[emb.tolist()])

# --- HyDE: Generate hypothetical document, then embed and search ---
def generate_hypothetical_document(query: str, num_hypotheses: int = 1) -> list[str]:
    """
    Ask the LLM to write a hypothetical document that would answer the query.
    The document should mimic the style and detail level of documents in the corpus.
    """
    prompt = f"""You are an expert technical writer. Write a detailed, factual passage 
that answers the following question. Write in the style of a technical manual or textbook. 
Include specific steps, measurements, and terminology as appropriate. 
The passage should be 3-5 sentences long.

Question: {query}

Hypothetical document:"""
    
    hypotheses = []
    for _ in range(num_hypotheses):
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.7,  # Some diversity for multiple hypotheses
            max_tokens=200,
        )
        hypotheses.append(response.choices[0].message.content.strip())
    
    return hypotheses

def hyde_retrieve(query: str, num_hypotheses: int = 1, top_k: int = 3) -> list[dict]:
    """
    Full HyDE pipeline: generate hypothetical docs, embed them, and search.
    If multiple hypotheses are generated, average their embeddings.
    """
    # Step 1: Generate hypothetical document(s)
    hypo_docs = generate_hypothetical_document(query, num_hypotheses)
    print("Generated hypothetical document(s):")
    for i, hd in enumerate(hypo_docs):
        print(f"  Hypothesis {i+1}: {hd[:150]}...")
    
    # Step 2: Embed the hypothetical document(s)
    hypo_embeddings = bi_encoder.encode(hypo_docs, normalize_embeddings=True)
    
    # Average embeddings if multiple hypotheses (improves stability)
    if len(hypo_embeddings) > 1:
        avg_embedding = np.mean(hypo_embeddings, axis=0)
        # Re-normalize after averaging
        avg_embedding = avg_embedding / np.linalg.norm(avg_embedding)
    else:
        avg_embedding = hypo_embeddings[0]
    
    # Step 3: Search with the hypothetical document embedding
    results = collection.query(
        query_embeddings=[avg_embedding.tolist()],
        n_results=top_k,
    )
    
    retrieved = []
    for doc_id, doc_text, distance in zip(
        results["ids"][0], results["documents"][0], results["distances"][0]
    ):
        retrieved.append({"id": doc_id, "text": doc_text, "distance": distance})
    
    return retrieved

# --- Compare: direct query embedding vs HyDE ---
query = "How do I solder a copper pipe joint?"

# Method A: Direct query embedding
query_emb = bi_encoder.encode(query, normalize_embeddings=True).tolist()
direct_results = collection.query(query_embeddings=[query_emb], n_results=3)

print("=== Direct Query Embedding Results ===")
for i, (doc_id, doc_text, dist) in enumerate(zip(
    direct_results["ids"][0], direct_results["documents"][0], direct_results["distances"][0]
)):
    print(f"  Rank {i+1} (dist={dist:.4f}): {doc_text[:100]}...")

# Method B: HyDE
print("\n=== HyDE Results ===")
hyde_results = hyde_retrieve(query, num_hypotheses=2, top_k=3)
for i, doc in enumerate(hyde_results):
    print(f"  Rank {i+1} (dist={doc['distance']:.4f}): {doc['text'][:100]}...")

Running this comparison shows HyDE retrieving the exact soldering procedure document (Section 4.2) as the top result, while direct query embedding may rank the more general "Copper vs PEX Piping" or "Soldering Safety" documents higher. The generated hypothetical document naturally includes phrases like "clean the pipe," "apply flux," and "heat the joint" that overlap with the real technical document's vocabulary.

Best Practices for HyDE

4. Combining All Three Techniques: A Production-Grade Pipeline

The real power comes from chaining these techniques together. Here is a complete pipeline that (1) expands the user query into multiple formulations, (2) generates HyDE embeddings for each expanded query, (3) retrieves candidates using the HyDE embeddings, unions the results, and (4) re-ranks the unified candidate set with a cross-encoder.

# Complete Advanced RAG Pipeline: Query Expansion + HyDE + Re-ranking
# pip install openai sentence-transformers chromadb numpy
import os
import json
import numpy as np
from openai import OpenAI
from sentence_transformers import SentenceTransformer, CrossEncoder
import chromadb

# --- Configuration ---
INITIAL_K_PER_QUERY = 10   # Candidates per expanded query
FINAL_K = 5                # Final documents after re-ranking
NUM_EXPANSIONS = 3         # Number of expanded queries
NUM_HYPOTHESES = 2         # HyDE hypotheses per query

# --- Initialize models and store ---
client_llm = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
bi_encoder = SentenceTransformer("all-MiniLM-L6-v2")
cross_encoder = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

# Sample corpus (mixing plumbing and finance to demonstrate disambiguation)
documents = [
    {"id": "doc_01", "text": "Copper pipe soldering: clean with emery cloth, apply flux, heat joint with propane torch, feed 60/40 solder. The solder flows via capillary action into the joint space, creating a watertight seal."},
    {"id": "doc_02", "text": "Apple Inc. (AAPL) reported Q4 revenue of $89.5 billion, a 4% increase year-over-year. Services revenue grew 12% to $23.1 billion. iPhone revenue was $43.8 billion."},
    {"id": "doc_03", "text": "Water leak emergency shutoff: locate the main water valve (typically in basement or crawlspace). Turn clockwise to close. For gate valves, turn the handle until it stops."},
    {"id": "doc_04", "text": "AAPL stock analysis: price-to-earnings ratio is 28.5, above the tech sector average of 22. Analysts project 8% revenue growth in FY2025."},
    {"id": "doc_05", "text": "Soldering safety protocol: wear PPE including heat-resistant gloves and safety glasses. Ensure workspace is free of flammable materials. Have a fire extinguisher accessible."},
    {"id": "doc_06", "text": "How to store apples: keep apples in a perforated plastic bag in the refrigerator crisper drawer at 32-35°F. Check weekly for spoilage. Most varieties last 2-4 months."},
    {"id": "doc_07", "text": "Plumbing code requirements: soldered joints must withstand 200 psi pressure testing. Use lead-free solder compliant with NSF/ANSI 61 standard."},
    {"id": "doc_08", "text": "FAANG stock performance 2024: Meta up 180%, Amazon up 45%, Apple up 28%, Netflix up 55%, Google up 40%. Tech sector outperformed S&P 500 by 22 points."},
    {"id": "doc_09", "text": "Pipe leak temporary fix: wrap the leaking section with rubber tape or a pipe clamp. Tighten securely. This holds until permanent repair can be completed."},
    {"id": "doc_10", "text": "Apple cider vinegar stock recipe: combine apple scraps, sugar, and water. Ferment for 3-4 weeks, strain, and bottle. Used as a health tonic and culinary ingredient."},
]

# Build vector store
chroma_client = chromadb.Client()
collection = chroma_client.create_collection(name="advanced_rag")
for doc in documents:
    emb = bi_encoder.encode(doc["text"], normalize_embeddings=True).tolist()
    collection.add(ids=[doc["id"]], documents=[doc["text"]], embeddings=[emb])

# --- Step 1: Query Expansion ---
def expand_query(query: str, n: int = NUM_EXPANSIONS) -> list[str]:
    prompt = f"""Generate {n} alternative search queries that capture different possible meanings 
of the following query. Return ONLY a JSON array of strings.

Query: "{query}"

Example format: ["interpretation one", "interpretation two", "interpretation three"]"""
    
    response = client_llm.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.3,
        max_tokens=300,
    )
    content = response.choices[0].message.content.strip()
    # Handle possible markdown code block wrapping
    if "" in content:
        content = content.split("")[1]
        if content.startswith("json"):
            content = content[4:]
    expanded = json.loads(content)
    return [query] + expanded

# --- Step 2: HyDE embedding generation ---
def generate_hyde_embedding(query: str, n_hypotheses: int = NUM_HYPOTHESES) -> np.ndarray:
    prompt = f"""Write a detailed, factual passage (3-5 sentences) that directly answers this question.
Write in the style of a textbook, manual, or reference document.

Question: {query}

Answer passage:"""
    
    embeddings = []
    for _ in range(n_hypotheses):
        response = client_llm.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.7,
            max_tokens=200,
        )
        hypo_text = response.choices[0].message.content.strip()
        emb = bi_encoder.encode(hypo_text, normalize_embeddings=True)
        embeddings.append(emb)
    
    avg_emb = np.mean(embeddings, axis=0)
    return avg_emb / np.linalg.norm(avg_emb)

# --- Step 3: Multi-query HyDE retrieval ---
def multi_query_hyde_retrieve(expanded_queries: list[str], k_per_query: int = INITIAL_K_PER_QUERY) -> list[dict]:
    seen_ids = set()
    all_candidates = []
    
    for q in expanded_queries:
        hyde_emb = generate_hyde_embedding(q).tolist()
        results = collection.query(query_embeddings=[hyde_emb], n_results=k_per_query)
        for doc_id, doc_text, dist in zip(results["ids"][0], results["documents"][0], results["distances"][0]):
            if doc_id not in seen_ids:
                seen_ids.add(doc_id)
                all_candidates.append({
                    "id": doc_id,
                    "text": doc_text,
                    "hyde_distance": dist,
                    "source_query": q,
                })
    
    return all_candidates

# --- Step 4: Cross-encoder re-ranking ---
def rerank_candidates(query: str, candidates: list[dict], final_k: int = FINAL_K) -> list[dict]:
    if not candidates:
        return []
    
    pairs = [(query, cand["text"]) for cand in candidates]
    scores = cross_encoder.predict(pairs)
    
    for cand, score in zip(candidates, scores):
        cand["rerank_score"] = float(score)
    
    candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
    return candidates[:final_k]

# --- Full pipeline ---
def advanced_rag_pipeline(user_query: str) -> list[dict]:
    print(f"Processing query: '{user_query}'\n")
    
    # Step 1: Expand
    all_queries = expand_query(user_query)
    print(f"Expanded queries ({len(all_queries)}):")
    for q in all_queries:
        print(f"  - {q}")
    
    # Step 2 & 3: HyDE retrieval for each expansion
    print("\nGenerating HyDE embeddings and retrieving...")
    candidates = multi_query_hyde_retrieve(all_queries)
    print(f"Retrieved {len(candidates)} unique candidates")
    
    # Step 4: Re-rank
    print("\nRe-ranking with cross-encoder...")
    final_docs = rerank_candidates(user_query, candidates)
    
    return final_docs

# --- Run and display ---
query = "How do I fix a leaking copper pipe safely?"
results = advanced_rag_pipeline(query)

print("\n=== Final Re-ranked Results ===")
for i, doc in enumerate(results):
    print(f"Rank {i+1} [score={doc['rerank_score']:.3f}]: {doc['text'][:120]}...")

This pipeline is modular—you can swap the LLM provider, embedding model, cross-encoder, or vector store independently. The output for a plumbing query correctly ranks the soldering procedure, safety protocol, and plumbing code document at the top, with financial documents excluded entirely.

Conclusion

Advanced RAG techniques transform a basic retrieval pipeline into a robust, high-precision system. Re-ranking compensates for the limitations of embedding similarity by using cross-encoders to deeply compare query-document pairs. Query expansion handles user ambiguity by generating multiple interpretations and casting a wider retrieval net. HyDE bridges the vocabulary gap between queries and documents by first generating a document-like passage and then embedding it. Each technique addresses a distinct failure mode of naive RAG, and when combined—expand, embed hypothetically, retrieve, and re-rank—they deliver state-of-the-art retrieval quality. The code examples in this tutorial are designed to be copied, run, and adapted directly into your own projects. Start with one technique, measure the improvement, then layer on the others as your retrieval demands grow.

— Ad —

Google AdSense will appear here after approval

← Back to all articles