Chat med dokumentasjonen din - Implementasjon

Dette er del 3

Dette er del 3 av en artikkelserie hvor vi bygger vår egen chat bot som lar oss "snakke med" Wikipedia artikler. Del 2 er tilgjengelig her: Chat med dokumentasjonen din - Nøkkelkonsepter

Som et eksempel tar vi for oss Wikipedia som kunnskapskilde hvor vi kan spørre vilkårlige spørsmål til valgte artikler.

La oss starte med å velge oss en vektor database. Tre populære alternativer er Pinecone, Weaviate og PgVector. (Sistnevnte er en utvidelse til den populære SQL databasen Postgres).

I vår implementasjon bruker vi Weavieate. De er Open-Source og kan kjøres både lokalt og i skyen. De tilbyr også en gratis-plan.

Vi trenger også et API for å generere Embeddinger. Weaviate har innebygde moduler som lar oss gjøre dette lokalt men i denne gjennomgangen kommer vi til å bruke OpenAI sitt Embedding API.

Opprett brukere hos Weavieate og OpenAI. Det er viktig å merke seg at OpenAI sitt API koster noen kroner, men det er ikke snakk om store summer.

Vi vil ende opp med to kjørbare script: populateDatabase.js og query.js. Den førstnevnte bruker vi for å populere vektordatabasen vår med Embeddings. Nummer to lar oss stille vilkårlige spørsmål om en valgfri Wikipedia artikkel som vi har opprettet Embeddingvektorer for.

Når vi er ferdige skal vi kunne kjøre
node populateDatabase.js "The Beatles" for å opprette Embedding vektorene våre. Deretter node query.js "The Beatles" "Do you know who the drummer of The Beatles was?" for å spørre om info knyttet til denne artikkelen.

Start med å lage og åpne en ny mappe
mkdir wikichat
cd wikichat

Kjør npm init -y for å lage et tomt node-prosjekt og installer deretter nødvendige pakker:
npm install dotenv weaviate-ts-client

Når dette er på plass er vi klare til å begynne på implementasjonen.

Generere og lagre Embeddinger

La oss starte med populateDatabase.js.
Vi starter med å laste inn miljøvariabler fra en .env fil, og hente ut artikkelnavnet i en variabel.

populateDatabase.js

//Last inn miljøvariabler fra .env
import dotenv from 'dotenv'
dotenv.config()

//Hent første argument
const articleName = process.argv[2];

Vi kommer til å trenge to API-nøkler: Én for Weaviate og én for OpenAI. I tillegg trenger du URLen til Weaviate databasen din. Når du har disse klar, lag en .env fil og erstatt {your key} og {your hostname}:

.env

OPENAI_API_KEY={your key}
WEAVIATE_API_KEY={your key}
WEAVIATE_HOSTNAME={your hostname}.weaviate.network
USE_GPT4=false

Vi vil deretter hente ut paragrafene i Wikipedia artiklene. Til dette lager vi oss en ny fil, wikiService.js, med følgende innhold:

wikiService.js

const apiUrl = 'https://en.wikipedia.org/w/api.php';

// Define the parameters for the API request

// Define an async function to fetch the article content
export async function fetchArticleParagraphs(articleName) {

    const params = new URLSearchParams({
        "action": "query",
        "titles": articleName,
        "prop": 'extracts',
        "explaintext": 1,
        'format': 'json'
    });

    try {
        // Fetch the article content from the API
        const response = await fetch(`${apiUrl}?${params.toString()}`);
        const data = await response.json();

        // Get the page content from the API response
        const pages = data.query.pages;
        const pageContent = pages[Object.keys(pages)[0]].extract;

        // Split the content into an array of paragraphs
        const paragraphs = pageContent.split('\n\n');

        return paragraphs;
    } catch (error) {
        console.error(error);
    }
}

Detaljene her er ikke så viktige. Hovedpoenget er at vi nå har en funksjon, fetchArticleParagraphs som returnerer en liste med strenger for hver paragraf i den gitte artikkelen.

Vi kan nå bruke denne i populateDatabase.js scriptet vårt:

populateDatabase.js

import { fetchArticleParagraphs } from './wikiService.js';

//Hent artikkel paragrafer fra Wikipedia
const paragraphs = await fetchArticleParagraphs(articleName);

Nå som vi har en liste med paragrafer, er det på tide å konvertere dem til embedding vektorer. For å gjøre konverteringen bruker vi Open AIs embedding API. La oss lage en ny fil, openAIService.js som tar imot en streng, og returnerer dens embedding vektor:
openAIService.js

import dotenv from 'dotenv';
dotenv.config();

export async function getEmbeddingVector(textInput) {

    const embeddingResponse = await (await fetch('https://api.openai.com/v1/embeddings', {
        headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
        },
        body: JSON.stringify({
            model: 'text-embedding-ada-002',
            input: textInput
        }),
        method: 'POST'
    })).json()

    const embedding = embeddingResponse.data[0].embedding;
    return embedding;
}

Funksjonen mottar en string, før den sender det videre til OpenAIs embeddings API endepunkt. Det vi får tilbake er en vektor som representerer meningen til teksten vår.

Vi kan nå ta denne i bruk i populateDatabase.js, hvor vi henter ut en Embedding vektor for hver paragraf i artikkelen, og legger den i en liste, sammen med selve paragrafen.

populateDatabase.js


//Create embeddings
let embeddings = [];
for (const paragraph of paragraphs) {
    const embedding = await getEmbeddingVector(paragraph); 
    embeddings.push({ embedding: embedding, text: paragraph });
}

Det siste vi må gjøre er å lagre denne informasjonen i vektor-databasen vår.

Lag en ny fil med navn vectorDB.js.

vectorDB.js

import weaviate from 'weaviate-ts-client';
import crypto from 'crypto';
import dotenv from 'dotenv';
dotenv.config()

const client = weaviate.client({
    scheme: 'https',
    host: process.env.WEAVIATE_HOSTNAME,
    apiKey: new weaviate.ApiKey(process.env.WEAVIATE_API_KEY),
});

export async function upsertVector(collectionName, vector, text) {
    return await client.data.creator()
        .withClassName(collectionName)
        .withId(crypto.createHash('md5').update(text).digest("hex"))
        .withProperties({ text })
        .withVector(vector)
        .do()
        .catch((e) => { console.log('Got An error, skipping'); console.error(e) })
}

Her oppretter vi en ny klient mot weaviate, og eksponerer funksjonen upsertVector. Denne tar imot navn på en "collection", en vektor (dvs. en liste med tall) og tilhørende tekst. En "collection" eller en "class" som Weaviate kaller det, er bare en samling av vektorer som hører sammen. Man kan se på det som én tabell i en relasjonsdatabase.

Med dette på plass kan vi gjøre implementere siste del i populateDatabase.js

populateDatabase.js

import { upsertVector } from './vectorDB.js';

for(const embedding of embeddings) {
    upsertVector(articleName.split(' ').join('') + 'Wiki', embedding.embedding, embedding.text)
}

Du skal nå kunne kjøre:
node populateDatabase.js "The Beatles", og se at det nå ligger data i Weaviate databasen din.

(Filen i sin helhet er tilgjenglig på GitHub)

Stille artikkelen spørsmål

Nå trenger vi del to, query.js. Et script som lar oss spørre spørsmål til artikkelen vår.

Vi starter med å opprette filen med følgende innhold:

query.js

import dotenv from 'dotenv'
dotenv.config()

const article = process.argv[2];
const query = process.argv[3];
if(process.argv.length < 4) {
    console.log('Please provide an article and a query')
    //usage tip
    console.log('node query.js "The Beatles" "Who was the drummer of the Beatles?"')
    process.exit(1)
}

Her laster vi inn miljøvariablene våre, og henter ut artikkelen og spørsmålet til brukeren.

Vår neste jobb er å hente ut Embedding vektoren til spørsmålet. Det har vi allerede en funksjon for:

query.js

import { getEmbeddingVector } from './openAIService.js';

const embedding = await getEmbeddingVector(query);

Neste steg er å finne relevante paragrafer til dette spørsmålet. For å gjøre dette lager vi en ny funksjon i vectorDB.js fila vår:

vectorDB.js

//... previously added code 

export async function getClosestEmbeddings(collectionName, embedding, limit = 3) {
    const res = await client.graphql
        .get()
        .withClassName(collectionName)
        .withFields('text')
        .withLimit(limit)
        .withNearVector(
            {
                vector: embedding,
            })
        .do()

    return res.data.Get[collectionName] // [{text: "..."}]
}

Her bruker vi igjen Weaviate klienten vår til å hente ut de 3 vektorene/paragrafene som er nærmest spørsmåls Embeddingen vår, i en gitt collection/class. Det vil si, de paragrafene som er likest i meningsinnhold. Ettersom vi spesifierer withFields('text'), er det kun text feltet som returnes.

Vi kan da ta denne i bruk:

query.js

import { getClosestEmbeddings } from './vectorDB.js';
const articleClassName = article.split(' ').join('') + 'Wiki';
const textSections = await getClosestEmbeddings(articleClassName, embedding);

Merk at vi slår sammen artikkelnavnet til én sammenhengende streng. Dette er fordi Weaviate ikke støtter class-navn med mellomrom.

Nå er det på tide å mekke sammen spørringen vår. Her står man fritt til å skrive hva enn som passer. I denne omgangen prøver vi å følge oppskriften fra del 2:

query.js

let prompt = `You are an enthusiastic librarian that helps people discover knowledge, with helpful explanations.
Given the following paragraphs from an article, what is the best answer to the question.
If you can't find the answer in the article, please write "Sorry, I can't find anything about this in the article about ${article}".

Paragraphs from article: ${textSections.map(x=>x.text).join('\n\n')}

Question:"""
${query}
"""

Siste del er å sende dette til GPT via OpenAIs APIer. For å gjøre dette kan vi lage en ny funksjon i openAIService.js:

openAIService.js

// ... previously added code

const model = process.env.USE_GPT_4 ? 'gpt-4' : 'gpt-3.5-turbo';

export async function getChatResponse(prompt) {
    const queryResponse = await (await fetch('https://api.openai.com/v1/chat/completions', {
        headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
        },
        body: JSON.stringify({
            model,
            messages: [{ "role": "user", "content": prompt }],
        }),
        method: 'POST'
    })).json()

    const answer = queryResponse.choices[0].message.content;
    return answer;
}

Her bruker vi completions APIet til OpenAI, og sender inn strengen vi får inn som parameter. Ettersom GPT-4 fortsatt er i lukket beta bruker vi gpt-3.5-turbo som modell. Om du har tilgang på GPT-4, kan du endre USE_GPT_4 til true i .env filen vi lagde tidligere.

Vi kan nå bruke dette i query.js fila vår:

query.js

import { getChatResponse } from './openAIService.js';

const answer = await getChatResponse(prompt);
console.log(answer)

Da er vi i mål! Du skal nå kunne kjøre node query.js "The Beatles" "Do you know who the drummer of The Beatles was?" og få tilbake et svar.

All koden skrevet her er tilgjengelig i sin helhet på GitHub