Building a code search chatbot with FAISS and the Strands SDK
A chatbot that answers questions about the Strands SDK by searching its own source code: FAISS for retrieval, mem0 for conversation memory, Gemini for generation. Retrieval was done in a day while context assembly filled the rest of the week, and this blogpost walks through both, including the re-ranking corner I cut.

Table of Contents
Everyone spent last year declaring RAG dead, and after building a retrieval system from scratch I mostly agree about the word, since it always collapsed three different engineering problems, finding information, assembling context, and generating answers, into one buzzword that sounded like a solved pattern. The problems themselves did not go anywhere. Jeff Huber, the CEO of Chroma, gave the framing I ended up building against in a Latent Space interview:
"Context engineering is the job of figuring out what should be in the context window at any given LLM generation step."
What I like about this framing is that it decomposes into pieces you can reason about independently, retrieval, filtering, re-ranking, assembly, memory, evaluation, each with its own failure modes and its own knobs. So I built a small system to find out which piece deserves the attention: a chatbot that answers questions about the Strands SDK by searching its own source code, the Python files, the markdown docs, and the examples. The stack is FAISS for dense vector search with local embeddings, the Strands SDK itself for agent orchestration, mem0 for conversation memory, and Gemini 2.5 Flash Lite for generation. The full tutorial, a self-contained Jupyter notebook with one-command setup, lives at learn-strands/rag-chatbot.
I expected retrieval to take most of the time. It was done in a day, while assembling context and memory filled the rest of the week, and the sections below follow that same order.
Retrieval in a day
For a codebase under 10K chunks you do not need approximate indices. FAISS with IndexFlatL2 and sentence-transformers gives you exact search with 100% recall, fast enough that nothing more sophisticated earns its complexity:
class FAISSVectorStore:
"""FAISS-based vector store with local embeddings."""
def __init__(self, local_model: str = "all-MiniLM-L6-v2", dimension: int = 384):
self.embedder = SentenceTransformer(local_model)
self.dimension = dimension
self.index = faiss.IndexFlatL2(dimension)
self.documents = []
def add_documents(self, documents: List[Dict[str, Any]], batch_size: int = 32):
"""Add documents with batched embedding generation."""
texts = [doc.get('text', '') for doc in documents]
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
embeddings = self.embedder.encode(batch, show_progress_bar=False)
all_embeddings.append(embeddings)
embeddings_array = np.vstack(all_embeddings).astype('float32')
faiss.normalize_L2(embeddings_array)
self.index.add(embeddings_array)
self.documents.extend(documents)
def search(self, query: str, k: int = 5, threshold: float = 0.3) -> List[Dict[str, Any]]:
"""Dense vector search with cosine similarity."""
if self.index.ntotal == 0:
return []
query_embedding = self.embedder.encode([query]).astype('float32')
faiss.normalize_L2(query_embedding)
distances, indices = self.index.search(query_embedding, min(k, self.index.ntotal))
similarities = 1 - (distances[0] / 2) # L2 on normalized vectors ā cosine
results = []
for idx, similarity in zip(indices[0], similarities):
if similarity >= threshold and idx < len(self.documents):
doc = self.documents[int(idx)].copy()
doc['similarity'] = float(similarity)
results.append(doc)
return results
L2 normalization on the embeddings turns Euclidean distance into cosine similarity, and the 0.3 threshold is deliberately aggressive, since I would rather feed the agent fewer, better chunks than flood the context with marginal matches.
The search itself becomes a Strands tool that the agent calls explicitly:
@tool
def search_strands_sdk(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
"""Semantic search over Strands SDK codebase."""
results = vector_store.search(query=query, k=max_results, threshold=0.3)
return [{
'text': r['text'],
'similarity': r['similarity'],
'file_path': r.get('file_path', 'unknown'),
'file_type': r.get('file_type', 'unknown')
} for r in results]
@tool
def search_by_file_type(query: str, file_type: str, max_results: int = 5) -> List[Dict[str, Any]]:
"""Combine dense search with metadata filtering."""
all_results = vector_store.search(query=query, k=max_results * 3, threshold=0.3)
filtered = [r for r in all_results if r.get('file_type', '') == file_type][:max_results]
return [{
'text': r['text'],
'similarity': r['similarity'],
'file_path': r.get('file_path', 'unknown')
} for r in filtered]
Huber calls this "naming the primitives", and the name earns its keep in practice: when retrieval is an explicit, composable step instead of a hidden subroutine, you can reason about it, debug it, and swap it out. search_strands_sdk handles broad semantic queries, search_by_file_type narrows by extension, and both show up in the agent's tool log when something goes wrong.
Accurate retrieval saves tokens downstream
The effect I did not anticipate: when the first stage is precise, going from 6,000 chunks to 20 good ones instead of 200 mediocre ones, the downstream agent burns dramatically fewer tokens. Sloppy retrieval hands the agent a pile of vaguely relevant chunks, it cannot find a clear answer, and it starts exploring, calling the search tool again with a rephrased query, reasoning through ambiguous context, or producing a hedged answer that invites a follow-up question, and every one of those extra steps costs tokens. With tight retrieval the agent reads five chunks, finds the answer, cites the source, and stops.
Huber frames the first stage in terms of recall:
"Using signals like vector search, like full text search, like metadata filtering... to go from 10,000 down to 300."
What I saw is that precision matters at least as much once an agent is the consumer, because an agent with twenty excellent chunks answers immediately, while one with two hundred okay chunks wanders off into re-queries and hedges.
Re-ranking, the corner I cut
Huber is direct about re-ranking:
"Using an LLM as a re-ranker and brute forcing from 300 down to 30, I've seen now emerging... way more cost effective than a lot of people realize."
That is a separate pass: take the 300 first-stage candidates, score them with a cross-encoder or an LLM, keep the top 30, and only then generate the answer from those 30, so the re-ranking step reduces what enters the generation context. My system does not have that pass. What I built is a response specialist that receives all retrieved chunks in a single prompt and generates from them:
@tool
def response_specialist_tool(query: str, context: str) -> str:
"""Generate response from retrieved context."""
agent = Agent(
system_prompt="""You are a response generation specialist for Strands SDK queries.
Generate answers based ONLY on provided context.
Guidelines:
1. PRIORITIZE the most relevant chunks from context
2. CITE sources with file paths and line numbers
3. COMBINE information from multiple chunks coherently
4. If context is insufficient, say so clearly
5. Provide runnable code snippets when possible""",
tools=[use_llm],
model=gemini_model
)
response = agent(f"""User Question: {query}
Retrieved Context:
{context}
Generate a comprehensive answer using ONLY the context above.""")
return str(response)
For a while I told myself the LLM's attention was doing "implicit re-ranking", focusing on the relevant chunks and ignoring the rest, and that is not what re-ranking is. Everything is still in the context window, every token still counts against the budget, and the marginal chunks contribute to what Huber calls context rot:
"As you use more and more tokens, the model can pay attention to less and then also can reason sort of less effectively."
With tight first-stage retrieval sending 5-10 chunks instead of 300, the missing pass is survivable for a codebase this size, and it will not survive scale, so a proper scoring step is the next thing on the list.
Assembly and memory took the rest of the week
Assembly is the problem of taking retrieved chunks, disconnected fragments from different files and different sections of documentation, and composing them into context the model can reason about coherently, and it is more than concatenation: the order matters, the framing matters, and so does whether you include file paths and line numbers or show a chunk verbatim rather than summarized.
Conversation memory is the same problem stretched across turns. The model needs context from earlier in the chat, and appending the full history grows without bound and walks straight into context rot, which is where mem0 earned its place in the stack:
mem0_config = {
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": "strands_chat",
"embedding_model_dims": 384,
"path": ":memory:"
}
},
"embedder": {
"provider": "huggingface",
"config": {"model": "all-MiniLM-L6-v2"}
}
}
memory = Memory.from_config(mem0_config)
@tool
def remember_conversation(user_message: str, assistant_response: str, user_id: str = "user") -> str:
"""Extract and store salient facts from the conversation."""
memory.add(
f"User asked: {user_message}\nAssistant responded: {assistant_response}",
user_id=user_id
)
return f"Stored conversation in memory for {user_id}"
@tool
def recall_conversation(query: str = "", user_id: str = "user") -> str:
"""Retrieve relevant conversation history ā not the full transcript."""
if not query:
query = "recent conversation history"
results = memory.search(query, user_id=user_id, limit=5)
if not results or 'results' not in results or not results['results']:
return "No previous conversation history found."
history = []
for item in results['results']:
if 'memory' in item:
history.append(item['memory'])
return "\n\n".join(history) if history else "No relevant conversation history found."
Instead of appending full dialogue turns, mem0 extracts salient facts and stores them as searchable memories, so when the agent needs conversation context it retrieves the relevant memories and not the entire transcript.
The orchestrator
The orchestrator ties retrieval, response generation, and memory into a single agent:
rag_chatbot = Agent(
system_prompt="""You are an intelligent assistant with expertise in the Strands SDK.
WORKFLOW:
1. RETRIEVE: Use retrieval_specialist to find relevant docs and code
2. RECALL: Use recall_conversation for relevant conversation context
3. RESPOND: Use response_specialist to generate a cited answer
4. REMEMBER: Use remember_conversation to store salient facts
Always retrieve context before answering technical questions.
Prefer code examples from the actual Strands SDK codebase.""",
tools=[
retrieval_specialist,
response_specialist_tool,
remember_conversation,
recall_conversation,
use_llm
],
model=gemini_model
)
When you ask "How do I create an agent with custom tools?", the orchestrator calls retrieval_specialist to search the codebase, recall_conversation to pull relevant memories from earlier turns, passes both to response_specialist for an answer citing specific files, and finishes with remember_conversation to store the key facts for future turns. Every step is visible and independently debuggable, and when an answer is wrong or a citation is off, you can tell which step failed instead of staring at one opaque pipeline.
Bootstrapping the runtime
To run this outside a notebook, the FAISS store has to be initialized, the index loaded from disk or built on the fly, the memory database configured, and a chat loop started:
import os
from pathlib import Path
from strands import Agent, tool
from strands.models.gemini import GeminiModel
from strands_tools import use_llm
from mem0 import Memory
# 1. Initialize the LLM
GOOGLE_API_KEY = os.environ["GOOGLE_API_KEY"]
gemini_model = GeminiModel(
client_args={"api_key": GOOGLE_API_KEY},
model_id="gemini-2.5-flash-lite"
)
# 2. Boot the FAISS vector store
vector_store = FAISSVectorStore(
local_model="all-MiniLM-L6-v2",
dimension=384
)
# 3. Load or index the codebase
if Path("data/strands_sdk.faiss").exists():
vector_store.load("data/strands_sdk.faiss", "data/documents.json")
else:
# Index the SDK docs and source code on the fly
documents = load_and_chunk_documents(
repo_path="data/strands-sdk",
chunk_size=1000,
chunk_overlap=200
)
vector_store.add_documents(documents)
vector_store.save("data/strands_sdk.faiss", "data/documents.json")
# 4. Initialize in-memory conversation memory
memory = Memory.from_config({
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": "strands_chat",
"embedding_model_dims": 384,
"path": ":memory:"
}
},
"embedder": {
"provider": "huggingface",
"config": {"model": "all-MiniLM-L6-v2"}
}
})
# 5. Start the interactive chat loop
print("š¬ Strands SDK RAG Chatbot is online.")
while True:
query = input("š¤ You: ")
if query.lower() in ['exit', 'quit']:
break
response = rag_chatbot(query, user_id="user")
print(f"\nš¤ Assistant: {response}\n")
Loading a pre-built index when one exists and indexing the SDK on the fly when it does not keeps iteration fast during development.
What I would measure next
Huber's most practical recommendation is the one I have not implemented yet, golden datasets:
"People should be creating small golden data sets of what queries they want to work and what chunks should return... quantitatively evaluate what matters."
The idea is to spend an evening labeling query-chunk pairs:
[
{
"query": "How do I create an agent with custom tools?",
"expected_chunks": [
"data/strands-sdk/examples/custom_tools.py",
"data/strands-sdk/docs/agents.md"
],
"expected_concepts": ["@tool decorator", "Agent class initialization"]
}
]
and then wire Recall@10 into CI so the build fails when it drops below a threshold. Without this, retrieval quality degrades silently: you swap an embedding model, change the chunking strategy, re-index the codebase, and never notice that three important queries stopped returning the right files.
Where this leaves the RAG question
The project started because everyone was saying RAG is dead and I wanted to know what replaces it, and the answer I can defend after building one is the same engineering work, decomposed into stages that are honest about where the difficulty lives. For this codebase the difficulty lived in assembly and memory rather than retrieval, retrieval's precision turned out to set the agent's token bill, and the re-ranking pass and the golden dataset are the two gaps I would close before trusting the system at a larger scale. If you have built something similar and your expensive stage was a different one, I would be curious to hear which.


