Say you run support for a bank. A customer opens the chat and asks:
What are the terms of the family mortgage?
You have to answer. Instead of reaching for a framework or the "ideal" RAG diagram, let's just build it. Start with the simplest thing, and fix each part when it breaks.
You have to answer. Instead of reaching for a framework or the "ideal" RAG diagram, let's just build it. Start with the simplest thing, and fix each part when it breaks.
To answer that one question, you do two things:
That is the whole system. One half searches. The other half answers.
The system splits in two: a search half that finds the document, and an answer half that writes the reply.
Start with the answer half. It is easier.
Assume search already works and gives you the right document. Then you just build a prompt:
<System>
You are Mark from support. Answer the customer's question using only the reference below.
If the reference doesn't contain the answer, say you don't know.
</System>
<Query>
What are the terms of the family mortgage?
</Query>
<References>
{the document search returned}
</References>
Send this to an LLM. A small open model is enough. I run Qwen3-8B with vLLM. That is the whole answer baseline. Done.
Search is the interesting part.
First, one question about your users. Do they only ask general things, like rates, deposit terms, or how to close an account? Or do they also ask about their own money, like one account or a transaction that has not cleared yet?
The answer is both. This matters, because it splits the system into two search paths:
Start with the reference path.
The simplest search that works is keyword search. BM25 is the standard choice. For each document you build a sparse vector, close to TF-IDF. Rare words get more weight. Words that appear in almost every document get almost none. Before that, clean the text: remove punctuation, normalise word endings, lowercase.
This works for a while. Then it stops.
Say someone searches for beautiful non-earthly creatures. BM25 gives almost the same score to a document about ugly earthly creatures, because the words overlap. The meaning is the opposite, but keyword search cannot see that.
So you add semantic search. You encode the query with a sentence embedding model and look for the nearest documents in vector space. With E5, for example, you add a prefix (query: for the question, passage: for the documents) and compare vectors. You store the vectors in Qdrant or Chroma, or use a framework like LlamaIndex to set it up.
Now paraphrases and near-synonyms match too, and BM25's blind spot is covered.
Semantic search is only as good as the chunks you give it. Split documents by their structure, usually by paragraph, so each chunk is one full idea.
Watch the token limits. Many encoders take 512 tokens at most. Some, like BGE-M3, take up to 8k. But think about how much meaning is left when you squeeze 8k tokens into one vector. The longer the chunk, the blurrier the embedding. Pick the chunk size with this trade-off in mind, not after.
Now you have two ranked lists: one from BM25, one from the encoder. You need to merge them. Reciprocal Rank Fusion (RRF) is a simple, solid default. Each document gets a score from its rank in each list, and you add the scores.
RRF is not perfect. A chunk that shows up in only one list can land too high or too low, because there is nothing to fuse it with. It is good enough to move on. Just remember it when the results look off.
The last step is a cross-encoder. It helps to see why it differs from E5.
E5 is a bi-encoder. It encodes the query and the document on their own, into two vectors, and compares them. Anything that did not fit into a vector is lost before the comparison.
A cross-encoder takes the pair query: … document: … as one input. From the first layer it sees the query and the document together. It runs attention over both and passes them through the network. It is slower, so you do not run it on the whole corpus. You run it on the top results from RRF and reorder them. Much of the final quality comes from this step.
Bi-encoder encodes the query and document into separate vectors and compares them. The cross-encoder reads both together and scores the pair directly.
So the reference path is: BM25 + dense retrieval, merged with RRF, reranked by a cross-encoder.
Bi-encoder encodes the query and document into separate vectors and compares them. The cross-encoder reads both together and scores the pair directly.
Now the second path. This one is harder. After the first message, the system decides if the question is about the customer's own accounts, transactions, or loans. Then it reads live data.
One rule beats all the others here: a session must never see another customer's data. Before any architecture, fix the identity. Pass user_id through the agent's injected state (the InjectedState / configurable pattern in LangGraph) and scope every tool call to that id. The agent can only read data for the person it is talking to.
Get this wrong and nothing else matters.
Do you need many agents, or is one agent with a few tools enough? It depends on how different the questions are. One agent with tools like "look up account", "list recent transactions", and "check loan status" covers a lot. It is also easier to reason about and cheaper to run.
Add more structure only when one prompt cannot hold all the tasks.
When that happens, the common shape is a hierarchical multi-agent system. You build small sub-agents. Each one has its own tools and system prompt: one for transactions, one for cards, one for loans. An orchestrator reads the question and sends it to the right sub-agent. Each sub-agent stays small and easy to test. The orchestrator handles the routing.
Bi-encoder encodes the query and document into separate vectors and compares them. The cross-encoder reads both together and scores the pair directly.
An agent is only as safe as its tools, so design them with care. Do not just expose whatever the backend has. For each tool, be clear if it reads or writes. Reads are cheap and easy to undo. Writes touch real money, so they need stronger checks and usually a human in the loop before they run.
Give each agent a small role and only the tools it needs. A transactions sub-agent should not hold a "close account" tool. And watch all of them: log every routing choice, every tool call, and every answer, so you can see which agent did what when something breaks.
Do not judge quality by reading a few chats. Build an eval harness: a set of real questions with known-good answers that you run the pipeline against, so retrieval, tool choice, and answer quality all get a number. This is a big topic on its own, so I will cover it in a later article. For now, just know that without it you are guessing.
Now the two halves have to work as one system. The reference path and the personal-data path answer different questions, so something has to pick the right one.
The simplest way to join them is a router. A small classifier, or a short LLM call, reads the message and sends it to the right path: the hybrid retriever for reference questions, the agent for personal-data questions, or both when the question needs both. The router is just another part of the system, so it goes into the logs and the eval harness too.
Bi-encoder encodes the query and document into separate vectors and compares them. The cross-encoder reads both together and scores the pair directly.
Put the two paths together and the system looks like this:
None of these parts is fancy. What matters is the order you add them. Build the dumb version. Watch it fail on a real question. Then add the one piece that fixes that failure. That is how you get a retriever you actually understand.