POC – porozmawiaj z centrum pomocy – bazy wektorowe part 3

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.