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

Оценяване на RAG: намира ли и стъпва ли

Накратко: Оценяването на RAG проверява две неща: дали търсенето намира правилното парче (recall@k) и дали отговорът стъпва само на намереното.

Проблем

По-евтино и „работи“

Асистентът от предишния урок слага до 3 парчета в промпта. Колега забелязва, че парчетата са повечето от токените. Сваля k на 1 и пробва два въпроса:

foreach (var question in new[] { "колко струва доставката до офис", "банката отказа картата ми" })
Console.WriteLine(question + "\n → " + Answer(question, 1));
Изход
колко струва доставката до офис
→ Доставката до офис на куриер струва 3 €. [доставка-1]
банката отказа картата ми
→ Опитайте плащането с друга карта. [плащане-2]

И двата отговора са верни, с източник. Колегата пуска промяната.

На следващия ден асистентът казва на клиент, че парите се връщат до 14 дни. В документите пише 5. Двата въпроса на ръка не можеха да го хванат: при тях правилното парче е първо по близост.

Идеята

Два отделни въпроса

RAG може да сгреши на две места и те се мерят поотделно:

  1. Намира ли? Търсенето трябва да донесе правилното парче сред първите k. Мястото му сред намерените е рангът. Делът на въпросите, при които рангът е от 1 до k, е recall@k. Това е същият recall от урока за оценката на модел: от всичко, което трябваше да намериш, колко намери.
  2. Стъпва ли? Отговорът трябва да е от парчетата, които моделът е видял: всеки източник да е от промпта и всяко число да е в цитираното парче.

Третото, „вярно ли е“, е проверката „трябва да има“ от урока за оценяването. Трите заедно казват къде е грешката: в търсенето, в модела или в документите.

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

Виж го

Тестовият набор и рангът

// случай от тестовия набор: тема, въпрос, правилното парче ("" = няма) и какво трябва да има
record Case(string Topic, string Question, string Expect, string Has);
// тестовият набор
List<Case> cases =
[
new("цена на доставка", "колко струва доставката до офис", "доставка-1", "3 €"),
new("срок на доставка", "кога ще пристигне пратката", "доставка-2", "два дни"),
new("връщане", "искам да върна стоката", "връщане-1", "14 дни"),
new("пари обратно", "кога ще ми върнете парите", "връщане-2", "5 дни"),
new("наложен платеж", "може ли наложен платеж", "плащане-1", "наложен платеж"),
new("отказана карта", "банката отказа картата ми", "плащане-2", "друга карта"),
new("гаранция", "каква е гаранцията на стоката", "гаранция-1", "2 години"),
new("неделя", "работите ли в неделя", "", "не знам"),
];
// честният отказ започва така
const string Decline = "не знам";

Последният въпрос няма парче: магазинът не е писал нищо за неделя. Правилният отговор е „не знам“.

Рангът е мястото на правилното парче сред намерените, а recall@k брои въпросите, при които то е сред първите k:

// мястото на правилното парче (от 1), 0 ако го няма
int Rank(Case c)
{
var hits = Search(c.Question, pieces.Count);
for (int i = 0; i < hits.Count; i++)
if (hits[i].Piece.Id == c.Expect)
return i + 1;
return 0;
}
// recall@k: при колко въпроса то е сред първите k
int Recall(int k)
{
int found = 0;
foreach (var c in cases)
{
int r = Rank(c);
if (r >= 1 && r <= k)
found++;
}
return found;
}
Console.WriteLine($"{"тема",-18}{"ранг",-6}{"k=1",-5}k=3");
int total = 0;
foreach (var c in cases)
{
if (c.Expect == "")
continue;
total++;
int r = Rank(c);
Console.WriteLine($"{c.Topic,-18}{r,-6}{Mark(r >= 1 && r <= 1),-5}{Mark(r >= 1 && r <= 3)}");
}
foreach (var k in new[] { 1, 3 })
Console.WriteLine($"recall@{k} = {Recall(k)}/{total}");
Изход
тема ранг k=1 k=3
цена на доставка 1 ✓ ✓
срок на доставка 3 ✗ ✓
връщане 4 ✗ ✗
пари обратно 2 ✗ ✓
наложен платеж 2 ✗ ✓
отказана карта 1 ✓ ✓
гаранция 1 ✓ ✓
recall@1 = 3/7
recall@3 = 6/7

// мястото на правилното парче (от 1), 0 ако го няма
int Rank(Case c)
{
var hits = Search(c.Question, pieces.Count);
for (int i = 0; i < hits.Count; i++)
if (hits[i].Piece.Id == c.Expect)
return i + 1;
return 0;
}
// recall@k: при колко въпроса то е сред първите k
int Recall(int k)
{
int found = 0;
foreach (var c in cases)
{
int r = Rank(c);
if (r >= 1 && r <= k)
found++;
}
return found;
}
Стъпка по стъпка, с думи
  1. Тестовият набор: 8 въпроса на клиенти и за всеки парчето, което отговаря. Последният няма парче в документите.
  2. Ранг: на кое място е правилното парче сред намерените. За „връщане“ е чак 4-то: малкият модел за вектори греши.
  3. При k = 1 в промпта влиза само първото парче. Правилното е там при 3/7 въпроса: recall@1 = 3/7.
  4. При k = 3 правилното парче е вътре при 6/7: recall@3 = 6/7. Само „връщане“ остава навън.
  5. Стъпва ли отговорът на парчетата? Всеки източник трябва да е в промпта и всяко число в източника: 7/8.
  6. Два различни провала: търсенето не намери парчето (recall), а моделът написа число, което го няма в източника (стъпва).

При k = 1 правилното парче е в промпта само при 3 от 7 въпроса. При k = 3 е при 6. „връщане“ е с ранг 4: малкият модел за вектори смята „искам да върна стоката“ за по-близо до гаранцията.

Виж го

Стъпва ли отговорът

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

// стъпва ли отговорът на парчетата: "" или защо не
string Unsupported(string answer, List<Hit> hits)
{
if (answer.ToLower().StartsWith(Decline))
return "";
var ids = Sources(answer);
if (ids.Count == 0)
return "без източник";
var words = new HashSet<string>();
foreach (var source in ids)
{
var hit = hits.FirstOrDefault(
h => h.Piece.Id == source);
if (hit == null)
return "източник извън промпта: " + source;
words.UnionWith(hit.Piece.Text.Split(' '));
}
foreach (var word in answer.Split(' '))
{
if (word.StartsWith('['))
continue;
var n = string.Concat(word.Where(char.IsDigit));
if (n != "" && !words.Contains(n))
return $"числото {n} го няма в източника";
}
return "";
}

Тя връща празен низ, когато отговорът стъпва, а иначе причината. Причината е по-полезна от ✗: казва къде да гледаш.

Пробвай

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

При k = 1 за „кога ще ми върнете парите“ в промпта влиза само „връщане-1“: „можете да върнете стоката до 14 дни в оригиналната опаковка“. Какво печата проверката за този отговор?

var claim = "Парите се връщат до 14 дни. [връщане-1]";
var why = Unsupported(claim, Search("кога ще ми върнете парите", 1));
Console.WriteLine(why == "" ? "стъпва" : why);
Отговор

Виж го

Целият набор

report пуска всеки въпрос през RAG и печата трите знака: намерено, стъпва, вярно. При k = 3:

// целият набор при k парчета: намерено, стъпва, вярно
void Report(int k)
{
int found = 0, total = 0, stands = 0, right = 0;
Console.WriteLine($"k = {k}");
Console.WriteLine("тема намерено стъпва вярно");
foreach (var c in cases)
{
var hits = Search(c.Question, k);
var text = Answer(c.Question, k);
var why = Unsupported(text, hits);
var f = "—";
if (c.Expect != "")
{
total++;
var inTop = hits.Any(h => h.Piece.Id == c.Expect);
if (inTop)
found++;
f = Mark(inTop);
}
if (why == "")
stands++;
if (text.Contains(c.Has))
right++;
var row = $"{c.Topic,-18}{f,-10}{Mark(why == ""),-8}{Mark(text.Contains(c.Has))}";
Console.WriteLine(why == "" ? row : row + " " + why);
}
Console.WriteLine($"recall@{k} {found}/{total}, стъпва {stands}/{cases.Count}, вярно {right}/{cases.Count}");
}
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

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

Пробвай

Попълни

Срещу кои парчета се търси всеки източник от отговора? Какво стои на мястото на ___?

// стъпва ли отговорът на парчетата: "" или защо не
string Unsupported(string answer, List<Hit> hits)
{
    if (answer.ToLower().StartsWith(Decline))
        return "";
    var ids = Sources(answer);
    if (ids.Count == 0)
        return "без източник";
    var words = new HashSet<string>();
    foreach (var source in ids)
    {
        var hit = .FirstOrDefault(
            h => h.Piece.Id == source);
        if (hit == null)
            return "източник извън промпта: " + source;
        words.UnionWith(hit.Piece.Text.Split(' '));
    }
    foreach (var word in answer.Split(' '))
    {
        if (word.StartsWith('['))
            continue;
        var n = string.Concat(word.Where(char.IsDigit));
        if (n != "" && !words.Contains(n))
            return $"числото {n} го няма в източника";
    }
    return "";
}

Пробвай

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

Колега пише recall наново. Сега recall@1 е 0/7, а recall@3 е 5/7. На кой ред е грешката?

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

Идеята

Кога боли

  • Наборът е малък. Тук един въпрос е 14% от recall. Истинският набор е стотици въпроси, взети от истински разговори, и се допълва с всеки бъг от клиент.
  • Етикетите остаряват. Правилното парче е записано по източника му. Когато документите се нарежат наново, източниците се сменят и наборът трябва да се обнови с тях.
  • Стъпва не значи вярно. Отговорът „14 дни“ стъпва на грешно парче. Затова трите проверки са заедно: намерено, стъпва, вярно.
  • Проверката е груба. Тя гледа само цифрите и само дали думата я има. „два дни“ с думи не се проверява, а „5“ от „5 евро“ минава и ако отговорът казва 5 дни. По-точно проверява втори модел (виж „Още“), но и той греши.
  • Правилното парче може да е повече от едно. Отговорът може да е в две парчета. Тогава наборът пази всичките, а „намерено“ значи поне едно, или всички, ако трябват и двете.
  • Наборът се пуска при всяка промяна. Ново k, нов праг, нов модел за вектори, ново нарязване, нов промпт: всяко сменя числата. Затова наборът е код и се пуска като тестовете, с праг, под който промяната не минава, както в урока за оценяването.
По-точно, за любопитните

Втори модел като съдия. Вместо да търсиш цифри, можеш да питаш втори модел: „Следва ли този отговор от тези парчета? Да или не.“ Хваща повече от грубата проверка, но струва още едно извикване и самият той трябва да се провери на набор с готови оценки.

Други мерки за търсенето. recall@k казва само дали парчето е вътре. MRR (средното на 1/ранг) казва и колко високо е: ранг 1 дава 1, ранг 3 дава 0,33. precision@k казва колко от k-те парчета са за въпроса, тоест колко токени отиват напразно.

Наборът в CI. Как наборът се пуска автоматично при всяка промяна и спира лоша версия преди да стигне до клиентите, е работа за ZeroToOps.

HashSet<string> пази думите на цитираните парчета. string.Concat(word.Where(char.IsDigit)) оставя само цифрите от думата.

Упражнение

Колко да е 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).

Тест

Провери се

1. Правилното парче за един въпрос е трето по близост. При кои k то влиза в промпта?
2. recall@3 е 6/7. Какво значи това?
3. Отговорът цитира [доставка-2], но в промпта е само „връщане-2“. Какво казва проверката?
4. Отговорът стъпва на парчето, което е в промпта. Значи ли това, че е верен?
5. При k = 4 трите числа са пълни (7/7, 8/8, 8/8), а токените са с около 7% повече от k = 3. Какво от това следва?

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