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.
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.
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.
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.
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.
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.
É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.
Tre steder, og de ligger i rekkefølge. En feil i første ledd følger med hele veien.
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.
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.
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.
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.
Hva er fristen for å levere reiseregning?
Reiseregning må leveres innen 30 dager.
Hva er fristen for å levere reiseregning?
Innen 14 dager etter hjemkomst. Kilde: Reiseregler, avsnitt 3.1, versjon fra 2026. Jeg finner ingenting om unntak for utenlandsreiser.
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.
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.
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.
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.
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.
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%}")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".
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%}")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.
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}],
# )[
{
"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?".
"""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.
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.