Artikel · lokal dokumentsökning · mätning

Sök i dina egna dokument lokalt – bygg ett facit med tio frågor innan du litar på svaret

En lokal sökning i egna dokument går att bygga på en Ollama-endpoint. Det som saknas är sättet att se om rätt stycke ens hämtades. Tio frågor med känt svar ger dig en siffra att räkna om efter varje ändring.

Av C. Leijon · Publicerad · Senast granskad · 8 min läsning

Tunna gröna trådar löper från en hylla med grå dokumentmappar in i en svart låda med spjälad front, där en ljus vit linje lyser bland de gröna.
Illustration: många stycken dras in, ett är det rätta — facitet är det som gör skillnaden synlig.

Du kopplar en lokal modell till dina egna dokument, ställer en fråga och får ett svar som låter rimligt. Men du ser inte vilket stycke som hämtades, och därför vet du inte om svaret bygger på rätt underlag eller på ett stycke som råkade ligga nära i vektorrummet. Utan facit blir varje ändring av styckestorlek, prefix eller dimension en gissning. Den här texten bygger en minimal sökning på Ollamas endpoint /api/embed och lägger tio frågor med känt rätt svar bredvid den.

Två fel som ser likadana ut

Ett dåligt svar kan bero på att fel stycke hämtades eller på att modellen formulerade svaret dåligt ur ett rätt stycke. Ett facit på hämtningssidan skiljer dem åt. Genereringssidan mäts separat i vår genomgång av strukturerad JSON från en lokal modell.

Facitet är tio frågor där du redan vet svaret

Ett facit behöver inte vara stort. Tio frågor räcker för att en förändring ska synas, och de ska vara skrivna som en kollega skulle ställa dem — inte som en omskrivning av rubriken de pekar på. För varje fråga antecknar du vilket stycke i vilket dokument som innehåller svaret. Det är hela facitet.

Testmaterialet i den här artikeln är påhittat: fem korta Markdown-dokument för ett uppdiktat bolag, med uppfunna belopp och tidsfrister. Använd inte skarpa dokument när du bygger facitet. Du kommer att klistra in stycken i felsökningsutskrifter, spara vektorer i en JSON-fil och jämföra rader — och det behöver kunna göras utan att någon behöver tänka på vad filen innehåller. Den principiella indelningen av material ligger i integritetsguiden.

# skapa-testmaterial.py — skriver fem påhittade dokument och facit.json
DOK = {
"resepolicy.md": """# Resepolicy Fiktiva Bruket AB

## Bilersättning
Egen bil ersätts med 32 kronor per mil. Parkering ersätts mot kvitto.

## Boende
Hotell bokas till högst 1450 kronor per natt exklusive frukost.
""",
}

FACIT = [
  ["Hur mycket får jag för att köra egen bil i tjänsten?", "resepolicy.md", "Bilersättning"],
  ["Vad är taket för en hotellnatt?", "resepolicy.md", "Boende"],
]

Två av frågorna i det fulla facitet är medvetet sneda mot texten: »Hur snabbt måste ett dataintrång rapporteras?« mot ett stycke som talar om misstänkt incident, och »Hur ofta räknas lagret?« mot ett stycke om fysisk inventering. Ett facit där varje fråga återanvänder styckets egna ord mäter ingenting.

Indexera med ett anrop

Referensen för /api/embed anger att model och input krävs och att input tar antingen en sträng eller en lista av strängar. Hela indexeringen blir därför ett anrop. Svaret innehåller embeddings, total_duration, load_duration och prompt_eval_count, där tiderna anges i nanosekunder.

Sätt truncate till false när du indexerar. Standardvärdet är true, vilket tyst kapar indata som överstiger kontextfönstret; med false får du ett fel i stället. Ett fel du ser är bättre än ett halvt stycke du inte ser. Modellens kontextfönster är samma sorts gräns som beskrivs i artikeln om kontextlängd och minne, fast för indexeringssidan.

# indexera.py — ett stycke per H2-rubrik, ett anrop till /api/embed
def embed(texter):
    data = json.dumps({"model": MODELL, "input": texter}).encode("utf-8")
    req = urllib.request.Request(URL, data=data,
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=300) as r:
        return json.loads(r.read())

bitar = []
for fil in sorted(pathlib.Path("dokument").glob("*.md")):
    for rubrik, brodtext in stycken(fil.read_text(encoding="utf-8")):
        bitar.append({"fil": fil.name, "rubrik": rubrik,
                      "text": f"{rubrik}\n{brodtext}"})

svar = embed([b["text"] for b in bitar])
for bit, vektor in zip(bitar, svar["embeddings"]):
    bit["vektor"] = vektor

Rubriken följer med in i varje bit. Ett stycke som bara säger »minst 16 tecken« utan sin rubrik tappar hälften av det som gör det sökbart. Ingen extern databas behövs för fem dokument: en JSON-fil med vektorerna och en cosinuslikhet i ren Python räcker gott, och den enkelheten är själva poängen medan du fortfarande mäter.

Räkna tre tal, inte ett

Mätskriptet bäddar in de tio frågorna, rankar alla bitar efter cosinuslikhet och letar upp facitstyckets plats i listan. Definiera måtten innan du kör: träff@1 är antalet frågor där facitstycket hamnade först, träff@3 antalet där det låg bland de tre översta, och MRR medelvärdet av ett delat med facitstyckets plats. Träff@3 är det tal som betyder något om du skickar tre stycken till modellen; MRR fångar rörelse som de andra två missar.

# mat.py — samma facit varje gång, tre tal ut
fragevektorer = embed([f["fraga"] for f in FACIT])
for fasit, qv in zip(FACIT, fragevektorer):
    rankad = sorted(INDEX["bitar"],
                    key=lambda b: likhet(qv, b["vektor"]), reverse=True)
    platser = [j for j, b in enumerate(rankad, 1)
               if b["fil"] == fasit["fil"] and b["rubrik"] == fasit["rubrik"]]
    plats = platser[0] if platser else 0
    traff1 += plats == 1
    traff3 += 1 <= plats <= 3
    mrr += 1 / plats if plats else 0

Skriv ut facitstyckets plats per fråga, inte bara totalen. Det är radlistan som talar om varför siffran rörde sig, och den kostar tre extra tecken i utskriften.

Fyra körningar på samma tio frågor

Mätningarna nedan gjordes 8 september 2026 på Windows 11 med Ollama 0.33.3 och modelltaggen nomic-embed-text (137M parametrar, 768 dimensioner, 2K kontextfönster enligt ollama show). Siffrorna gäller det påhittade materialet i den här artikeln och ingenting annat. De är inte en bedömning av modellen och de går inte att flytta till ditt material.

UppsättningBitarDim.träff@1träff@3MRR
Ett stycke per rubrik, utan uppgiftsprefix157687/108/100,78
Ett stycke per rubrik, med uppgiftsprefix157688/109/100,86
Hela filen som en bit, med uppgiftsprefix57687/109/100,80
Ett stycke per rubrik, med prefix, dimensions: 256152565/107/100,64

Den andra raden kostar varken indexstorlek eller körtid. Nomics modellkort för nomic-embed-text-v1.5 anger att texten måste innehålla ett uppgiftsprefix och dokumenterar fyra: search_document, search_query, clustering och classification. Ollamas modellsida nämner dem inte, och en sökning som utelämnar dem ser ut att fungera. Skillnaden är två strängar:

# indexera.py — utan uppgiftsprefix
        bitar.append({"fil": fil.name, "rubrik": rubrik,
                      "text": f"{rubrik}\n{brodtext}"})
# indexera.py — med uppgiftsprefix
        bitar.append({"fil": fil.name, "rubrik": rubrik,
                      "text": f"search_document: {rubrik}\n{brodtext}"})

Frågesidan får search_query: på samma sätt. På det här materialet flyttade det träff@1 från 7/10 till 8/10 och MRR från 0,78 till 0,86. Modellkortet kräver prefixet på den text som bäddas in, så båda sidorna behöver märkas – dokumenten vid indexeringen och frågorna vid sökningen.

Tre fällor som siffran gör synliga

Magnetbiten. I körningen utan prefix rankades ett och samma stycke — det om läkarintyg — högst för fyra olika frågor, varav tre inte hade med sjukfrånvaro att göra. En bit som vinner för allt är ett fynd i sig: den är antingen för lång, för generisk eller skriven i ett tonläge som liknar frågor. Totalen 7/10 hade dolt det. Radlistan gjorde det uppenbart på en sekund.

Ordklyftan. Frågan »Hur ofta räknas lagret?« hamnade inte först i någon av de fyra körningarna. Facitstycket säger »fysisk inventering görs fyra gånger per år« och delar inget innehållsord med frågan. Ingen justering av styckestorlek löser det. Antingen skriver du in det ord användarna faktiskt använder i dokumentet, eller så kompletterar du vektorsökningen med en ordbaserad sökning — men först när du kan visa vilka frågor som behöver det.

Olika antal kandidater. Raden med hela filer ser nästan lika bra ut som den bästa styckesraden, men den valde mellan fem kandidater i stället för femton. Uppgiften var lättare, så talen är inte direkt jämförbara. Dessutom betyder en träff där att ett helt dokument hämtades: modellen får hela resepolicyn och ska själv hitta hotellbeloppet. Byter du styckestorlek läser du inte av träff@1 som om det vore samma prov.

Dimensionen är en avvägning, inte en inställning

Referensen för /api/embed tar en valfri parameter dimensions. Modellkortet beskriver en Matryoshka-representation som är avsedd att göra vektorerna kortare med liten förlust, och redovisar mätvärden per dimensionssteg på ett publikt underlag. På det här materialet kostade steget till 256 dimensioner tre frågor på träff@1. Det säger inget om hur Ollama implementerar parametern, och inget om vad du får på ditt material — men det visar att ändringen är värd en omräkning innan indexet krymps. Samma logik gäller keep_alive, som håller modellen laddad mellan indexeringskörningar: en knapp med en kostnad, mätbar med samma facit.

Vad siffran inte är

Träff@1 mäter hämtning. Den säger ingenting om huruvida modellen sedan skriver ett korrekt svar ur det hämtade stycket, ingenting om hur sökningen beter sig på tusen dokument i stället för fem, och ingenting om andra frågor än de tio i ditt facit. Den är ett spårningsverktyg: samma tio frågor, samma poängkriterium, en ny siffra efter varje ändring. Byt du facitet är serien bruten och du börjar om.

Spara facitet, indexskriptet och de tre talen tillsammans med modelltagg och Ollama-version. Nästa gång du byter modell — vare sig det är en tagg från biblioteket eller en egen GGUF-import — kör du om samma prov och ser på tio minuter om bytet var en förbättring eller bara en förändring. Valet av driftform för sökningen ligger kvar i verktygsguiden.

Källor och tekniskt underlag

Källorna kontrollerades 8 september 2026. Alla tal i tabellen är egna körningar på artikelns påhittade testmaterial, angivna som antal av tio frågor och inte som procent. De är inte ett omdöme om någon embeddingmodells träffsäkerhet, inte jämförbara mellan raderna med olika antal kandidater och inte överförbara till andra dokumentsamlingar. Testmaterialet, facitet och skripten kördes i den miljö som anges i avsnittet med fyra körningar.

Nästa steg

Skriv dina tio frågor med känt facit innan du bygger indexet. Ordningen spelar roll: ett facit som skrivs efteråt anpassas omedvetet till det sökningen redan klarar.

Mät genereringssidan