Hoppa till innehållet
AI-grafen
E· Universitetdatavetenskap· ca 60 min· utvecklande· verifierad 2026-09-20

Lagra och hitta vektorer effektivt

Kunna förklara varför exakt närmaste-granne är dyrt och hur approximativa index fungerar.

Förkunskaper

Intuition

Att hitta de närmaste vektorerna till en fråga är enkelt om du har tusen vektorer: jämför med alla.

Med tio miljoner vektorer à 768 dimensioner blir det 7,7 miljarder multiplikationer per fråga. Det går, men inte hundra gånger per sekund.

Approximativa index byter exakthet mot hastighet. De hittar oftast rätt grannar, ibland inte, och gör det tusen gånger snabbare.

IndexIdéBra på
Flatjämför med allaexakt, små samlingar
IVFdela upp i kluster, sök i de närmastestora samlingar, justerbar
HNSWnavigerbar graf av grannarlåg latens, hög recall
PQkomprimera vektorernaminnesbegränsning

Måttet som räknas heter recall@k: av de kk verkliga närmaste grannarna, hur många hittade indexet? 0,95 betyder att en av tjugo missas — vilket i ett RAG-system oftast är helt acceptabelt, eftersom flera dokument ändå hämtas.

Formellt

HNSW (hierarchical navigable small world) bygger ett skiktat grannskapsnätverk. Översta lagret har få noder med långa hopp; nedersta har alla noder med korta hopp. Sökningen börjar högst upp, greedy-vandrar mot frågan, och går ner ett lager i taget.

Det är samma idé som en hoppa-lista: långa hopp först, sedan finjustering.

ParameterBetyderEffekt
Mgrannar per nodhögre → bättre recall, mer minne
efConstructionsökbredd vid byggethögre → bättre index, längre byggtid
efSearchsökbredd vid frågajusteras i drift: högre → bättre recall, högre latens

Den sista är den viktigaste i praktiken: den låter dig flytta dig längs recall/latens-kurvan utan att bygga om indexet.

IVF klustrar vektorerna (k-means) och söker bara i de nprobe närmaste klustren. Enkelt, minnessnålt och lätt att förstå — men recall faller vid klustergränser, eftersom en granne kan ligga precis på andra sidan.

Produktkvantisering (PQ) delar vektorn i delar och ersätter varje del med närmaste kodbokspost. En 768-dimensionell float32-vektor på 3 072 byte blir 96 byte — 32× mindre. Kostnaden är precision, och PQ kombineras därför nästan alltid med en omrankning: hämta 10× fler kandidater med PQ, räkna exakta avstånd på dem.

Avståndsmåttet måste matcha embeddingmodellen:

MåttAnvänds när
Cosinustextembeddingar (nästan alltid)
Inre produktnär vektorerna är normaliserade — samma som cosinus
Euklidisktbild- och ljudembeddingar ibland

Att använda euklidiskt avstånd på onormaliserade textembeddingar är ett vanligt och svårupptäckt fel: det fungerar «nästan», och recall blir lägre utan att något går sönder.

Mät alltid recall mot exakt sökning på ett urval av frågor innan du litar på ett index. Det är en halvtimmes arbete och det enda sättet att veta var på kurvan du befinner dig.

Kod

import numpy as np, time

rng = np.random.default_rng(0)
N, D = 200_000, 128
X = rng.normal(size=(N, D)).astype("float32")
X /= np.linalg.norm(X, axis=1, keepdims=True)      # normalisera → inre produkt = cosinus
fragor = X[:100] + 0.1 * rng.normal(size=(100, D)).astype("float32")
fragor /= np.linalg.norm(fragor, axis=1, keepdims=True)

def exakt(q, k=10):
    return np.argsort(-(X @ q))[:k]

t = time.perf_counter()
facit = [exakt(q) for q in fragor]
print(f"exakt: {(time.perf_counter() - t) / len(fragor) * 1000:.1f} ms/fråga")

# IVF för hand: klustra, sök bara i de nprobe närmaste klustren
from collections import defaultdict

K = 256
centroider = X[rng.choice(N, K, replace=False)]
for _ in range(8):                                  # enkel k-means
    tillhor = np.argmax(X @ centroider.T, axis=1)
    for c in range(K):
        m = tillhor == c
        if m.any():
            centroider[c] = X[m].mean(0)
    centroider /= np.linalg.norm(centroider, axis=1, keepdims=True)

listor = defaultdict(list)
for i, c in enumerate(np.argmax(X @ centroider.T, axis=1)):
    listor[int(c)].append(i)

def ivf_sok(q, k=10, nprobe=8):
    naraste = np.argsort(-(centroider @ q))[:nprobe]
    kandidater = np.array([i for c in naraste for i in listor[int(c)]])
    if len(kandidater) == 0:
        return np.array([], dtype=int)
    poang = X[kandidater] @ q
    return kandidater[np.argsort(-poang)[:k]]

def recall(nprobe):
    traff = 0
    t0 = time.perf_counter()
    for q, f in zip(fragor, facit):
        traff += len(set(ivf_sok(q, 10, nprobe)) & set(f))
    ms = (time.perf_counter() - t0) / len(fragor) * 1000
    return round(traff / (10 * len(fragor)), 3), round(ms, 2)

for nprobe in (1, 4, 16, 64, 256):
    r, ms = recall(nprobe)
    print(f"  nprobe={nprobe:>3}: recall@10 {r:.3f}  {ms:>6.2f} ms/fråga")
#  ↑ kurvan mellan recall och latens — välj punkt efter krav, mät alltid själv

Loopen längst ner är det viktigaste i hela noden: recall och latens är en kurva, inte ett värde. Att välja nprobe eller efSearch utan att ha ritat den kurvan för sin egen data är att gissa.

Behärskning innebär

  • Förklarar kostnaden för exakt sökning
  • Beskriver hur HNSW och IVF fungerar
  • Väljer index och parametrar efter krav

Logga in för att göra övningarna och bygga upp din behärskning.

Källor

Alla källor och licenser