RAG i praksis

Du har dokumenter og vil kunne spørre dem. Det finnes fire måter å gjøre det på, og den enkleste holder lenger enn du tror. Her er hva som virker, hva som går galt, og hvordan du ser forskjellen.

Magnus Gribbestad 8. oktober 2026 7 min
Figur · dokumenter til svar
Fra dokumenter til svar, med tre feilkilder Dokumenter deles i biter og lagres i en indeks. Et spørsmål finner treff i indeksen, modellen skriver et svar av treffene. Feil kan oppstå ved oppdelingen, ved søket og i svaret uten kilde. Alle tre testes hver for seg. ÉN GANG: BYGG INDEKSEN HVER GANG NOEN SPØR Dokumenter filer og sider Biter deles opp Indeks gjøres søkbare Spørsmål fra brukeren Treff finner de beste Svar skal ha kilde 1 Regel og unntak delt 2 Feil bit øverst 3 Svar uten kilde Test alle tre med spørsmål du kjenner svaret på
  1. Først bygger du en indeks: dokumentene deles i biter, og bitene gjøres søkbare. Én gang, og på nytt når dokumentene endres.
  2. Feil 1 er oppdelingen. Skjærer du midt i en regel, havner unntaket i en annen bit enn regelen.
  3. Så spør noen. Spørsmålet brukes til å finne de bitene som passer best.
  4. Feil 2 er søket. Det finner det som ligner på spørsmålet, og det er ikke alltid det som svarer på det.
  5. Modellen får bitene sammen med spørsmålet og skriver et svar.
  6. Feil 3 er svaret. Uten kilde kan ingen sjekke det, og et feil svar høres like trygt ut som et riktig.
  7. Derfor måler du alle tre hver for seg, med spørsmål du kjenner svaret på.
Kort sagt

Med RAG slår systemet opp i dokumentene dine før modellen svarer, og gir den treffene sammen med spørsmålet. Hvordan det virker står på RAG-siden. Her handler det om å få det til å virke.

De fleste RAG-prosjekter blir ikke dårlige fordi modellen er dårlig. De blir dårlige fordi dokumentene ble delt opp feil, fordi søket fant feil bit, eller fordi ingen sjekket svaret mot kilden. Alle tre er til å unngå, men da må du vite hvor du skal se.

Fire måter, fra enkel til fullverdig

Det er fristende å begynne med en vektordatabase. Det er sjelden riktig. Gå ett trinn opp om gangen, og stopp der svarene er gode nok. Tidene under er grove anslag.

1. Lim inn eller last opp i samtalen

Ett dokument, ett spørsmål. Du ser nøyaktig hva modellen fikk, og du gjør oppslaget selv. Holder så lenge materialet får plass i kontekstvinduet.

Minutter

2. Prosjekt eller kunnskapsbase i et ferdig verktøy

Du laster opp filene én gang og spør mot dem. Claude Projects bytter selv til søk når materialet blir for stort for kontekstvinduet. ChatGPT har prosjekter med filer, og Microsoft 365 Copilot kan bruke SharePoint som kilde. Prisen er at du ikke bestemmer hvordan filene deles opp.

Timer

3. Selvbygd med vektorsøk

Du deler dokumentene selv, lager embeddings og lagrer dem i en vektordatabase som Chroma, eller i Postgres med utvidelsen pgvector. Kontrollen er din, og det er ansvaret også. En prototype på en liten mappe kan gå på en ettermiddag, er mitt anslag. Å gjøre den god er en annen jobb. Et kjørbart startpunkt, uten embeddings og uten installasjon, står i boksen «For deg som bygger» nederst.

Dager

4. Fullverdig pipeline

Hybridsøk (søk på både ord og mening), omrangering av treffene, metadata som filter, tilgangsstyring per bit, logging og en fast testserie. Her bygger du et produkt, ikke et eksperiment.

Uker

Én ting kan spare deg for alt dette. Anthropic skrev i 2024 at du kan legge hele kunnskapsbasen i prompten og droppe RAG, så lenge den er under 200 000 token, omtrent 500 sider med hans regnestykke. Dagens tokenizer gir rundt 30 prosent flere token for samme tekst, så det samme tallet gir nærmere 400 sider i dag. Mellomlagring av prompten gjør det raskere og billigere. Kontekstvinduene har blitt større siden, men poenget står: lite materiale trenger ikke søk. Vet du dessuten hvilken rad eller hvilket dokument det gjelder, er et oppslag enklere enn et søk.

Der det typisk går galt

Tre steder, og de ligger i rekkefølge. En feil i første ledd følger med hele veien.

1. Dokumentene deles opp dumt

Før søket kan finne noe, må dokumentene deles i biter. Deles de midt i en regel, havner regelen i én bit og unntaket i den neste. Søket finner regelen, modellen svarer trygt, og svaret er feil. Anthropic omtaler biter på noen hundre token, og regner i sitt eget kostnadseksempel med biter på 800 token. De understreker at størrelse, grenser og overlapp må testes på dine egne dokumenter. Et startpunkt å teste fra, mitt og ikke Anthropics: 300 til 800 token per bit og 10 til 20 prosent overlapp. Del heller på overskrifter og avsnitt enn på antall tegn, ta med dokumentets tittel i hver bit, og la nabobitene overlappe litt. Sjekk også hvor lang tekst embedding-modellen tar imot. Voyage tåler mye, men den åpne norske modellen NbAiLab/nb-sbert-v2-large stopper på 512 token (modellkortet på Hugging Face, lest 8. oktober 2026), og det som er lenger enn grensen, avkortes vanligvis uten feilmelding. Da måler du en avkortet bit og tror det er modellen som er dårlig. Skannede PDF-er, tabeller og rotete HTML må ryddes før du indekserer.

2. Søket finner feil bit

Vektorsøk finner det som ligner på spørsmålet, ikke det som svarer på det. «Hva er oppsigelsestiden?» ligner mest på avsnittet som snakker mest om oppsigelsestid, også når det står i en annen avtale. Tre grep hjelper. Bruk metadata som filter, for eksempel bare siste versjon og bare riktig avtaletype. Bruk hybridsøk, som også leter etter eksakte ord som produktkoder og paragrafnumre. Og la en egen modell sortere de beste treffene på nytt før du sender de aller beste videre. Anthropic hentet for eksempel 150 treff og sendte de 20 øverste etter omrangering videre til modellen.

Anthropic har målt hva slike grep gir. De lot modellen skrive en kort beskrivelse av hvor hver bit hører hjemme i dokumentet, la den foran biten før indeksering, og la så til ordsøk og omrangering.

5,7 %
av de relevante bitene manglet blant de 20 beste treffene med vanlig vektorsøk
3,7 %
med beskrivelse av hver bit lagt foran før indeksering
2,9 %
når ordsøk (BM25) ble lagt til
1,9 %
når treffene også ble omrangert

Kilde: Anthropic, «Introducing Contextual Retrieval», 19. september 2024. Gjennomsnitt over fire typer tekst (kode, skjønnlitteratur og to typer vitenskapelige artikler), ikke virksomhetsdokumenter, så dine tall blir andre. Retningen er det som teller.

3. Svaret mangler kilde

Uten kilde kan ingen sjekke svaret, og da blir systemet enten overtrodd eller ikke brukt. Be modellen oppgi dokument og avsnitt for hver påstand, og si fra når treffene ikke svarer på spørsmålet. Claude API har støtte for dette. Du sender hver bit som en innholdsblokk av typen search_result med citations: {enabled: true}, og svaret peker tilbake til passasjen det bygger på. Et skript som gjør hele løpet, fra mappe til svar med siteringer, står i boksen nederst.

Uten kilde Svak
Du

Hva er fristen for å levere reiseregning?

Assistent

Reiseregning må leveres innen 30 dager.

Riktig? Kanskje. Du får ikke vite om det er dagens regel.
Med kilde God
Du

Hva er fristen for å levere reiseregning?

Assistent

Innen 14 dager etter hjemkomst. Kilde: Reiseregler, avsnitt 3.1, versjon fra 2026. Jeg finner ingenting om unntak for utenlandsreiser.

Du kan åpne avsnittet og sjekke. Eksemplet er konstruert.
Prompt · Svar bare fra kildene
Du svarer på spørsmål om [virksomhetens dokumenter]. Bruk bare tekstutdragene som følger med spørsmålet.

1. Hver påstand i svaret får en kilde: dokumentnavn og avsnitt.
2. Står svaret ikke i utdragene, skriv «Det finner jeg ikke i dokumentene». Ikke gjett.
3. Er utdragene uenige, eller kommer de fra ulike versjoner, si fra og oppgi datoene.
4. Svar kort og på norsk.
Regel 2 er den viktigste. Uten den fyller modellen hullene med noe som passer.

Slik ser du om det virker

De fleste tester RAG med spørsmål de kan svaret på, og blir imponert. Det sier lite. Lag heller en testserie med tre typer spørsmål: der svaret står klart i ett dokument, der det krever to dokumenter, og der svaret ikke finnes. Den siste typen avslører om systemet innrømmer at det ikke vet.

Mål to ting hver for seg. Var riktig bit blant treffene? Og var svaret riktig og i tråd med kilden? Måler du bare svaret, vet du ikke om det er søket eller teksten du skal fikse. Tretti spørsmål er et fornuftig sted å starte, men bare nok til å se store forskjeller, og skal du avgjøre noe, trenger du flere, gjerne femti eller mer. Med tretti spørsmål er ett spørsmål 3,3 prosentpoeng. Er treffprosenten 80, kan det sanne tallet ligge hvor som helst mellom 63 og 90. Går du fra 80 til 87 prosent, har to spørsmål snudd, og det kan være støy. Vil du se små forskjeller, trenger du flere spørsmål, og de samme spørsmålene i hver kjøring. Regnestykket står i boksen nederst. Kjør serien på nytt hver gang du endrer oppdeling, søk eller prompt.

Figur · hvor sikker er treffprosenten
Intervallet rundt en treffprosent krymper med flere spørsmål Hver prikk er ett testspørsmål, og prikkene fyller en stolpe fra 0 til 100 prosent. Med ti spørsmål og åtte riktige ligger den sanne treffprosenten et sted mellom 49 og 94 prosent. Med tretti spørsmål og samme treffprosent er intervallet 63 til 90, og med hundre er det 71 til 87. To systemer med tretti spørsmål hver og treffprosent 80 og 87 har intervaller som overlapper mye, så forskjellen kan være støy. 0 % 50 % 100 % 10 spørsmål, 8 riktige 49 % 94 % 30 spørsmål, 24 riktige 63 % 90 % 100 spørsmål, 80 riktige 71 % 87 % System A · 30 spørsmål · 80 % 63 % 90 % System B · 30 spørsmål · 87 % 70 % 95 % overlapper kan være støy
  1. Ti spørsmål, åtte riktige. Hver prikk er ett spørsmål, og prikkene fyller en stolpe fra 0 til 100 prosent.
  2. Treffprosenten er 80, men det sanne tallet kan ligge hvor som helst i intervallet: fra 49 til 94 prosent.
  3. Tretti spørsmål og samme treffprosent: intervallet krymper til 63 til 90.
  4. Hundre spørsmål: 71 til 87. Ti ganger så mange spørsmål gir et intervall som er omtrent en tredjedel så bredt.
  5. To systemer, tretti spørsmål hver: 80 og 87 prosent. Intervallene overlapper mye, så forskjellen kan være støy.
Prompt · Lag testspørsmål
Her er et utdrag fra et dokument:

[lim inn]

Skriv fem spørsmål en ansatt kunne funnet på å stille, der svaret står i utdraget. Skriv også ett spørsmål som ligner, men der svaret ikke finnes her. Svar som en JSON-liste der hvert element har feltene «spørsmål», «riktig_svar», «sitat» og «dokument». Bruk ikke de samme ordene som i dokumentet.
Modellen skriver spørsmålene, du sjekker fasiten. Det siste kravet hindrer at søket får det for lett, og formatet lar deg lime resultatet rett inn i en testserie.

Tre ting som ikke kan vente

Start enkelt. Se hva søket faktisk leverer, ikke bare hva modellen svarer. Det beste RAG-systemet er det som blir brukt, ikke det du planlegger å bygge.

For deg som bygger

Dette er den minste testserien jeg kan lage uten å installere noe. Bare Python 3 og standardbiblioteket. Korpuset (tolv biter fra en konstruert personalhåndbok) og de ti spørsmålene er funnet på, og spørsmålene bruker med vilje andre ord enn bitene. Lagre som rag_test.py og kjør python rag_test.py. Vises æøå feil på Windows, sett PYTHONUTF8=1 først.

Python · rag_test.py
import math
import re
from collections import Counter

# Konstruert minikorpus: tolv biter fra et fiktivt personalhåndbok-utdrag.
KORPUS = {
    "reise-1": "Reiseregning leveres innen 14 dager etter hjemkomst. Kvitteringer skannes og legges ved.",
    "reise-2": "Diett utbetales etter statens satser. Måltider som dekkes av arrangør trekkes fra.",
    "reise-3": "Fly bestilles i bestillingsverktøyet. Velg laveste rimelige billett og book i god tid.",
    "ferie-1": "Ferie avtales med nærmeste leder. Søknad om sommerferie sendes innen 1. mars.",
    "ferie-2": "Ubrukt ferie kan overføres til neste år hvis leder godkjenner det skriftlig.",
    "sykdom-1": "Egenmelding kan brukes i opptil tre dager. Du melder fra til leder samme morgen.",
    "sykdom-2": "Sykmelding fra lege kreves fra dag fire. Den leveres til personalavdelingen.",
    "it-1": "Mistet mobil eller PC meldes til IT-support umiddelbart, så enheten kan sperres.",
    "it-2": "Passord byttes hver sjette måned. Bruk passordhvelvet i stedet for å skrive dem ned.",
    "innkjop-1": "Innkjøp under 5 000 kroner kan gjøres av avdelingen. Over det kreves godkjent bestilling.",
    "hms-1": "Avvik og nesten-ulykker registreres i avviksskjemaet innen 24 timer.",
    "hms-2": "Verneombudet skal varsles før endringer i arbeidsmiljøet som berører flere ansatte.",
}

# Ti spørsmål med fasit. Formulert med andre ord enn bitene, med vilje.
SPORSMAL = [
    ("Hvor lenge har jeg på å sende inn utgiftene etter en tjenestereise?", "reise-1"),
    ("Hva får jeg i dagpenger når jeg er borte over natta?", "reise-2"),
    ("Kan jeg ta med meg feriedager til neste år?", "ferie-2"),
    ("Når må jeg søke om sommerferie?", "ferie-1"),
    ("Hvor mange dager kan jeg være borte uten legepapir?", "sykdom-1"),
    ("Hva gjør jeg hvis jeg mister telefonen?", "it-1"),
    ("Hvor ofte skal passordet mitt skiftes?", "it-2"),
    ("Hvem kan godkjenne et kjøp på 8 000 kroner?", "innkjop-1"),
    ("Hvor fort må en nesten-ulykke rapporteres?", "hms-1"),
    ("Hvem skal få beskjed før vi omorganiserer kontorlandskapet?", "hms-2"),
]

def ord_i(tekst):
    return re.findall(r"[a-zæøå0-9]+", tekst.lower())

# Søk 1: stikkord. Teller hvor mange ulike ord spørsmålet og biten deler.
def stikkord_score(sporsmal, bit):
    return len(set(ord_i(sporsmal)) & set(ord_i(bit)))

# Søk 2: enkel TF-IDF, skrevet for hånd. Sjeldne ord teller mer enn vanlige.
def bygg_tfidf(korpus):
    n = len(korpus)
    df = Counter(w for t in korpus.values() for w in set(ord_i(t)))
    idf = {w: math.log((1 + n) / (1 + d)) + 1 for w, d in df.items()}
    def vektor(tekst):
        tf = Counter(ord_i(tekst))
        return {w: c * idf.get(w, 0.0) for w, c in tf.items()}
    def cosinus(a, b):
        skalar = sum(a[w] * b.get(w, 0.0) for w in a)
        lengde = math.sqrt(sum(v * v for v in a.values())) * math.sqrt(sum(v * v for v in b.values()))
        return skalar / lengde if lengde else 0.0
    biter = {k: vektor(t) for k, t in korpus.items()}
    def score(sporsmal, bit_id):
        return cosinus(vektor(sporsmal), biter[bit_id])
    return score

# Felles form: et søk er en funksjon (spørsmål, bit-id) -> tall. Det er den
# som byttes ut når du går over til embeddings.
def rangering(score, sporsmal, korpus):
    return sorted(korpus, key=lambda b: score(sporsmal, b), reverse=True)

def recall_at_k(score, korpus, sporsmal_med_fasit, k):
    riktige = 0
    for sporsmal, fasit in sporsmal_med_fasit:
        if fasit in rangering(score, sporsmal, korpus)[:k]:
            riktige += 1
    return riktige / len(sporsmal_med_fasit)

# 95 % Wilson-intervall for en andel: hvor bredt sprer tallet seg?
def wilson(riktige, n, z=1.96):
    p = riktige / n
    midt = (p + z * z / (2 * n)) / (1 + z * z / n)
    bredde = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
    return midt - bredde, midt + bredde

if __name__ == "__main__":
    # Stikkordsøket bruker bit-id mot teksten, så pakk det inn likt.
    sok = {
        "stikkord": lambda q, b: stikkord_score(q, KORPUS[b]),
        "tfidf": bygg_tfidf(KORPUS),
    }
    print(f"{len(KORPUS)} biter, {len(SPORSMAL)} spørsmål")
    for navn, score in sok.items():
        r1 = recall_at_k(score, KORPUS, SPORSMAL, 1)
        r3 = recall_at_k(score, KORPUS, SPORSMAL, 3)
        print(f"{navn:9} recall@1 = {r1:.0%}   recall@3 = {r3:.0%}")
        bom = [f for q, f in SPORSMAL if f not in rangering(score, q, KORPUS)[:3]]
        print(f"{'':9} bommet i topp 3 på: {', '.join(bom) or 'ingen'}")

    print("\nHvor sikker er en treffprosent på 80 %?")
    for n in (10, 30, 100):
        lav, hoy = wilson(round(0.8 * n), n)
        print(f"  {n:3} spørsmål: mellom {lav:.0%} og {hoy:.0%}")
Utskrift
12 biter, 10 spørsmål
stikkord  recall@1 = 70%   recall@3 = 70%
          bommet i topp 3 på: reise-2, it-1, it-2
tfidf     recall@1 = 60%   recall@3 = 70%
          bommet i topp 3 på: reise-2, it-1, it-2

Hvor sikker er en treffprosent på 80 %?
   10 spørsmål: mellom 49% og 94%
   30 spørsmål: mellom 63% og 90%
  100 spørsmål: mellom 71% og 87%

Begge søkene finner riktig bit i topp tre på sju av ti spørsmål, og de tre som bommer er de samme: «dagpenger» mot «diett», «telefon» mot «mobil» og «skiftes» mot «byttes». Søk på ord finner ikke ord som mangler i bitene. Forskjellen mellom 60 og 70 prosent på recall@1 er ett spørsmål av ti og sier ingenting. Det er poenget med intervallene nederst i utskriften: med få spørsmål er treffprosenten usikker. For en treffprosent på 80 er et 95-prosents Wilson-intervall 49 til 94 prosent med ti spørsmål, 63 til 90 med tretti og 71 til 87 med hundre. Bruker du de samme spørsmålene i begge kjøringene, kan du i tillegg se hvilke spørsmål som snudde, og det er mer treffsikkert enn å sammenligne to prosenter.

Et ekte oppsett bytter ut søket med embeddings. Alt annet i testserien står. Funksjonen embedding_score under tar en embed-funksjon: tekster inn, vektorer ut. Her er embed en leke-versjon som bare hasher tegngrupper, så rørleggingen kan kjøres uten modell. Den er ikke en embedding-modell. voyage_embed viser stedet der en ekte modell går inn. Anthropic har ikke egen embedding-modell, og dokumentasjonen peker på Voyage AI (modellnavnet voyage-4, sjekket 8. oktober 2026). Embed dokumentene med input_type="document" og spørsmålene med "query".

Python · rag_embedding.py
import math
import zlib

from rag_test import KORPUS, SPORSMAL, recall_at_k, bygg_tfidf, ord_i

# Her kommer embeddings inn. Alt annet i testserien står uendret.
# embed er en funksjon: (liste med tekster, "document" eller "query") -> liste
# med vektorer. Embedding-modeller til søk vil ofte vite om teksten er et dokument
# eller et spørsmål, derfor to typer.
def embedding_score(embed, korpus):
    vektorer = dict(zip(korpus, embed(list(korpus.values()), "document")))
    def cosinus(a, b):
        skalar = sum(x * y for x, y in zip(a, b))
        lengde = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
        return skalar / lengde if lengde else 0.0
    return lambda sporsmal, bit_id: cosinus(embed([sporsmal], "query")[0], vektorer[bit_id])

# LEKE-EMBEDDING, bare for å prøve rørleggingen: tegngrupper på tre bokstaver
# hashes ned i 256 tall. Dette er ikke en ekte modell. Den teller tegngrupper
# og kjenner ikke igjen betydning.
def leke_embed(tekster, type, dim=256):
    ut = []
    for tekst in tekster:
        v = [0.0] * dim
        for w in ord_i(tekst):
            w = f"#{w}#"
            for i in range(len(w) - 2):
                v[zlib.crc32(w[i:i + 3].encode()) % dim] += 1.0
        ut.append(v)
    return ut

# Ekte oppsett, bytt inn leke_embed med dette (pip install -U voyageai, og
# VOYAGE_API_KEY satt). Ikke kjørt her.
def voyage_embed(tekster, type):
    import voyageai
    return voyageai.Client().embed(tekster, model="voyage-4", input_type=type).embeddings

if __name__ == "__main__":
    sok = {
        "tfidf": bygg_tfidf(KORPUS),
        "leke-embed": embedding_score(leke_embed, KORPUS),
    }
    for navn, score in sok.items():
        print(f"{navn:10} recall@1 = {recall_at_k(score, KORPUS, SPORSMAL, 1):.0%}"
              f"   recall@3 = {recall_at_k(score, KORPUS, SPORSMAL, 3):.0%}")
Utskrift, med leke-embedding
tfidf      recall@1 = 60%   recall@3 = 70%
leke-embed recall@1 = 60%   recall@3 = 90%

Leke-embeddingen når 90 prosent i topp tre fordi tegngrupper tilfeldigvis treffer bøyningsformer som «passord» og «passordet». Det forteller bare at ti spørsmål er for lite til å si noe, og ingenting om ekte embeddings. Test minst to embedding-modeller på dine egne norske spørsmål, for eksempel voyage-4 og nb-sbert-v2-large.

Først slik bygger du blokkene som gir svar med kildehenvisning. Dette er bare å sette sammen dataene. Selve kallet står som kommentar nederst, og det samlede skriptet lenger ned gjør det på ordentlig.

Python · rag_sitering.py
import json

# Bygger innholdsblokkene Claude API forventer for søkeresultater med siteringer.
# Ingen API-kall her: dette er bare å sette sammen dataene.
def som_sokeresultater(treff, sporsmal):
    blokker = [
        {
            "type": "search_result",
            "source": bit_id,
            "title": tittel,
            "content": [{"type": "text", "text": tekst}],
            "citations": {"enabled": True},
        }
        for bit_id, tittel, tekst in treff
    ]
    blokker.append({"type": "text", "text": sporsmal})
    return blokker

treff = [("reise-1", "Reiseregler, avsnitt 3.1",
          "Reiseregning leveres innen 14 dager etter hjemkomst.")]
innhold = som_sokeresultater(treff, "Hvor lenge har jeg på å sende inn reiseregning?")
print(json.dumps(innhold, ensure_ascii=False, indent=2))

# Sendes slik (krever pip install anthropic og ANTHROPIC_API_KEY):
#   import anthropic
#   client = anthropic.Anthropic()
#   svar = client.messages.create(
#       model="claude-opus-5-5",
#       max_tokens=1024,
#       messages=[{"role": "user", "content": innhold}],
#   )
Utskrift
[
  {
    "type": "search_result",
    "source": "reise-1",
    "title": "Reiseregler, avsnitt 3.1",
    "content": [
      {
        "type": "text",
        "text": "Reiseregning leveres innen 14 dager etter hjemkomst."
      }
    ],
    "citations": {
      "enabled": true
    }
  },
  {
    "type": "text",
    "text": "Hvor lenge har jeg på å sende inn reiseregning?"
  }
]

Til slutt alt samlet i én fil: del en mappe med .txt- og .md-filer i biter, embed dem med voyage-4, finn de fem beste, send dem som search_result-blokker til Claude og skriv ut svaret med kildehenvisninger. Den trenger pip install anthropic voyageai og to nøkler i miljøvariablene ANTHROPIC_API_KEY og VOYAGE_API_KEY. Lagre som rag_ekte.py og kjør python rag_ekte.py mappe/ "Hvor lenge har jeg på å levere reiseregning?".

Python · rag_ekte.py · ikke kjørt mot API-et her
"""RAG i én fil: del en mappe i biter, embed dem, søk, spør Claude og skriv ut
svaret med siteringer.

    pip install anthropic voyageai
    ANTHROPIC_API_KEY og VOYAGE_API_KEY må ligge i miljøet, aldri i koden.
    python rag_ekte.py mappe/ "Hvor lenge har jeg på å levere reiseregning?"
"""
import re
import sys
from pathlib import Path

CLAUDE = "claude-opus-5-5"   # sjekket 8. oktober 2026
EMBEDDING = "voyage-4"       # sjekket 8. oktober 2026
BIT_TEGN = 1500              # grovt 400 token norsk tekst, under 512 også for åpne modeller
OVERLAPP = 200               # tegn fra forrige bit, så en regel og unntaket ikke havner hver for seg
ANTALL_TREFF = 5
SYSTEM = ("Du svarer på spørsmål om virksomhetens dokumenter. Bruk bare tekstutdragene som følger "
          "med spørsmålet. Står svaret ikke i utdragene, skriv «Det finner jeg ikke i dokumentene». "
          "Er utdragene uenige, si fra. Svar kort, på norsk.")


def del_opp(tekst, maks=BIT_TEGN, overlapp=OVERLAPP):
    # Del på tomme linjer så avsnitt holdes hele. Et avsnitt som er for langt, deles hardt.
    deler = []
    for avsnitt in (a.strip() for a in re.split(r"\n\s*\n", tekst)):
        deler += [avsnitt[i:i + maks - overlapp - 2] for i in range(0, len(avsnitt), maks - overlapp - 2)]
    biter, bit = [], ""
    for del_ in filter(None, deler):
        if bit and len(bit) + len(del_) + 2 > maks:
            biter.append(bit)
            hale = bit[-overlapp:].split(" ", 1)[-1]   # slutten av forrige bit, uten halve ord
            bit = f"{hale}\n\n{del_}"
        else:
            bit = f"{bit}\n\n{del_}" if bit else del_
    return biter + [bit] if bit else biter


def les_mappe(mappe):
    biter = []
    for fil in sorted(Path(mappe).rglob("*")):
        if fil.suffix.lower() in {".txt", ".md"}:
            for nr, tekst in enumerate(del_opp(fil.read_text(encoding="utf-8")), 1):
                biter.append({"fil": fil.name, "nr": nr, "tekst": tekst})
    return biter


def embed(tekster, type):
    # type er "document" når du indekserer og "query" når du spør. Voyage normaliserer
    # vektorene til lengde 1, så prikkproduktet er det samme som cosinus.
    import voyageai
    klient = voyageai.Client()   # leser VOYAGE_API_KEY
    ut = []
    for i in range(0, len(tekster), 64):   # i porsjoner, så en stor mappe ikke blir ett kjempekall
        ut += klient.embed(tekster[i:i + 64], model=EMBEDDING, input_type=type).embeddings
    return ut


def sok(sporsmal, biter, vektorer, k=ANTALL_TREFF):
    q = embed([sporsmal], "query")[0]
    skar = [sum(a * b for a, b in zip(q, v)) for v in vektorer]
    beste = sorted(range(len(biter)), key=lambda i: skar[i], reverse=True)[:k]
    return [(biter[i], skar[i]) for i in beste]


def spor_claude(sporsmal, treff):
    import anthropic
    innhold = [{
        "type": "search_result",
        "source": bit["fil"],
        "title": f"{bit['fil']}, del {bit['nr']}",
        "content": [{"type": "text", "text": bit["tekst"]}],
        "citations": {"enabled": True},
    } for bit, _ in treff]
    innhold.append({"type": "text", "text": sporsmal})
    # max_tokens er romslig fordi Claude Opus 5.5 tenker som standard, og tenkingen teller med.
    return anthropic.Anthropic().messages.create(
        model=CLAUDE, max_tokens=16000, system=SYSTEM,
        messages=[{"role": "user", "content": innhold}])


def skriv_svar(svar, treff):
    tekst, kilder = "", {}   # kilder: indeks blant treffene -> tall i teksten
    for blokk in svar.content:
        if blokk.type != "text":   # tenkeblokker og annet hoppes over
            continue
        tekst += blokk.text
        for sitat in getattr(blokk, "citations", None) or []:
            if sitat.type == "search_result_location":
                nr = kilder.setdefault(sitat.search_result_index, len(kilder) + 1)
                tekst += f" [{nr}]"
    print("\nSvar:", tekst.strip() or "(tomt svar)")
    if svar.stop_reason not in ("end_turn", "stop_sequence"):
        print(f"OBS: modellen stoppet med {svar.stop_reason}, så svaret kan være avkortet eller avvist.")
    for indeks, nr in kilder.items():
        print(f"[{nr}] {treff[indeks][0]['fil']}, del {treff[indeks][0]['nr']}")


def main(mappe, sporsmal):
    biter = les_mappe(mappe)
    if not biter:
        print("Fant ingen .txt- eller .md-filer i", mappe)
        return 1
    print(f"{len(biter)} biter fra {mappe}. Embedder ...")
    vektorer = embed([f"{b['fil']}\n{b['tekst']}" for b in biter], "document")
    treff = sok(sporsmal, biter, vektorer)
    print("Søket fant:")
    for bit, skar in treff:
        print(f"  {skar:.2f}  {bit['fil']}, del {bit['nr']}")
    skriv_svar(spor_claude(sporsmal, treff), treff)
    return 0


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit("Bruk: python rag_ekte.py <mappe> \"<spørsmål>\"")
    sys.stdout.reconfigure(encoding="utf-8")
    sys.exit(main(sys.argv[1], sys.argv[2]))

Siteringene kommer tilbake på tekstblokkene i svaret. Hver har type search_result_location, cited_text, source, title og search_result_index, som er plassen til blokken blant søkeresultatene du sendte, talt fra null i den rekkefølgen de lå i forespørselen. Derfor kan skriptet slå opp riktig bit i treff. Claude siterer hele tekstblokker i content, ikke deler av dem. Vil du ha finere kildehenvisninger, kan du dele hver bit i flere blokker.

Skriptet er syntakssjekket, og delene som ikke trenger API-et er kjørt: oppdelingen, rangeringen og utskriften av siteringer. Det er gjort med leke-embeddingen fra tidligere og en falsk klient som svarer i formen dokumentasjonen viser. Utskriften under er fra den kjøringen. Svarteksten er skrevet på forhånd og er ikke fra en modell, så den viser formen og ikke hva Claude ville ha svart.

Utskrift · falsk klient og leke-embedding, to små filer
2 biter fra mappe. Embedder ...
Søket fant:
  0.41  reiseregler.md, del 1
  0.28  ferie.txt, del 1

Svar: Reiseregning leveres innen 14 dager etter hjemkomst. [1] Kvitteringer skal skannes og legges ved. [1] Jeg finner ingenting om unntak.
[1] reiseregler.md, del 1

Det skriptet ikke gjør, er det som skiller et skript fra et system: vektorene lagres ikke (hver kjøring embedder hele mappen på nytt), filer som er endret oppdages ikke, ingen får bare se det de har lov til å se, og det er ingen testserie. Alt det står på lista over under «Tre ting som ikke kan vente».

Kjørt 8. oktober 2026 med Python 3.13.16 på Windows 11: rag_test.py, rag_embedding.py (med leke-embedding) og rag_sitering.py, og utskriftene over er fra disse kjøringene. rag_ekte.py er syntakssjekket med python -m py_compile og kjørt med falsk klient og leke-embedding, som beskrevet over. Ikke kjørt: voyage_embed, embed i rag_ekte.py og selve kallet mot Claude. Test med din egen nøkkel. Blokkformatet for search_result, siteringsfeltene og modell-ID-en claude-opus-5-5 er lest i Claude-dokumentasjonen 8. oktober 2026, voyage-4 i Anthropics side om embeddings, og grensen på 512 token på modellkortet til NbAiLab/nb-sbert-v2-large.

Oppdatert oktober 2026. Kilder: Anthropic, «Introducing Contextual Retrieval» (19. september 2024, tallene over og grensen på 200 000 token). Claude hjelpesenter, RAG for projects. Claude-dokumentasjonen om søkeresultater og siteringer. Microsoft Learn, Agent Builder: add knowledge. Anthropic, embeddings (ingen egen modell, peker på Voyage AI). Produktfunksjoner endrer seg raskt, og jeg har ikke sjekket filgrenser eller priser. Tidsanslagene i listen og bitstørrelsene jeg foreslår er mine og ikke målt.