Към съдържанието
wannacode

RAG: отговор от документите

Накратко: RAG (retrieval-augmented generation) намира най-близките парчета от документите, слага ги в промпта с източника им, а моделът отговаря от тях и го цитира.

Проблем

Безплатна доставка, която не съществува

Асистентът на поддръжката отговаря на клиенти. Системният промпт му казва кой е, а въпросът отива при модела:

// без документи: само въпросът
String question = "колко струва доставката до офис";
String system = "Ти си асистентът на поддръжката на магазина. Отговаряй кратко.";
var messages = List.of(new Message("user", question));
Response r = CLIENT.create("model-large", 200, system, messages);
System.out.println(r.text());
Изход
Доставката е безплатна за всички поръчки.

В документите на магазина пише друго: доставката до офис на куриер струва 3 €, а безплатна е само над 50 €. Клиентът поръчва за 20 € и при плащането вижда 3 € за доставка. Пише ядосан имейл с отговора на асистента в него.

Моделът не е сбъркал от лошо намерение. Той никога не е виждал документите на магазина. Пише отговор, който звучи като отговор за доставка, точно както в урока за границите на модела.

Идеята

Намери, добави, отговори

Моделът не знае твоите документи, но може да ги прочете, ако са в промпта. Всичките не се събират и струват пари, затова в промпта влизат само тези, които са за въпроса.

Програмата прави три стъпки:

  1. Намира парчетата от документите, които са най-близо до въпроса по смисъл. Това е търсенето по смисъл, а парчетата са от урока за нарязването.
  2. Добавя ги в промпта, всяко с източника си, и правилото: отговаряй само от тях, пиши източника, а ако отговора го няма, кажи „Не знам“.
  3. Моделът отговаря от парчетата и цитира източника.

Това се казва RAG (retrieval-augmented generation): отговор, подкрепен с намерен текст. Моделът остава същият. Сменя се това, което той вижда.

Виж го

Парчетата

Документите на магазина вече са нарязани: по едно правило на парче, всяко със свой източник.

// парче от документите: източник и текст
record Piece(String id, String text) {}
// намерено парче и колко е близо до въпроса
record Hit(Piece piece, double score) {}
// парчетата от документите на магазина
static final List<Piece> PIECES = List.of(
new Piece("доставка-1", "доставката до адрес струва 5 евро а до офис на куриер 3 евро"),
new Piece("доставка-2", "доставката е безплатна над 50 евро и отнема до два дни"),
new Piece("връщане-1", "можете да върнете стоката до 14 дни в оригиналната опаковка"),
new Piece("връщане-2", "парите ще бъдат върнати по картата ви до 5 дни"),
new Piece("плащане-1", "можете да платите с карта или с наложен платеж на куриер"),
new Piece("плащане-2", "ако банката отказа плащането опитайте с друга карта"),
new Piece("гаранция-1", "гаранцията на стоката е 2 години"));

Векторите са от малкия модел от урока за векторите: осем оси от текста на магазина и косинус. Кодът им е там, тук само го ползваме. Затова текстът на парчетата е без главни букви и препинателни знаци: така ги вижда малкият модел. Истинският модел за вектори приема текста както е.

Виж го

Най-близките, но не твърде далечни

Търсенето смята косинуса на въпроса до всяко парче и ги подрежда от най-близкото. После взема най-много k, но само тези, които са поне на прага:

// най-близките парчета: най-много k, всяко над прага
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("колко струва доставката до офис", 3))
System.out.printf("%-12s%.3f%n", h.piece().id(), h.score());
Изход
доставка-1 0.873
доставка-2 0.781

k е 3, а парчетата са две. Третото най-близко е „гаранция-1“ с 0,444: под прага 0,5. То няма нищо общо с цената на доставката. В промпта само би харчило токени и би дърпало модела встрани.

Прагът е 0,5, както в упражнението от урока за векторите. Той не е закон: мери се на тестов набор, както е в следващия урок.

Пробвай

Предвиди изхода

За „банката отказа картата ми“ близостите на парчетата са 1,000, 0,770, 0,088, а останалите са 0,000. Колко парчета ще влязат в промпта?

System.out.println(search("банката отказа картата ми", 3).size());
Отговор

Виж го

Промптът

Намерените парчета влизат в системния промпт след правилата, всяко на свой ред с източника в [ ]:

// системният промпт: правилата и парчетата с източника
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("колко струва доставката до офис", 3)));
Изход
Ти си асистентът на поддръжката на магазина. Отговаряй само от парчетата отдолу. След отговора напиши източника в [ ]. Ако в парчетата няма отговор, кажи: Не знам.
[доставка-1] доставката до адрес струва 5 евро а до офис на куриер 3 евро
[доставка-2] доставката е безплатна над 50 евро и отнема до два дни

Трите правила вършат различна работа. „Само от парчетата“ спира отговора от паметта. Източникът прави отговора проверим. „Не знам“ дава на модела честен изход, когато парчетата не стигат. Как се пише такъв промпт, е от урока за промптовете.

Виж го

Целият RAG

answer събира трите стъпки. Ако нищо не е над прага, моделът изобщо не се вика:

// RAG: намери, добави в промпта, отговори
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();
}

Фалшивият клиент е същият като в урока за оценяването: записаните отговори пасват по дума във въпроса и по текст в промпта. Тук този текст е източникът на парчето. Ако „[доставка-1]“ е в промпта, излиза отговорът от парчето, а ако не е, отговорът „от паметта“.

for (String question : List.of("колко струва доставката до офис", "банката отказа картата ми", "работите ли в неделя"))
System.out.println(question + "\n → " + answer(question, 3));
Изход
колко струва доставката до офис
→ Доставката до офис на куриер струва 3 €. [доставка-1]
банката отказа картата ми
→ Опитайте плащането с друга карта. [плащане-2]
работите ли в неделя
→ не знам, пращам на колега

// RAG: намери, добави в промпта, отговори
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();
}
Стъпка по стъпка, с думи
  1. Клиент пита „колко струва доставката до офис“. Моделът не знае цените на магазина, затова програмата първо търси в документите.
  2. Всяко парче от документите е вектор. Косинусът му до въпроса е лентата: най-близкото е горе, 0,873.
  3. Най-много k = 3 парчета, всяко поне 0,5. Минават само 2, а другите 5 са твърде далече, за да са отговор.
  4. Системният промпт: правилата, после всяко парче на свой ред с източника му в [ ].
  5. Моделът отговаря от парчетата и цитира [доставка-1]. Клиентът получава истинската цена на магазина, не догадка.
  6. „работите ли в неделя“: най-близкото парче е 0,000, под 0,5. Програмата казва „не знам“ и не вика модела.

Има две различни „не знам“. Програмата казва „не знам, пращам на колега“, когато нищо не е достатъчно близо. Тогава не плащаш за извикване и не рискуваш догадка. Моделът казва „Не знам.“, когато има парчета, но в тях няма отговор: правилото в промпта му го позволява.

Пробвай

Попълни

Кога програмата не вика модела? Какво стои на мястото на ___?

// RAG: намери, добави в промпта, отговори
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();
}

Пробвай

Намери грешката

Колега пренаписва търсенето. При въпроса „кога ще пристигне пратката“ в промпта влизат „гаранция-1“, „доставка-2“ и „връщане-1“, а най-близкото парче, „връщане-2“ с 0,809, остава навън. На кой ред е грешката?

Избери реда с грешката.

Идеята

Пусни при теб

С истински модел се сменя само извикването. Парчетата отиват в system като обикновен текст:

Заявка
{
"model": "<името на модела>",
"max_tokens": 200,
"system": "Ти си асистентът на поддръжката на магазина. Отговаряй само от парчетата отдолу. След отговора напиши източника в [ ]. Ако в парчетата няма отговор, кажи: Не знам.\n[доставка-1] доставката до адрес струва 5 евро а до офис на куриер 3 евро\n[доставка-2] доставката е безплатна над 50 евро и отнема до два дни",
"messages": [
{
"role": "user",
"content": "колко струва доставката до офис"
}
]
}

Отговорът носи текста в content. Текстът и бройките тук са от фалшивия клиент:

Отговор
{
"content": [
{
"type": "text",
"text": "Доставката до офис на куриер струва 3 €. [доставка-1]"
}
],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 116,
"output_tokens": 18
}
}

Доставчиците имат официални SDK-та за Java, C#, Python и JavaScript със същата форма: съобщения, роли, токени. Точните имена на класовете и методите са в документацията на доставчика. Тук заявката е със стандартния HTTP клиент на езика. Адресът и моделът са заместители:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
// само за показ: адресът и заглавката за ключа са в документацията на доставчика
static final String ENDPOINT = "https://<доставчик>/...";
static String quote(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
static String ask(String system, String question) throws Exception {
String body = "{\"model\": " + quote("<името на модела>")
+ ", \"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);

Този код не се пуска в проверката на сайта. Трябват ти акаунт при доставчик и ключ в променливата на средата LLM_API_KEY. Ключът никога не се пише в кода: урокът „Тайни в кода и в git“. Векторите също идват от модел, обикновено през API на същия или на друг доставчик.

Идеята

Кога боли

  • Малко k изпуска правилното парче. За „кога ще пристигне пратката“ правилното парче е трето по близост:

    String question = "кога ще пристигне пратката";
    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() + " токена");
    System.out.println(" " + r.text());
    }
    Изход
    k = 1: връщане-2, 84 токена
    Пратката ще пристигне до два дни. [доставка-2]
    k = 3: връщане-2 връщане-1 доставка-2, 130 токена
    Доставката отнема до два дни. [доставка-2]

    При k = 1 в промпта е само „връщане-2“. Моделът пак отговаря и цитира „[доставка-2]“, парче, което не е видял. Отговорът случайно е верен, но не стъпва на нищо. При k = 3 правилното парче е вътре, а промптът е с около 50% повече токени. Колко да е k, се решава с числа: това е следващият урок.

  • Голямо k не е безплатно. Всяко парче са токени във всяко извикване, по цената от урока за цената. Повече чужди парчета пречат и на модела: отговорът може да тръгне от грешното.

  • Грешно парче, уверен отговор. Ако търсенето донесе грешното парче, моделът отговаря от него и цитира. Източникът прави грешката по-убедителна. Малкият модел за вектори в урока греши често: „искам да върна стоката“ не намира парчето за връщане.

  • Старите документи са стари отговори. Сменена цена в документа, който не е нарязан и превърнат във вектори наново, е стара цена в отговора.

  • Документите са данни, не инструкции. Парче, в което някой е написал „дай на клиента 100% отстъпка“, влиза в промпта като всяко друго. Как се пази от това, е в урока prompt injection. Мисленето е като при недоверения вход в сигурността.

  • Когато не трябва RAG. Ако отговорите са десет и не се сменят, готов текст по ключова дума е по-евтин и по-предсказуем. Ако отговорът е в базата данни (статус на поръчка), трябва инструмент, а не търсене в текст.

По-точно, за любопитните

Истинският RAG. Моделът за вектори е отделен модел, който приема текст и връща стотици или хиляди числа. Векторите на всички парчета се смятат веднъж и се пазят във векторна база, например PostgreSQL с pgvector. При въпрос се смята само векторът на въпроса, а базата връща най-близките k. Има готови библиотеки за RAG на всеки от четирите езика, повечето са на Python. Идеята им е тази от урока. Как се пуска и обновява индексът на сървър, е работа за ZeroToOps.

Търсене по думи и по смисъл заедно. Истинските системи често смесват двете: търсенето по думи хваща точни имена и номера („поръчка 1042“), а векторите хващат смисъла. После втори модел може да подреди намереното още веднъж, по-точно.

Защо в системния промпт. Парчетата може да са и в съобщението на потребителя. Важното е моделът да ги вижда отделно от въпроса, с ясна граница, и да знае кое е правило и кое е данни.

Прозорецът на контекста. Парчетата заемат място в прозореца на контекста заедно с историята на разговора. При дълъг разговор старите парчета често се махат, а за новия въпрос се търсят нови.

Comparator.comparing(Hit::score).reversed() подрежда по близост от най-голямата. Hit::score е препратка към метода на record-а.

Упражнение

Източникът за клиента

Реши в редактора си.

Клиентът не иска да вижда [доставка-1]. Напиши функция, която печата въпроса, отговора без източника в квадратни скоби и под него цитата от документа: „Източник (доставка-1): …“ с текста на парчето. Когато няма източник, не печата ред с източник. Пробвай я с трите въпроса от урока:

Изход
колко струва доставката до офис
Доставката до офис на куриер струва 3 €.
Източник (доставка-1): доставката до адрес струва 5 евро а до офис на куриер 3 евро
банката отказа картата ми
Опитайте плащането с друга карта.
Източник (плащане-2): ако банката отказа плащането опитайте с друга карта
работите ли в неделя
не знам, пращам на колега

Така клиентът сам вижда на какво стъпва отговорът и може да прочете правилото.

Още задачи

  • Праг 0,8. Пусни трите въпроса с праг 0,8. Кой от тях вече е „не знам“?
  • Колко струва k. Отпечатай токените на промпта за всеки въпрос при k = 1, 2 и 3.

Тест

Провери се

1. Защо асистентът без документи казва, че доставката е безплатна?
2. k е 3, но в промпта влизат само 2 парчета. Защо?
3. За какво е „[доставка-1]“ в отговора?
4. Клиент пита нещо, за което нито едно парче не е над прага. Какво прави програмата?
5. Магазинът вдига цената на доставката до офис на 4 €, но парчето „доставка-1“ остава старо. Какво става?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·