Оценяване на RAG: намира ли и стъпва ли
Накратко: Оценяването на RAG проверява две неща: дали търсенето намира правилното парче (recall@k) и дали отговорът стъпва само на намереното.
Проблем
По-евтино и „работи“
Асистентът от предишния урок слага до 3 парчета в промпта. Колега забелязва, че парчетата са повечето от токените. Сваля k на 1 и пробва два въпроса:
for (String question : List.of("колко струва доставката до офис", "банката отказа картата ми")) System.out.println(question + "\n → " + answer(question, 1));колко струва доставката до офис → Доставката до офис на куриер струва 3 €. [доставка-1]банката отказа картата ми → Опитайте плащането с друга карта. [плащане-2]И двата отговора са верни, с източник. Колегата пуска промяната.
На следващия ден асистентът казва на клиент, че парите се връщат до 14 дни. В документите пише 5. Двата въпроса на ръка не можеха да го хванат: при тях правилното парче е първо по близост.
Идеята
Два отделни въпроса
RAG може да сгреши на две места и те се мерят поотделно:
- Намира ли? Търсенето трябва да донесе правилното парче сред първите k. Мястото му сред намерените е рангът. Делът на въпросите, при които рангът е от 1 до k, е recall@k. Това е същият recall от урока за оценката на модел: от всичко, което трябваше да намериш, колко намери.
- Стъпва ли? Отговорът трябва да е от парчетата, които моделът е видял: всеки източник да е от промпта и всяко число да е в цитираното парче.
Третото, „вярно ли е“, е проверката „трябва да има“ от урока за оценяването. Трите заедно казват къде е грешката: в търсенето, в модела или в документите.
За това трябва тестов набор: въпроси на клиенти и за всеки парчето, което отговаря. Наборът се пише на ръка, веднъж, от човек, който знае документите.
Виж го
Тестовият набор и рангът
// случай от тестовия набор: тема, въпрос, правилното парче ("" = няма) и какво трябва да имаrecord Case(String topic, String question, String expect, String has) {}// тестовият наборstatic final List<Case> CASES = List.of( new Case("цена на доставка", "колко струва доставката до офис", "доставка-1", "3 €"), new Case("срок на доставка", "кога ще пристигне пратката", "доставка-2", "два дни"), new Case("връщане", "искам да върна стоката", "връщане-1", "14 дни"), new Case("пари обратно", "кога ще ми върнете парите", "връщане-2", "5 дни"), new Case("наложен платеж", "може ли наложен платеж", "плащане-1", "наложен платеж"), new Case("отказана карта", "банката отказа картата ми", "плащане-2", "друга карта"), new Case("гаранция", "каква е гаранцията на стоката", "гаранция-1", "2 години"), new Case("неделя", "работите ли в неделя", "", "не знам"));
// честният отказ започва такаstatic final String DECLINE = "не знам";Последният въпрос няма парче: магазинът не е писал нищо за неделя. Правилният отговор е „не знам“.
Рангът е мястото на правилното парче сред намерените, а recall@k брои въпросите, при които то е сред първите k:
// мястото на правилното парче (от 1), 0 ако го нямаstatic int rank(Case c) { List<Hit> hits = search(c.question(), PIECES.size()); for (int i = 0; i < hits.size(); i++) if (hits.get(i).piece().id().equals(c.expect())) return i + 1; return 0;}
// recall@k: при колко въпроса то е сред първите kstatic int recall(int k) { int found = 0; for (Case c : CASES) { int r = rank(c); if (r >= 1 && r <= k) found++; } return found;}System.out.printf("%-18s%-6s%-5s%s%n", "тема", "ранг", "k=1", "k=3");int total = 0;for (Case c : CASES) { if (c.expect().isEmpty()) continue; total++; int r = rank(c); System.out.printf("%-18s%-6d%-5s%s%n", c.topic(), r, mark(r >= 1 && r <= 1), mark(r >= 1 && r <= 3));}for (int k : new int[] {1, 3}) System.out.println("recall@" + k + " = " + recall(k) + "/" + total);тема ранг k=1 k=3цена на доставка 1 ✓ ✓срок на доставка 3 ✗ ✓връщане 4 ✗ ✗пари обратно 2 ✗ ✓наложен платеж 2 ✗ ✓отказана карта 1 ✓ ✓гаранция 1 ✓ ✓recall@1 = 3/7recall@3 = 6/7// мястото на правилното парче (от 1), 0 ако го нямаstatic int rank(Case c) { List<Hit> hits = search(c.question(), PIECES.size()); for (int i = 0; i < hits.size(); i++) if (hits.get(i).piece().id().equals(c.expect())) return i + 1; return 0;}
// recall@k: при колко въпроса то е сред първите kstatic int recall(int k) { int found = 0; for (Case c : CASES) { int r = rank(c); if (r >= 1 && r <= k) found++; } return found;}// стъпва ли отговорът на парчетата: "" или защо неstatic String unsupported(String answer, List<Hit> hits) { if (answer.toLowerCase().startsWith(DECLINE)) return ""; List<String> ids = sources(answer); if (ids.isEmpty()) return "без източник"; Set<String> words = new HashSet<>(); for (String source : ids) { Hit hit = null; for (Hit h : hits) if (h.piece().id().equals(source)) hit = h; if (hit == null) return "източник извън промпта: " + source; String text = hit.piece().text(); words.addAll(List.of(text.split(" "))); } for (String word : answer.split(" ")) { if (word.startsWith("[")) continue; String n = word.replaceAll("[^0-9]", ""); if (!n.isEmpty() && !words.contains(n)) return "числото " + n + " го няма в източника"; } return "";}Стъпка по стъпка, с думи
- Тестовият набор: 8 въпроса на клиенти и за всеки парчето, което отговаря. Последният няма парче в документите.
- Ранг: на кое място е правилното парче сред намерените. За „връщане“ е чак 4-то: малкият модел за вектори греши.
- При k = 1 в промпта влиза само първото парче. Правилното е там при 3/7 въпроса: recall@1 = 3/7.
- При k = 3 правилното парче е вътре при 6/7: recall@3 = 6/7. Само „връщане“ остава навън.
- Стъпва ли отговорът на парчетата? Всеки източник трябва да е в промпта и всяко число в източника: 7/8.
- Два различни провала: търсенето не намери парчето (recall), а моделът написа число, което го няма в източника (стъпва).
При k = 1 правилното парче е в промпта само при 3 от 7 въпроса. При k = 3 е при 6. „връщане“ е с ранг 4: малкият модел за вектори смята „искам да върна стоката“ за по-близо до гаранцията.
Виж го
Стъпва ли отговорът
Проверката взема отговора и парчетата, които моделът е видял. Честният отказ минава. Всеки източник трябва да е сред парчетата, а всяко число в отговора трябва да е дума в цитираните парчета:
// стъпва ли отговорът на парчетата: "" или защо неstatic String unsupported(String answer, List<Hit> hits) { if (answer.toLowerCase().startsWith(DECLINE)) return ""; List<String> ids = sources(answer); if (ids.isEmpty()) return "без източник"; Set<String> words = new HashSet<>(); for (String source : ids) { Hit hit = null; for (Hit h : hits) if (h.piece().id().equals(source)) hit = h; if (hit == null) return "източник извън промпта: " + source; String text = hit.piece().text(); words.addAll(List.of(text.split(" "))); } for (String word : answer.split(" ")) { if (word.startsWith("[")) continue; String n = word.replaceAll("[^0-9]", ""); if (!n.isEmpty() && !words.contains(n)) return "числото " + n + " го няма в източника"; } return "";}Тя връща празен низ, когато отговорът стъпва, а иначе причината. Причината е по-полезна от ✗: казва къде да гледаш.
Пробвай
Предвиди изхода
При k = 1 за „кога ще ми върнете парите“ в промпта влиза само „връщане-1“: „можете да върнете стоката до 14 дни в оригиналната опаковка“. Какво печата проверката за този отговор?
String claim = "Парите се връщат до 14 дни. [връщане-1]";String why = unsupported(claim, search("кога ще ми върнете парите", 1));System.out.println(why.isEmpty() ? "стъпва" : why);„стъпва“. Източникът е в промпта, а 14 е дума в парчето. Отговорът стъпва на намереното, но е грешен: парите се връщат до 5 дни, това е в „връщане-2“, което не е намерено. Затова „стъпва“ и „вярно“ са две проверки.
Виж го
Целият набор
report пуска всеки въпрос през RAG и печата трите знака: намерено, стъпва, вярно. При k = 3:
// целият набор при k парчета: намерено, стъпва, вярноstatic void report(int k) { int found = 0, total = 0, stands = 0, right = 0; System.out.println("k = " + k); System.out.println("тема намерено стъпва вярно"); for (Case c : CASES) { List<Hit> hits = search(c.question(), k); String text = answer(c.question(), k); String why = unsupported(text, hits); String f = "—"; if (!c.expect().isEmpty()) { total++; boolean in = false; for (Hit h : hits) if (h.piece().id().equals(c.expect())) in = true; if (in) found++; f = mark(in); } if (why.isEmpty()) stands++; if (text.contains(c.has())) right++; String row = String.format("%-18s%-10s%-8s%s", c.topic(), f, mark(why.isEmpty()), mark(text.contains(c.has()))); System.out.println(why.isEmpty() ? row : row + " " + why); } System.out.println("recall@" + k + " " + found + "/" + total + ", стъпва " + stands + "/" + CASES.size() + ", вярно " + right + "/" + CASES.size());}report(3);k = 3тема намерено стъпва вярноцена на доставка ✓ ✓ ✓срок на доставка ✓ ✓ ✓връщане ✗ ✗ ✗ числото 30 го няма в източникапари обратно ✓ ✓ ✓наложен платеж ✓ ✓ ✓отказана карта ✓ ✓ ✓гаранция ✓ ✓ ✓неделя — ✓ ✓recall@3 6/7, стъпва 7/8, вярно 7/8Единственият провал е „връщане“: правилното парче не е намерено, моделът е цитирал парчето за парите и е написал 30 дни, а в него пише 5. Проверката хваща числото.
Сега същото при k = 1, промяната на колегата:
report(1);k = 1тема намерено стъпва вярноцена на доставка ✓ ✓ ✓срок на доставка ✗ ✗ ✓ източник извън промпта: доставка-2връщане ✗ ✗ ✗ източник извън промпта: връщане-2пари обратно ✗ ✓ ✗наложен платеж ✗ ✓ ✗отказана карта ✓ ✓ ✓гаранция ✓ ✓ ✓неделя — ✓ ✓recall@1 3/7, стъпва 6/8, вярно 5/8Трите числа падат. Причините са различни: при „срок на доставка“ моделът цитира парче, което не е видял (и случайно е прав), при „пари обратно“ стъпва на грешното парче, а при „наложен платеж“ честно казва „Не знам.“, защото парчето не е намерено. Двата въпроса на ръка бяха от трите, които минават.
Пробвай
Попълни
Срещу кои парчета се търси всеки източник от отговора? Какво стои на мястото на ___?
// стъпва ли отговорът на парчетата: "" или защо не
static String unsupported(String answer, List<Hit> hits) {
if (answer.toLowerCase().startsWith(DECLINE))
return "";
List<String> ids = sources(answer);
if (ids.isEmpty())
return "без източник";
Set<String> words = new HashSet<>();
for (String source : ids) {
Hit hit = null;
for (Hit h : )
if (h.piece().id().equals(source))
hit = h;
if (hit == null)
return "източник извън промпта: " + source;
String text = hit.piece().text();
words.addAll(List.of(text.split(" ")));
}
for (String word : answer.split(" ")) {
if (word.startsWith("["))
continue;
String n = word.replaceAll("[^0-9]", "");
if (!n.isEmpty() && !words.contains(n))
return "числото " + n + " го няма в източника";
}
return "";
}hits: парчетата, които са влезли в промпта. Не всички документи: източник, който съществува, но моделът не го е видял, значи отговор от паметта.
Пробвай
Намери грешката
Колега пише recall наново. Сега recall@1 е 0/7, а recall@3 е 5/7. На кой ред е грешката?
Избери реда с грешката.
r < k изпуска парчето точно на място k. При k = 1 нищо не минава, защото рангът е поне 1. Правилното условие е 1 ≤ r ≤ k.
Идеята
Кога боли
- Наборът е малък. Тук един въпрос е 14% от recall. Истинският набор е стотици въпроси, взети от истински разговори, и се допълва с всеки бъг от клиент.
- Етикетите остаряват. Правилното парче е записано по източника му. Когато документите се нарежат наново, източниците се сменят и наборът трябва да се обнови с тях.
- Стъпва не значи вярно. Отговорът „14 дни“ стъпва на грешно парче. Затова трите проверки са заедно: намерено, стъпва, вярно.
- Проверката е груба. Тя гледа само цифрите и само дали думата я има. „два дни“ с думи не се проверява, а „5“ от „5 евро“ минава и ако отговорът казва 5 дни. По-точно проверява втори модел (виж „Още“), но и той греши.
- Правилното парче може да е повече от едно. Отговорът може да е в две парчета. Тогава наборът пази всичките, а „намерено“ значи поне едно, или всички, ако трябват и двете.
- Наборът се пуска при всяка промяна. Ново k, нов праг, нов модел за вектори, ново нарязване, нов промпт: всяко сменя числата. Затова наборът е код и се пуска като тестовете, с праг, под който промяната не минава, както в урока за оценяването.
По-точно, за любопитните
Втори модел като съдия. Вместо да търсиш цифри, можеш да питаш втори модел: „Следва ли този отговор от тези парчета? Да или не.“ Хваща повече от грубата проверка, но струва още едно извикване и самият той трябва да се провери на набор с готови оценки.
Други мерки за търсенето. recall@k казва само дали парчето е вътре. MRR (средното на 1/ранг) казва и колко високо е: ранг 1 дава 1, ранг 3 дава 0,33. precision@k казва колко от k-те парчета са за въпроса, тоест колко токени отиват напразно.
Наборът в CI. Как наборът се пуска автоматично при всяка промяна и спира лоша версия преди да стигне до клиентите, е работа за ZeroToOps.
HashSet пази думите на цитираните парчета и contains проверява число за константно време. replaceAll("[^0-9]", "") оставя само цифрите от думата.Упражнение
Колко да е k
Реши в редактора си.
Напиши функция, която за дадено k пуска целия набор и печата recall, стъпва, вярно и колко токена са влезли в промптовете (токените на промпта и на въпроса, само когато моделът е извикан). Пусни я за k от 1 до 4:
k = 1: recall 3/7, стъпва 6/8, вярно 5/8, 576 токенаk = 2: recall 5/7, стъпва 6/8, вярно 7/8, 736 токенаk = 3: recall 6/7, стъпва 7/8, вярно 7/8, 808 токенаk = 4: recall 7/7, стъпва 8/8, вярно 8/8, 866 токенаКое k би избрал и защо? k = 4 оправя „връщане“, а струва около 7% повече токени от k = 3. Помисли и колко от тези числа са просто този малък набор.
Още задачи
- Праг. Пусни набора с праг 0,6 и 0,7 при k = 3. Какво става с recall и с „неделя“?
- MRR. Изчисли средното на 1/ранг за седемте въпроса с парче (ранг 0 дава 0).
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Цена и бавност: бюджет, кеш, по-малък модел · Търсене по смисъл: най-близките k · Нарязване на документи (chunking) · RAG: отговор от документите
- Продължава в: Асистентът на поддръжката
- Задачи по темата: Търсене по смисъл и RAG