, we took a take a look at how the retrieval mechanism of a RAG pipeline works. In a RAG pipeline, related paperwork from a information base are recognized and retrieved primarily based on how comparable they’re to the consumer’s question. Extra particularly, the similarity of every textual content chunk is quantified utilizing a retrieval metric, like cosine similarity, L2 distance, or dot product as a measure, then the textual content chunks are ranked primarily based on their similarity scores, and at last, we decide the highest textual content chunks which might be essentially the most just like the consumer’s question.
Sadly, excessive similarity scores don’t all the time assure excellent relevance. In different phrases, the retriever might retrieve a textual content chunk that has a excessive similarity rating, however is in actual fact not that helpful – simply not what we have to reply our consumer’s query 🤷🏻♀️. And that is the place re-ranking is launched, as a technique to refine outcomes earlier than feeding them into the LLM.
As in my earlier posts, I’ll as soon as once more be utilizing the Warfare and Peace textual content for instance, licensed as Public Area and simply accessible via Challenge Gutenberg.
• • •
What about Reranking?
Textual content chunks retrieved solely primarily based on a retrieval metric – that’s, uncooked retrieval– is probably not that helpful for a number of completely different causes:
- The retrieved chunks we find yourself with might range largely with the chosen variety of prime chunks ok. Relying on the quantity ok of prime chunks we retrieve, we might get very completely different outcomes.
- We might retrieve chunks which might be semantically near what we’re on the lookout for, however nonetheless off-topic and, in actuality, not applicable to reply the consumer’s question.
- We might get partial matches to particular phrases included within the consumer’s question, resulting in chunks that embrace these particular phrases however are in actual fact irrelevant.
Again to my favourite query from the ‘Warfare and Peace’ instance, if we ask ‘Who’s Anna Pávlovna?’, and use a really small ok (like ok = 2), the retrieved chunks might not include sufficient data to comprehensively reply the query. Conversely, if we enable for a lot of chunks ok to be retrieved (say ok = 20), we’re likely going to additionally retrieve some irrelevant textual content chunks the place ‘Anna Pávlovna’ is simply talked about, however isn’t the subject of the chunk. Thus, the which means of a few of these chunks goes to be unrelated to the consumer’s question and ineffective for answering it. Due to this fact, we want a technique to distinguish the really related retrieved textual content chunks out of all of the retrieved chunks.
Right here, it’s value clarifying that one easy answer for this difficulty can be simply retrieving all the things and passing all the things to the era step (to the LLM). Sadly, this can’t be finished for a bunch of causes, like that the LLMs have sure context home windows, or that the LLMs’ efficiency will get worse when overstuffing with data.
So, that is the difficulty we attempt to sort out by introducing the reranking step. In essence, reranking means re-evaluating the chunks which might be retrieved primarily based on the cosine similarity scores with a extra correct, but additionally costlier and slower technique.
There are numerous strategies for doing this, as as an example, cross-encoders, using an LLM to do the reranking, or utilizing heuristics. Finally, by introducing this additional reranking step, we primarily implement what is known as a two-stage retrieval with reranking, which is an ordinary trade strategy. This enables for enhancing the relevance of the retrieved textual content chunks and, consequently, the standard of the generated responses.
So, let’s take a extra detailed look… 🔍
• • •
Reranking with a Cross-Encoder
Cross-encoders are the usual fashions used for reranking in a RAG framework. In contrast to retriever features used within the preliminary retrieval step, which simply take note of the similarity scores of various textual content chunks, cross-encoders are in a position to carry out a extra in-depth comparability of every of the retrieved textual content chunks with the consumer’s question. Extra particularly, a cross encoder collectively embeds a doc and the consumer’s question and produces a similarity rating. On the flip facet, in cosine similarity-based retrieval, the doc and the consumer’s question are embedded individually from each other, after which their similarity is calculated. Consequently, some data of the unique texts is misplaced when creating the embeddings individually, and a few extra data is preserved when the texts are collectively embedded. Consequently, a cross encoder can assess higher the relevance between two textual content chunks (that’s, the consumer’s question and a doc).
So why not use a cross-encoder within the first place? The reply is as a result of cross-encoders are very gradual. As an example, a cosine similarity seek for about 1,000 passages takes lower than a millisecond. Quite the opposite, utilizing solely a cross-encoder (like ms-marco-MiniLM-L-6-v2
) to go looking the identical set of 1,000 passages and match for a single question can be orders-of-magnitude slower!
That is to be anticipated if you consider it, since utilizing a cross-encoder signifies that we have now to pair every chunk of the information base with the consumer’s question and embed them on the spot, and for each new question. Quite the opposite, with cosine similarity-based retrieval, we get to create all of the embeddings of the information base beforehand, and simply as soon as, after which as soon as the consumer submits a question, we simply have to embed the consumer’s question and calculate the pairwise cosine similarities.
For that purpose, we alter our RAG pipeline appropriately and get the most effective of each worlds; first, we slender down the candidate related chunks with the cosine similarity search, after which, within the second step, we assess the similarity of the retrieved chunks extra precisely with a cross-encoder.
• • •
Again to the ‘Warfare and Peace’ Instance
So now let’s see how all these play out within the ‘Warfare and Peace’ instance by answering another time my favourite query – ‘Who’s Anna Pávlovna?’.
My code to date appears one thing like this:
import os
from langchain.chat_models import ChatOpenAI
from langchain.document_loaders import TextLoader
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.docstore.doc import Doc
import faiss
api_key = "my_api_key"
# initialize LLM
llm = ChatOpenAI(openai_api_key=api_key, mannequin="gpt-4o-mini", temperature=0.3)
# initialize embeddings mannequin
embeddings = OpenAIEmbeddings(openai_api_key=api_key)
# loading paperwork for use for RAG
text_folder = "RAG recordsdata"
paperwork = []
for filename in os.listdir(text_folder):
if filename.decrease().endswith(".txt"):
file_path = os.path.be a part of(text_folder, filename)
loader = TextLoader(file_path)
paperwork.prolong(loader.load())
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
split_docs = []
for doc in paperwork:
chunks = splitter.split_text(doc.page_content)
for chunk in chunks:
split_docs.append(Doc(page_content=chunk))
paperwork = split_docs
# normalize information base embeddings
import numpy as np
def normalize(vectors):
vectors = np.array(vectors)
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
return vectors / norms
doc_texts = [doc.page_content for doc in documents]
doc_embeddings = embeddings.embed_documents(doc_texts)
doc_embeddings = normalize(doc_embeddings)
# faiss index with interior product
import faiss
dimension = doc_embeddings.form[1]
index = faiss.IndexFlatIP(dimension) # interior product index
index.add(doc_embeddings)
# create vector database w FAISS
vector_store = FAISS(embedding_function=embeddings, index=index, docstore=None, index_to_docstore_id=None)
vector_store.docstore = {i: doc for i, doc in enumerate(paperwork)}
def predominant():
print("Welcome to the RAG Assistant. Sort 'exit' to stop.n")
whereas True:
user_input = enter("You: ").strip()
if user_input.decrease() == "exit":
print("Exiting…")
break
# embedding + normalize question
query_embedding = embeddings.embed_query(user_input)
query_embedding = normalize([query_embedding])
# search FAISS index
D, I = index.search(query_embedding, ok=2)
# get related paperwork
relevant_docs = [vector_store.docstore[i] for i in I[0]]
retrieved_context = "nn".be a part of([doc.page_content for doc in relevant_docs])
# D accommodates interior product scores == cosine similarities (since normalized)
print("nTop chunks and their cosine similarity scores:n")
for rank, (idx, rating) in enumerate(zip(I[0], D[0]), begin=1):
print(f"Chunk {rank}:")
print(f"Cosine similarity: {rating:.4f}")
print(f"Content material:n{vector_store.docstore[idx].page_content}n{'-'*40}")
# system immediate
system_prompt = (
"You're a useful assistant. "
"Use ONLY the next information base context to reply the consumer. "
"If the reply is just not within the context, say you do not know.nn"
f"Context:n{retrieved_context}"
)
# messages for LLM
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_input}
]
# generate response
response = llm.invoke(messages)
assistant_message = response.content material.strip()
print(f"nAssistant: {assistant_message}n")
if __name__ == "__main__":
predominant()
For ok = 2, we get the next prime chunks retrieved.

However, if we set ok = 6, we get the next chunks retrieved, and considerably of a extra informative reply, containing further information on our query, like the truth that she’s ‘maid of honor and favourite of the Empress Márya Fëdorovna’.

Now, let’s alter our code to rerank these 6 chunks and see if the highest 2 stay the identical. To do that, we will likely be utilizing a cross-encoder mannequin to re-rank the top-k retrieved paperwork earlier than passing them to your LLM. Extra particularly, I will likely be using the cross-encoder/ms-marco-TinyBERT-L2 cross-encoder, which is a straightforward, pre-trained cross-encoding mannequin, working on prime of PyTorch. To take action, we additionally have to import the torch
and transformers
libraries.
import torch
from transformers import CrossEncoder
Then we will initialise the cross-encoder and outline a perform for reranking the highest ok chunks retrieved from the vector search:
# initialize cross-encoder mannequin
cross_encoder = CrossEncoder('cross-encoder/ms-marco-TinyBERT-L-2', machine='cuda' if torch.cuda.is_available() else 'cpu')
def rerank_with_cross_encoder(question, relevant_docs):
pairs = [(query, doc.page_content) for doc in relevant_docs] # pairs of (question, doc) for cross-encoder
scores = cross_encoder.predict(pairs) # relevance scores from cross-encoder mannequin
ranked_indices = np.argsort(scores)[::-1] # kind paperwork primarily based on cross-encoder rating (the upper, the higher)
ranked_docs = [relevant_docs[i] for i in ranked_indices]
ranked_scores = [scores[i] for i in ranked_indices]
return ranked_docs, ranked_scores
… and in addition alter of perform as follows:
...
# search FAISS index
D, I = index.search(query_embedding, ok=6)
# get related paperwork
relevant_docs = [vector_store.docstore[i] for i in I[0]]
# rerank with our perform
reranked_docs, reranked_scores = rerank_with_cross_encoder(user_input, relevant_docs)
# get prime reranked chunks
retrieved_context = "nn".be a part of([doc.page_content for doc in reranked_docs[:2]])
# D accommodates interior product scores == cosine similarities (since normalized)
print("nTop 6 Retrieved Chunks:n")
for rank, (idx, rating) in enumerate(zip(I[0], D[0]), begin=1):
print(f"Chunk {rank}:")
print(f"Similarity: {rating:.4f}")
print(f"Content material:n{vector_store.docstore[idx].page_content}n{'-'*40}")
# show prime reranked chunks
print("nTop 2 Re-ranked Chunks:n")
for rank, (doc, rating) in enumerate(zip(reranked_docs[:2], reranked_scores[:2]), begin=1):
print(f"Rank {rank}:")
print(f"Reranker Rating: {rating:.4f}")
print(f"Content material:n{doc.page_content}n{'-'*40}")
...
… and at last, these are the highest 2 chunks, and the respective reply we get, after re-ranking with the cross-encoder:

Discover how these 2 chunks are completely different from the highest 2 chunks we received from the vector search.
Thus, the significance of the reranking step is rendered clearly. We use the vector search to slender down the presumably related chunks, out of all of the out there paperwork within the information base, however then use the reranking step to establish essentially the most related chunks precisely.

We are able to think about the two-step retrieval as a funnel: the primary stage pulls in a large set of candidate chunks, and the reranking stage filters out the irrelevant ones. What’s left is essentially the most helpful context, resulting in clearer and extra correct solutions.
• • •
On my thoughts
So, it turns into obvious is an important step for constructing a strong RAG pipeline. Basically, it permits us to bridge the hole between the fast however not so exact vector search, and context-aware solutions. By performing a two-step retrieval, with the vector search being step one, and the second step being the reranking, we get the most effective of each worlds: effectivity at scale and better high quality responses. In apply, this two-stage strategy is what makes fashionable RAG pipelines each sensible and highly effective.
• • •
Liked this publish? Let’s be associates! Be part of me on:
📰Substack 💌 Medium 💼LinkedIn ☕Purchase me a espresso!
• • •
What about pialgorithms?
Seeking to convey the facility of RAG into your group?
pialgorithms can do it for you 👉 guide a demo at the moment!