Kontynuując poprzedni wpis o bazach wektorowych warto rozbudować nieco mechanizm SimilaritySearch, aby właściwie skorzystać z tej funkcji do zbudowania Proof of Concept konkretnego rozwiązania. Idea jest prosta. Dajmy możliwość zadania pytania (zamiast wyszukiwania), następnie wyszukajmy w bazie danych (za pomocą similarity search) podobnych fraz, które pomogą zbudować kontekst dla modelu. Dzięki temu kontekstowi powinno się udać osiągnąć rozwiązanie, w którym ogólny model wspiera proces w dość zamkniętej domenie, wspierany informacjami z bazy. Zaczynamy.
Krok 1. Refactor indeksera danych
Teoretycznie można by przejść od razu do sedna i na podstawie osiągnięć z poprzedniego wpisu zbudować kontekst dla modelu. Jednak dane otrzymane z wyszukiwania nie są do końca spójne. Otrzymujemy listę kilku podobnych wektorów wraz z ich scoringiem. Może się jednak okazać, że jest to za mało dla modelu. Bardzo łatwo o sytuację, gdy otrzymamy tylko część frazy (ze względu na jej podobieństwo), ale nie otrzymamy frazy np występującej zaraz po niej, w której były istotne informacje.
Aby tego uniknąć każdy wektor powinien mieć identyfikator, dzięki któremu możemy wyciągnąć oryginalną treść lub jej fragment. W moim przypadku jestem jeszcze na dość uproszczonym momencie i dane wejściowe przygotowywałem w zasadzie ręcznie. Zamiast budować zewnętrzne źródło danych do których będę sięgał, postanowiłem na razie ograniczyć się do otagowania w metadanych wektora informacji o rozdziale z którego pochodzi dany wektor. Po wyszukaniu wektora /ów podobnych zaciągnę wszystkie wektory z danego rozdziały i to one będą stanowić kontekst. W docelowym rozwiązaniu niemal na pewno będzie to wymagało zmiany, ale na potrzeby POC powinno wystarczyć.
Przy okazji zmian usprawniam nieco kod z poprzedniego wpisu i dodaję dzielenie na rozdziały i wprowadzam tytuł rozdziału do metadanych.
Główna metoda wygląda teraz bardzo prosto:
$content = file_get_contents(__DIR__ . '/rejestracja.md');
$chapters = explode("\n### ", $content);
foreach ($chapters as $chapter) {
$this->indexChapter($chapter);
}
Metoda indexChapter zajmuje się budowaniem metadanych oraz podzieleniem rozdziału na poszczególne linie:
private function indexChapter(string $chapter): void
{
$lines = explode("\n", $chapter);
$title = $lines[0];
$metadata = [
'source' => 'help_center',
'page' => 'rejestracja',
'chapter' => $title,
];
$points = new PointsStruct();
foreach ($lines as $line) {
if ($line === "") {
continue;
}
$points->addPoint(
$this->indexLine($line, $metadata)
);
}
// insert points
$this->qdrantRepository->collectionUpsert('help_center', $points);
}
Ostatnim krokiem jest wygenerowanie embeddingu lini i wygenerowanie odpowiedniego wektora:
private function indexLine(string $line, array $metadata): PointStruct
{
$embedding = $this->client->embeddings()->create(
[
"model" => "text-embedding-ada-002",
"encoding_format" => "float",
"input" => $line,
]
)->embeddings[0]->embedding;
$metadata['line'] = $line;
$metadata['uuid'] = Uuid::uuid4()->toString();
return new PointStruct(
$metadata['uuid'],
new VectorStruct($embedding, 'help_center_vector'),
$metadata
);
}
Krok 2. Budowa kontekstu
Po przeindeksowaniu danych ponownie upewniam się, że wyszukiwanie działa prawidłowo:

Wszystko działa jak należy. Widać tutaj różnicę względem poprzedniej wersji, a jest nią parametr chapter w metadanych wektora. Wykorzystajmy ten parametr, aby załadować cały rozdział.
Do repozytorium Qdranta, które tworzę, dopisuję sobie metodę do pobierania punktów na podstawie jednego taga z metadanych
public function scroll(string $collectionName, string $vectorName, string $metadataKey, string $metadataValue): Response
{
$filter = new Filter();
$filter->addMust(
new MatchString(
$metadataKey,
$metadataValue
)
);
return $this->qdrant->collections($collectionName)->points()->scroll(
$filter,
[
'with_payload' => false,
'limit' => 1000,
]
);
}
Wobec tego wynik wyszukiwania podobieństwa, zrealizowany w poprzednim wpisie, wykorzystuję do pobrania wszystkich linii z danego rozdziału:
$chapter = $searchResponseArray['result'][0]['payload']['chapter'];
// Load all lines from chapter
$scrollResponse = $this->qdrantRepository->scroll(
'help_center',
'help_center_vector',
'chapter',
$chapter,
);
$scrollResponseArray = $scrollResponse->__toArray();
$chapterLines = array_map(
function ($item) use ($chapter) {
return $item['payload']['line'] !== $chapter ? $item['payload']['line'] : null;
},
$scrollResponseArray['result']['points']
);
dump($chapterLines);
A oto efekt działania searcha. W celu choć minimalnej optymalizacji kosztów linia z tytułem rozdziału została usunięta (null w ostatnim elemencie tablicy). Tytuły raczej nie wnoszą nic co mogło by pomóc modelowi w przygotowaniu odpowiedzi.

Krok 3. Generowanie odpowiedzi
Pozostało już tylko jedno. Mając zestaw danych wejściowych przygotujmy zapytanie do modelu GPT, aby odpowiedział na pytanie zadane przez użytkownika.
private function answerQuestion(string $question, array $context): string
{
$context = json_encode($context);
$prompt = [
// 'model' => 'gpt-3.5-turbo-1106',
'model' => 'gpt-4-1106-preview',
'messages' => [
[
'role' => 'system',
'content' => <<<EOT
Odpowiedz na pytanie użytkownika tak prawdziwie jak potrafisz używając tylko i wyłącznie kontekstu poniżej.
Jeśli nie znasz odpowiedzi, zwróć `Nie wiem`.
Context:
###
$context
###
EOT
],
[
'role' => 'user',
'content' => $question
],
],
'max_tokens' => 400,
];
$response = $this->client->chat()->create($prompt);
return $response->choices[0]->message->content;
}
Odpowiedź testowałem stosując różne modele. Braki w kontekście i niedoskonałości prompta można skutecznie zamaskować stosując mocniejszy model. Oczywiście jest to połączone z wolniejszym działaniem i wyższymi kosztami.
Tak wygląda odpowiedź wygenerowana przez GPT-3.5-turbo-1105

Tak natomiast prezentuje się GPT-4-1106-preview

Obie te odpowiedzi powinny zostać uzupełnione o link do źródła. Z powyższego przykładu widać, że na początkowym etapie projektu warto wybierać mocniejszy model, aby później w ramach prac optymalizacyjnych dopracować aplikację i móc zejść na słabszy (ale szybszy i tańszy) model 3.5.
