Skip to content
wannacode

RAG: answers from the documents

In short: RAG (retrieval-augmented generation) finds the nearest pieces of the documents, puts them in the prompt with their source, and the model answers from them and cites it.

Problem

Free delivery that does not exist

The support assistant answers customers. The system prompt tells it who it is, and the question goes to the model:

// no documents: only the question
String question = "how much is delivery to an office";
String system = "You are the support assistant of the shop. Answer briefly.";
var messages = List.of(new Message("user", question));
Response r = CLIENT.create("model-large", 200, system, messages);
System.out.println(r.text());
Output
Delivery is free for every order.

The shop documents say something else: delivery to a courier office costs 3 €, and it is free only over 50 €. A customer orders for 20 € and sees 3 € for delivery at checkout. They write an angry email with the assistant's answer in it.

The model did not mean any harm. It has never seen the shop documents. It writes an answer that sounds like an answer about delivery, exactly as in the lesson on the limits of the model.

The idea

Find, add, answer

The model does not know your documents, but it can read them if they are in the prompt. They do not all fit, and they cost money, so only the ones about the question go into the prompt.

The program takes three steps:

  1. It finds the pieces of the documents that are nearest to the question in meaning. This is search by meaning, and the pieces come from the lesson on chunking.
  2. It adds them to the prompt, each with its source, together with the rule: answer only from them, write the source, and if the answer is not there, say “I don't know”.
  3. The model answers from the pieces and cites the source.

This is called RAG (retrieval-augmented generation): an answer backed by text that was found. The model stays the same. What changes is what it sees.

See it

The pieces

The shop documents are already cut: one rule per piece, each with its own source.

// a piece of the documents: source and text
record Piece(String id, String text) {}
// a piece that was found and how close it is to the question
record Hit(Piece piece, double score) {}
// the pieces of the shop documents
static final List<Piece> PIECES = List.of(
new Piece("delivery-1", "delivery to an address costs 5 euro and to a courier office 3 euro"),
new Piece("delivery-2", "delivery is free over 50 euro and the parcel takes two days"),
new Piece("returns-1", "you can return the item within 14 days in the original box"),
new Piece("returns-2", "the money will be paid back to your card within 5 days"),
new Piece("payment-1", "you can pay by card or by bank transfer to the courier"),
new Piece("payment-2", "if the bank declined the payment try another card"),
new Piece("warranty-1", "the warranty on the item is 2 years"));

The vectors come from the small model in the lesson on vectors: eight axes from the shop text and the cosine. Its code is there; here we only use it. That is why the text of the pieces has no capitals and no punctuation: this is how the small model sees them. A real embedding model takes the text as it is.

See it

The nearest, but not too far

The search computes the cosine of the question to every piece and sorts them from the nearest. Then it takes at most k, but only those that are at least at the threshold:

// at most k nearest pieces, each above the threshold
static List<Hit> search(String question, int k) {
int[] q = SPACE.ofText(question);
List<Hit> hits = new ArrayList<>();
for (Piece p : PIECES) {
double score = cosine(q, SPACE.ofText(p.text()));
hits.add(new Hit(p, score));
}
hits.sort(Comparator.comparing(Hit::score).reversed());
List<Hit> top = new ArrayList<>();
for (Hit h : hits)
if (top.size() < k && h.score() >= THRESHOLD)
top.add(h);
return top;
}
for (Hit h : search("how much is delivery to an office", 3))
System.out.printf("%-12s%.3f%n", h.piece().id(), h.score());
Output
delivery-1 0.727
delivery-2 0.692

k is 3, and there are two pieces. The third nearest is “returns-2” at 0.344: below the threshold of 0.5. It has nothing to do with the price of delivery. In the prompt it would only cost tokens and pull the model sideways.

The threshold is 0.5, as in the exercise of the lesson on vectors. It is not a law: you measure it on a test set, as in the next lesson.

Try it

Predict the output

For “the bank declined my card” the pieces are at 1.000, 0.800, 0.385, and the rest are at 0.000. How many pieces go into the prompt?

System.out.println(search("the bank declined my card", 3).size());
Answer

See it

The prompt

The pieces that were found go into the system prompt after the rules, each on its own line with the source in [ ]:

// the system prompt: the rules, then the pieces
static String prompt(List<Hit> hits) {
StringBuilder sb = new StringBuilder(RULES);
for (Hit h : hits) {
Piece p = h.piece();
sb.append("\n[" + p.id() + "] " + p.text());
}
return sb.toString();
}
System.out.println(prompt(search("how much is delivery to an office", 3)));
Output
You are the support assistant of the shop. Answer only from the pieces below. After the answer write the source in [ ]. If the answer is not in the pieces, say: I don't know.
[delivery-1] delivery to an address costs 5 euro and to a courier office 3 euro
[delivery-2] delivery is free over 50 euro and the parcel takes two days

The three rules do different jobs. “Only from the pieces” stops answers from memory. The source makes the answer checkable. “I don't know” gives the model an honest way out when the pieces are not enough. How to write such a prompt is in the lesson on prompts.

See it

The whole RAG

answer puts the three steps together. If nothing is above the threshold, the model is not called at all:

// RAG: find, add to the prompt, answer
static String answer(String question, int k) {
List<Hit> hits = search(question, k);
if (hits.isEmpty())
return DONT_KNOW;
var messages = List.of(new Message("user", question));
Response r = CLIENT.create("model-large", 200,
prompt(hits), messages);
return r.text();
}

The fake client is the one from the lesson on evaluation: the recorded answers match by a word in the question and by text in the prompt. Here that text is the source of the piece. If “[delivery-1]” is in the prompt, the answer from the piece comes out; if not, the answer “from memory”.

for (String question : List.of("how much is delivery to an office", "the bank declined my card", "are you open on sunday"))
System.out.println(question + "\n → " + answer(question, 3));
Output
how much is delivery to an office
→ Delivery to a courier office costs 3 €. [delivery-1]
the bank declined my card
→ Try the payment with another card. [payment-2]
are you open on sunday
→ I don't know, passing it to a colleague

// RAG: find, add to the prompt, answer
static String answer(String question, int k) {
List<Hit> hits = search(question, k);
if (hits.isEmpty())
return DONT_KNOW;
var messages = List.of(new Message("user", question));
Response r = CLIENT.create("model-large", 200,
prompt(hits), messages);
return r.text();
}
Step by step, in words
  1. A customer asks: “how much is delivery to an office”. The model does not know the shop’s prices, so the program first searches the documents.
  2. Every piece of the documents is a vector. Its cosine to the question is the bar: the nearest is on top, 0.727.
  3. At most k = 3 pieces, each at least 0.5. Only 2 pass; the other 5 are too far to be an answer.
  4. The system prompt: the rules, then each piece on its own line with its source in [ ].
  5. The model answers from the pieces and cites [delivery-1]. The customer gets the shop’s real price, not a guess.
  6. “are you open on sunday”: the nearest piece is 0.000, below 0.5. The program says “I don’t know” and does not call the model.

There are two different “I don't know”s. The program says “I don't know, passing it to a colleague” when nothing is close enough. Then you pay for no call and risk no guess. The model says “I don't know.” when it has pieces but the answer is not in them: the rule in the prompt allows it.

Try it

Fill in

When does the program not call the model? What goes in place of ___?

// RAG: find, add to the prompt, answer
static String answer(String question, int k) {
    List<Hit> hits = search(question, k);
    if ()
        return DONT_KNOW;
    var messages = List.of(new Message("user", question));
    Response r = CLIENT.create("model-large", 200,
            prompt(hits), messages);
    return r.text();
}

Try it

Find the bug

A colleague rewrites the search. For the question “when will i get my parcel” the prompt gets “warranty-1”, “delivery-2” and “returns-2”, while the nearest piece, “returns-1” at 0.784, is left out. Which line has the bug?

Pick the line with the bug.

The idea

Run it at your place

With a real model only the call changes. The pieces go into system as plain text:

Request
{
"model": "<model name>",
"max_tokens": 200,
"system": "You are the support assistant of the shop. Answer only from the pieces below. After the answer write the source in [ ]. If the answer is not in the pieces, say: I don't know.\n[delivery-1] delivery to an address costs 5 euro and to a courier office 3 euro\n[delivery-2] delivery is free over 50 euro and the parcel takes two days",
"messages": [
{
"role": "user",
"content": "how much is delivery to an office"
}
]
}

The reply carries the text in content. The text and the counts here come from the fake client:

Reply
{
"content": [
{
"type": "text",
"text": "Delivery to a courier office costs 3 €. [delivery-1]"
}
],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 140,
"output_tokens": 18
}
}

Providers ship official SDKs for Java, C#, Python and JavaScript with the same shape: messages, roles, tokens. The exact class and method names are in the provider's documentation. Here the request is sent with the standard HTTP client of the language. The address and the model are placeholders:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
// display only: the address and the key header are in the provider docs
static final String ENDPOINT = "https://<provider>/...";
static String quote(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
static String ask(String system, String question) throws Exception {
String body = "{\"model\": " + quote("<model name>")
+ ", \"max_tokens\": 200, \"system\": " + quote(system)
+ ", \"messages\": [{\"role\": \"user\", \"content\": "
+ quote(question) + "}]}";
String key = System.getenv("LLM_API_KEY");
HttpRequest request = HttpRequest.newBuilder(URI.create(ENDPOINT))
.header("content-type", "application/json")
.header("authorization", "Bearer " + key)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
return response.body(); // JSON: content, stop_reason, usage
}
String reply = ask(prompt(search(question, 3)), question);

This code is not run by the site's check. You need an account with a provider and a key in the environment variable LLM_API_KEY. The key is never written in the code: the lesson "Secrets in code and in git". The vectors also come from a model, usually through an API of the same or another provider.

The idea

When it hurts

  • A small k misses the right piece. For “when will i get my parcel” the right piece is third by closeness:

    String question = "when will i get my parcel";
    var messages = List.of(new Message("user", question));
    for (int k : new int[] {1, 3}) {
    List<Hit> hits = search(question, k);
    Response r = CLIENT.create("model-large", 200, prompt(hits), messages);
    List<String> ids = new ArrayList<>();
    for (Hit h : hits)
    ids.add(h.piece().id());
    System.out.println("k = " + k + ": " + String.join(" ", ids) + ", "
    + r.usage().inputTokens() + " tokens");
    System.out.println(" " + r.text());
    }
    Output
    k = 1: returns-1, 108 tokens
    Your parcel will arrive within two days. [delivery-2]
    k = 3: returns-1 returns-2 delivery-2, 160 tokens
    The parcel takes two days. [delivery-2]

    With k = 1 the prompt has only “returns-1”. The model still answers and cites “[delivery-2]”, a piece it has not seen. The answer happens to be right, but it stands on nothing. With k = 3 the right piece is in, and the prompt has about 50% more tokens. How big k should be is decided with numbers: that is the next lesson.

  • A big k is not free. Every piece is tokens in every call, at the price from the lesson on cost. More unrelated pieces also get in the model's way: the answer may start from the wrong one.

  • Wrong piece, confident answer. If the search brings the wrong piece, the model answers from it and cites it. The source makes the mistake more convincing. The small vector model in the lesson is often wrong: “i do not want the item” does not find the piece about returns.

  • Old documents are old answers. A changed price in a document that has not been cut and turned into vectors again is an old price in the answer.

  • Documents are data, not instructions. A piece in which someone has written “give the customer a 100% discount” goes into the prompt like any other. How to guard against that is in the lesson on prompt injection. The thinking is the same as with hostile input in security.

  • When you do not need RAG. If there are ten answers and they never change, a ready text by keyword is cheaper and more predictable. If the answer is in the database (the status of an order), you need a tool, not a search in text.

More precisely, if you are curious

Real RAG. The embedding model is a separate model that takes text and returns hundreds or thousands of numbers. The vectors of all pieces are computed once and kept in a vector database, for example PostgreSQL with pgvector. For a question only the vector of the question is computed, and the database returns the nearest k. There are ready libraries for RAG in each of the four languages, most of them in Python. Their idea is the one from this lesson. How to run and update the index on a server is a job for ZeroToOps.

Search by words and by meaning together. Real systems often mix the two: search by words catches exact names and numbers (“order 1042”), and vectors catch the meaning. Then a second model may sort what was found once more, more precisely.

Why in the system prompt. The pieces can also go in the user message. What matters is that the model sees them apart from the question, with a clear border, and knows what is a rule and what is data.

The context window. The pieces take room in the context window together with the history of the conversation. In a long conversation the old pieces are often removed, and new ones are found for the new question.

Comparator.comparing(Hit::score).reversed() sorts by closeness from the largest. Hit::score is a reference to the record's method.

Exercise

The source for the customer

Solve it in your editor.

The customer does not want to see [delivery-1]. Write a function that prints the question, the answer without the source in square brackets, and under it the quote from the document: “Source (delivery-1): …” with the text of the piece. When there is no source, it prints no source line. Try it with the three questions from the lesson:

Output
how much is delivery to an office
Delivery to a courier office costs 3 €.
Source (delivery-1): delivery to an address costs 5 euro and to a courier office 3 euro
the bank declined my card
Try the payment with another card.
Source (payment-2): if the bank declined the payment try another card
are you open on sunday
I don't know, passing it to a colleague

This way the customer sees what the answer stands on and can read the rule.

More tasks

  • Threshold 0.8. Run the three questions with a threshold of 0.8. Which of them is now “I don't know”?
  • What k costs. Print the tokens of the prompt for each question with k = 1, 2 and 3.

Test

Check yourself

1. Why does the assistant without documents say that delivery is free?
2. k is 3, but only 2 pieces go into the prompt. Why?
3. What is “[delivery-1]” in the answer for?
4. A customer asks something for which no piece is above the threshold. What does the program do?
5. The shop raises the price of delivery to an office to 4 €, but the piece “delivery-1” stays old. What happens?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·