Så kan du använda Pinecone och OpenAI för att ChatGPTfiera din webbplats!

Christian Brevik
Variant Sverige
Published in
9 min readMay 2, 2023
A book split into a thousand parts, glowing, magically, digital art — by DALL-E 2

Variants personalhandbok, som finns på svenska här https://handbook.variant.se och på norska här https://handbook.variant.no, är en praktisk och användbar resurs som vi på Variant hänvisar till hela tiden både internt och externt. Den berättar mycket om vilka vi är, men det finns även praktisk information som kan vara bra att ha i olika situationer i vardagen. Även om den är späckad med värdefull information, inser vi att det inte alltid är lätt att få snabba svar. Så varför inte köra ett litet experiement där vi utnyttjar kraften hos stora språkmodeller (LLM), särskilt GPT-3.5, för att svara på frågor om innehållet i vår handbok?

Här använder vi den norska handboken som exempel, där vi frågar GPT “Får vi jullov?” — där svaret är (lätt omskrivet) ”Ja, vi får jullov. Alla dagar mellan julafton och nyårsdagen räknas som helgdag”. Notera att detta gäller i Norge, i Sverige så har vi några dagars längre semester istället. Men för exemplet så spelar det mindre roll.

Vi blev inspirerade av Greg Richardsons implementering av en liknande funktion, som han gjorde för Supabase-dokumentationen. Du kan läsa mer om hur de gjorde i det här blogginlägget.

Nedan tar vi dig igenom hur vi implementerade detta för vår egen handbok med hjälp av Pinecone och API:er från OpenAI.

Översatt och anpassat av David Dinka och Jacob Berglund.

Indexering av handboken

Till att börja med så måste vi på något sätt hämta in och indexera innehållet i handboken!

Som tur är så har vi indexieringen på plats då vi redan implementerat en handbokssökmotor med Algolia sedan tidigare. Detaljerna här är mindre viktiga och kan skilja sig från hur andra löser det för deras webbplatser.

I princip så kör vår sökindexerare igenom alla .mdx-filer i vår handbok; hämtar texten, delar upp den i sektioner och retunerar det hela i JSON-format. För vår normala sökning laddas sedan alla dessa indexobjekt upp till Algolia. Men här så ville vi lagra dem i en vektordatabas istället.

Varför en vektordatabas undrar du kanske? Om du har använt ChatGPT kanske du har märkt att den har förmågan att komma ihåg vad du har sagt i samma konversation. Men det kommer inte ihåg vad du har sagt i tidigare konversationer. Detta beror på att modellen (åtminstone för närvarande) är ett blankt blad för varje ny konversation, den kommer bara ihåg vad den ursprungligen har tränats på (till synes information på det offentliga Internet fram till och med 2021). Det betyder att om du vill ha en konversation med modellen om ett smalt ämne eller domän, och få bra och uppdaterade svar, måste du ge det ett sammanhang. Och det är här vektordatabasen kommer in.

Vektordatabasen fungerar i praktiken som ett långtidsminne för LLM, som vi kan mata den med. Det gör att vi kan förse modellen med sammanhang från vår handbok, och den kommer att kunna använda detta för att ge bättre svar. En vektordatabas är i detta fall ett bättre alternativ än en traditionell databas eftersom den gör att vi snabbt kan hitta olika texter som relaterar till varandra. Varför detta är viktigt kommer att bli mer tydligt senare.

Spara indexet

För vektordatabas så valde vi Pinecone, främst för att det är en hanterad tjänst, samt att vi inte ville lägga för mycket tid på att sätta upp och underhålla en databas. Men det finns också andra alternativ tillgängliga, OpenAI-kokboken för vektordatabaser.

Vad jag vill göra i det här fallet är att spara de olika indexobjekten till Pinecone. Varje indexobjekt är ett partiellt avsnitt från vår handbok och ser ut ungefär så här:

{
"title": "En variants håndbok",
"url": "https://handbook.variant.no/#en-variants-håndbok",
"content": "Om du ikke er en variant men liker det du leser,\n ta en titt på ledige stillinger hos oss. Mer info\nom oss på nettsiden vår .",
"department": ["Trondheim", "Oslo", "Bergen", "Molde"]
}

Problemet är dock att vektorer i huvudsak bara är ”arrayer” med flyttalsnummer i dem. Så hur representerar vi ett stycke text som en vektor? För att göra det måste vi konvertera innehållet till embedding.

Embedding, i samband med maskininlärning, är ett sätt att representera komplexa data, som ord, meningar eller till och med bilder, som punkter i ett vector space . Det magiska med embeddings är att de kan arrangera och sortera ord (eller annan data) i detta vector space så att liknande ord ligger närmare varandra och olikartade ord är långt ifrån varandra. Då kan vi identifiera samband mellan ord och meningar med liknande semantisk betydelse, bara genom att jämföra avståndet mellan vektorer. Med andra ord blir det enklare att hitta meningar som har ett samband.

Lyckligtvis för oss har OpenAI API ett API för att skapa embeddings. Så genom att använda NodeJS-biblioteket för OpenAI API går det att ta content -fältet från avsnittet ovan och skapa embedding för det så här:

const content = index[0].content;
const configuration = new Configuration({
apiKey: openAIApiKey,
});
const openaiClient = new OpenAIApi(configuration);
const embeddingResponse = await openaiClient.createEmbedding({
model: "text-embedding-ada-002",
input: content,
});

const [{ embedding }] = embeddingResponse.data.data;

Detta kommer att skapa en vektor-array med flyttal, vilket är den embedding som går att spara i en vektordatabas. Eftersom embeddings skapade av text-embedding-ada-002 modellen har 1536 utdatadimensioner — så måste indexet i Pinecone skapas och specifikt ställas in för att stödja 1536 dimensioner. För Pinecone kan detta göras genom ett enkelt API-anrop:

curl --location 'https://controller.eu-west4-gcp.pinecone.io/databases' \
--header 'Api-Key: <your-api-key>' \
--header 'accept: text/plain' \
--header 'content-type: application/json' \
--data '
{
"metric": "cosine",
"pods": 1,
"replicas": 1,
"pod_type": "p2.x1",
"metadata_config": {
"indexed": ["department"]
},
"dimension": 1536,
"name": "handbook-index"
}
'

I det här fallet så har vi specificerat att department fältet bör indexeras, så att vi kan filtrera resultaten baserat på avdelning senare. Genom att göra detta ser vi också till att inga andra metadatafält kommer att indexeras, vilket är standard. Detta kommer att spara minne och göra frågorna snabbare.

Som nämnts tidigare är sektionsindexet uppdelat i flera delar. Detta hjälper också till när vi ställer frågorna, då det blir snabbare och mer exakt när vektorerna är mindre. Så istället för att spara hela avsnittet som vektor i Pinecone, sparar vi istället flera små delar. Men varje liten del lagras med hela innehållet i hela avsnittet som metadata, vilket gör att vi kan hämta hela innehållet om frågorna träffar någon del av avsnittet.

Så för att spara varje del så tar vi embeddings som vi skapade ovan med hjälp av OpenAI API och sparar det tillsammans med metadata till Pinecone med hjälp av deras NodeJS-library:

const upsertRequest = {
vectors: [
{
id: inputChecksum,
values: embedding, // the embedding from earlier
metadata: {
title,
content, // the section content the embedding is created from
fullContent, // the full content of the entire section
url,
department,
},
},
],
namespace: "handbook-namespace",
};

await pineconeIndex.upsert({ upsertRequest });

Så, för att sammanfatta vad vi gjort fram tills nu:

  1. Vi har indexerat hela handboken genom att dela upp den i flera avsnitt och varje avsnitt i flera delar
  2. Sedan skapade vi embeddings (vektorer) för varje sektionsdel genom OpenAI API
  3. Och slutligen sparade vi dessa embeddings och metadata från avsnittet i Pinecone vektordatabasen

Nu har vi ett vektordatabasindex som kan efterfrågas för relevanta avsnitt i handboken utifrån frågor som ställs. Kom ihåg att detta kommer att fungera som långtidsminnet för LLM. Så nu kommer vi att hämta de relevanta avsnitten från handboken utifrån en fråga som ställs. Därefter är nästa steg att kunna be GPT-3.5 om svar med handboken som sammanhang.

Hämta relevanta avsnitt

Eftersom input för att ställa frågor om handboken är öppen för alla måste vi vara extra noga med att vi ställer frågor till GPT-3.5 som följer Open AI:s användningspolicy. För att säkerställa efterlevnad kan vi använda deras kostnadsfria moderation endpoint, som verifierar om en fråga överensstämmer med deras riktlinjer.

Så när användaren ställer en fråga kontrollerar vi först om den följer OpenAI:s användningspolicy:

const moderationResponse = await openai.createModeration({ input: question });
const [results] = moderationResponse.data.results;
if (results.flagged) {
throw new Error("Doesn't comply with OpenAI usage policy");
}

Om det går igenom är nästa steg att fråga vektordatabasen efter relevanta avsnitt i handboken. Frågan måste dock först omvandlas till en embedding.

Om du minns från tidigare är en embedding ett sätt att representera en text i ett flerdimensionellt utrymme. Så tanken här är att konvertera frågan till en vektor, som vi sedan kan fråga Pinecone-databasen om. Detta gör att vi kan hitta relaterade avsnitt i handboken, bara genom att jämföra avståndet mellan sektionsvektorerna och frågevektorn.

Så för att uppnå detta skapar vi en embedding för frågan, som vi gjorde för handboksavsnitten:

const embeddingResponse = await openai.createEmbedding({
model: "text-embedding-ada-002",
input: question,
});

const [{ embedding }] = embeddingResponse.data.data;

Med frågan konverterad kan vi nu fråga vektordatabasen efter relevanta och relaterade handboksavsnitt:

const queryRequest: QueryRequest = {
vector: embedding, // the query embedding
topK: 5,
includeValues: false,
includeMetadata: true,
namespace: "handbook-namespace",
};

const queryResponse = await index.query({ queryRequest });

const uniqueFullContents = queryResponse.matches
.map((m) => m.metadata)
.map((m) => m.fullContent)
.reduce(reduceToUniqueValues, []);

Frågan ovan kommer att returnera de 5 mest relevanta avsnitten i handboken, baserat på frågan. Med andra ord, de vektorer som var närmast vår frågevektor i det flerdimensionella rummet.

Och om du kommer ihåg så lagrar vi också hela innehållet i avsnitten i metadata. Vi ser till att filtrera bort dubblettavsnitt. Detta är viktigt, eftersom sektionerna är uppdelade i flera delar, och vi vill inte uppmana GPT-3.5 med samma sektion flera gånger om vi får flera matchande resultat från samma sektion.

Varför LLM?

Nu kanske du frågar dig själv, varför vill vi be GPT-3.5 om ett svar när vi redan har dragit ut de relevanta avsnitten ur databasen? Detta beror på att LLM har förmågan att sammanfatta de relevanta avsnitten och svara kortfattat med avseende på din fråga. Alternativet här kan vara att skriva ut allt avsnittsinnehåll och låta dig läsa igenom för att själv hitta svaret, men det låter inte särskilt tillfredsställande.

Nästa steg är då att ge LLM tillräckligt med relevant sammanhang för att svara på frågan från en prompt.

Prompten

Mycket kan sägas om hur man konstruerar en bra prompt för GPT-3.5, men vi ska hålla det kort här. Uppmaningen konstrueras genom att frågan kombineras med relevanta avsnitt från handboken. Uppmaningen skickas sedan till GPT-3.5 för slutförande. Uppmaningen är konstruerad så här:

const prompt = `
You are a very enthusiastic Variant representative who
loves to help people! Given the following sections from
the Variant handbook, answer the question using only that
information. If you are unsure and the answer is not
written in the handbook, say "Sorry, I don't know how to
help with that." Please do not write URLs that you cannot
find in the context section.

Context section:
${uniqueFullContents.join("\n---\n")}

Question: """
${question}
"""
`;

Som du ser så, förutom att ge den relevanta avsnitt från handboken, ställer vi också upp en tone-of-voice och några förutsättningar för hur man ska svara på frågan (och när ska man inte försöka svara, för den delen).

Nu, äntligen, är vi redo att be GPT-3.5 om ett svar. Vi gör detta genom att skicka uppmaningen till completion endpoint för slutförandet:

const completionOptions: CreateCompletionRequest = {
model: 'text-davinci-003',
prompt,
max_tokens: 512,
temperature: 0,
stream: false,
};
const res = await openai.createCompletion(completionOptions);
const { choices } = res.data;

const answer = choices[0].text
console.log(answer); // or display it in the UI of your choice

Äntligen har vi ett svar! Tänk på att de GPT-3.5-genererade svaren är icke-deterministiska, vilket innebär att de kan variera något varje gång. GPT-3.5 är dock skicklig på att generera korrekta svar när de ges tillräckligt med sammanhang för att göra det. Att ställa bra frågor är alltså bara halva striden — korrekt förbearbetning och indexering av innehållet i förväg är lika viktigt.

Så för att sammanfatta vad vi gör när användaren ställer en fråga:

  1. Inspektera frågan för flaggat innehåll.
  2. Skapa en embedding med hjälp av frågetexten.
  3. Fråga vektordatabasen för relevant handboksinnehåll.
  4. Skapa en prompt på naturligt språk som innehåller frågan och relevant innehåll, vilket ger tillräckligt sammanhang för GPT-3.5.
  5. Skicka in uppmaningen till GPT-3.5 för att få svar.

Och det är själva grunderna för hur vi byggde en integration mot en LLM i vår handbok, baserad på Pinecone och API:erna från OpenAI.

Vi har utelämnat många detaljer för att läsningen inte ska bli för lång. Men den fullständiga implementeringen med alla detaljer kan ses i Open Source-repository för vår handbok. De mest relevanta filerna är troligen generate-embeddings.mjs för detaljer om hur vi gör indexering och infogning till Pinecone. Och openai-data.ts för detaljer om hur vi hanterar användarfrågor.

--

--