Wpisz “szczeniak” w systemie, który porównuje tylko słowa kluczowe, i dokument o szkoleniu psów się nie pojawi, choć każdy, kto przeczytałby oba teksty, uznałby je za ściśle powiązane. Wyszukiwanie po słowach kluczowych porównuje ciągi znaków. Nie wie, że “szczeniak” i “pies” znaczą prawie to samo. Embeddingi rozwiązują właśnie ten problem. Zamieniają fragment tekstu w listę liczb, wektor, umieszczony tak, że tekst o podobnym znaczeniu trafia blisko innego tekstu o podobnym znaczeniu, niezależnie od użytych słów.

Jak model embeddingów zamienia tekst w liczby

Model embeddingów to sieć neuronowa wytrenowana do mapowania tekstu na punkt w przestrzeni wielowymiarowej: typowe rozmiary to 384, 768 lub 1536 wymiarów, zależnie od modelu. Nie sprawdza słów w słowniku. Na podstawie ogromnych ilości tekstu nauczył się, które słowa i frazy pojawiają się w podobnych kontekstach, i koduje to w miejscu, na które wskazuje wektor.

Odległość w tej przestrzeni odzwierciedla podobieństwo znaczenia. “Król” i “królowa” lądują blisko siebie. “Król” i “golden retriever” lądują daleko od siebie. Nikt nie powiedział modelowi, żeby porównywał te słowa. Taka geometria wynika z treningu, nie z zakodowanej na sztywno reguły.

Ten sam kształt widać na liczbach wybranych ręcznie, jeszcze zanim w grę wejdzie prawdziwy model. Przypisz każdemu słowu wektor z 3 liczb (prawdziwy model używa ich setek):

1
2
3
king  = [0.90, 0.10, 0.85]
queen = [0.88, 0.12, 0.10]
dog   = [0.05, 0.95, 0.40]

king i queen są bardzo podobne na pierwszych dwóch liczbach. dog nie. Aby zmienić to podobieństwo w jeden wynik liczbowy, potrzebna jest metryka.

Cosine similarity, dot product i Euclidean distance w porównaniu

W pracy z embeddingami powtarzają się trzy metryki, każda odpowiada na nieco inne pytanie:

MetrykaCo mierzyKiedy jej używać
Cosine similarityKąt między dwoma wektorami, bez uwzględnienia ich długościDomyślna opcja dla embeddingów tekstowych, bo większość modeli jest trenowana właśnie pod tę metrykę
Dot productKąt i długość razemModel był wyraźnie trenowany pod ranking dot product (niektóre modele retrieval tak działają)
Euclidean distanceOdległość w linii prostej między dwoma punktamiDane przestrzenne lub oparte na zliczeniach; rzadziej spotykana dla tekstu

Cosine similarity to metryka, po którą sięgniesz niemal zawsze. Wiele modeli text embedding normalizuje swoje wektory wyjściowe do długości 1, więc jedyną zmienną zostaje kierunek, a na znormalizowanych wektorach cosine i dot product dają ten sam ranking. Wynik mieści się w zakresie od -1 (przeciwny) do 1 (identyczny), gdzie 0 oznacza brak związku. Tak to wygląda na ręcznie wybranych wektorach:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import numpy as np

def cosine_similarity(a, b):
    a, b = np.array(a, dtype=float), np.array(b, dtype=float)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

king  = [0.90, 0.10, 0.85]
queen = [0.88, 0.12, 0.10]
dog   = [0.05, 0.95, 0.40]

print(cosine_similarity(king, queen))  # 0.801
print(cosine_similarity(king, dog))    # 0.375

0,801 wobec 0,375. king jest zdecydowanie bliżej queen niż dog. Prawdziwy model daje taki sam rodzaj różnicy, rozłożonej na setki wymiarów, których nauczył się z danych, a nie z trzech wybranych ręcznie liczb.

Jak generować embeddingi w Pythonie

sentence-transformers to standardowa biblioteka open source do tego zadania. Działa lokalnie, bez klucza API:

1
pip install sentence-transformers
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
sentences = [
    "The weather is lovely today.",
    "It's so sunny outside!",
    "He drove to the stadium.",
]
embeddings = model.encode(sentences)          # shape: (3, 384)
print(model.similarity(embeddings, embeddings))

all-MiniLM-L6-v2 zamienia każde zdanie w wektor z 384 liczb, a model.similarity liczy cosine similarity dla każdej pary. Dwa zdania o słonecznej pogodzie nie mają ze sobą prawie żadnych wspólnych słów, a mimo to otrzymują wysoki wynik wobec siebie i niski wobec zdania o stadionie. Ten sam wzorzec co w przykładzie zabawkowym, tylko że teraz liczby pochodzą z modelu, który nauczył się języka.

Hostowane API zwracają to samo. Endpointy text-embedding-3-small od OpenAI i embed od Cohere dają listę liczb zmiennoprzecinkowych dla każdego tekstu. Jeśli już uruchamiasz modele lokalnie, Ollama również udostępnia embeddingi: ollama pull nomic-embed-text, a następnie wywołaj jego endpoint /api/embed (konfiguracja jest opisana w Jak Uruchomić LLM Lokalnie z Ollama). Biblioteka się zmienia. Wektor i to, co z nim robisz, nie.

Jak wyszukiwanie podobieństwa wektorowego znajduje najbliższe dopasowanie

Obliczenie embeddingu jednego tekstu to łatwa część. Prawdziwym zadaniem jest zazwyczaj: mając wektor zapytania, znaleźć najbliższe dopasowania wśród tysięcy lub milionów zapisanych wektorów. To jest wyszukiwanie podobieństwa wektorowego, a jego najprostsza wersja to brute force: porównaj zapytanie z każdym zapisanym wektorem i zachowaj najlepsze wyniki.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import numpy as np, time

rng = np.random.default_rng(0)

for n in [10_000, 100_000, 1_000_000]:
    vectors = rng.normal(size=(n, 384)).astype(np.float32)
    query = rng.normal(size=384).astype(np.float32)

    start = time.perf_counter()
    scores = vectors @ query / (np.linalg.norm(vectors, axis=1) * np.linalg.norm(query))
    top5 = np.argsort(scores)[-5:]
    elapsed = time.perf_counter() - start
    print(f"n={n:>9,}  {elapsed*1000:.2f} ms")

Na zwykłej maszynie to wypisuje coś bliskiego:

1
2
3
n=   10,000  0.70 ms
n=  100,000  7.82 ms
n=1,000,000  63.64 ms

Czas rośnie liniowo z liczbą wektorów, O(n) w rozumieniu notacji Big O, ale NumPy sprawia, że każdy krok jest tak tani, że milion wektorów zajmuje dużo mniej niż 100 ms. To przestaje wystarczać przy dużym ruchu współbieżnym, dziesiątkach milionów wektorów albo obu naraz. Wtedy potrzebny jest indeks approximate nearest neighbor (ANN), który dla wektorów robi to, co B-tree robi dla klauzuli WHERE w bazie danych (zobacz Jak Działają Indeksy w Bazie Danych i Kiedy Je Dodawać). Wszędzie pojawiają się dwa rodzaje:

  • HNSW (Hierarchical Navigable Small World) buduje wielowarstwowy graf łączący każdy wektor z jego sąsiadami, dzięki czemu wyszukiwanie przechodzi przez graf w kierunku odpowiedzi, a nie dotyka każdego wektora. Daje najlepszy recall na milisekundę ze wszystkich typowych opcji i płaci za to większym zużyciem pamięci i wolniejszym budowaniem indeksu.
  • IVFFlat grupuje wektory w klastry wokół centroidów, a potem przeszukuje tylko klastry najbliższe zapytaniu. Buduje się szybciej i zużywa mniej pamięci niż HNSW, ale wymaga wczytania reprezentatywnych danych przed utworzeniem. Zbudowany na pustej tabeli, daje słabe klastry i słaby recall.

W Postgresie z rozszerzeniem pgvector każdy z nich to jedno zapytanie:

1
2
3
4
5
-- HNSW: można zbudować w każdej chwili, nawet na pustej tabeli
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);

-- IVFFlat: najpierw wczytaj reprezentatywne dane, potem zbuduj
CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

Oba wymieniają dokładność na szybkość. Mogą nie znaleźć naprawdę najbliższego wektora, co jest w porządku przy wyszukiwaniu i rekomendacjach, a problemem wszędzie, gdzie potrzebna jest dokładna odpowiedź za każdym razem.

Częste błędy w wyszukiwaniu z embeddingami

  • Odpytywanie jednym modelem, a zapisywanie innym. Wektory z dwóch różnych modeli żyją w niekompatybilnych przestrzeniach. Przy zmianie modelu przelicz embedding każdego zapisanego dokumentu nowym modelem. Mieszanka starych i nowych wektorów zwraca bezużyteczne dopasowania bez żadnego błędu.
  • Używanie dot product bez normalizacji wektorów. Dot product uwzględnia długość, więc długi wektor może wyprzedzić w rankingu krótszy i bardziej trafny. Normalizuj przy zapisie albo użyj klasy operatora cosine, która ignoruje długość.
  • Traktowanie bliskości jako poprawności. Wysoki wynik cosine znaczy “pojawia się w podobnych kontekstach”, nie “jest właściwą odpowiedzią”. Zapytanie o skutki uboczne leku może zwrócić fragment o dawkowaniu tego samego leku: powiązany i błędny. To główny sposób, w jaki zawodzą pipeline’y RAG. Podobieństwo zawęża kandydatów. Nie weryfikuje ich.
  • Dodawanie indeksu ANN, zanim brute force stanie się naprawdę zbyt wolny. Powyższy benchmark obsługuje milion wektorów w mniej niż 100 ms. HNSW lub IVFFlat dodają czas budowania, parametry do dostrojenia i wyniki przybliżone. Warto je wprowadzić dopiero po przekroczeniu dziesiątek milionów wektorów albo pod dużym obciążeniem współbieżnym, nie domyślnie.
  • Zapominanie o ponownym embeddowaniu po zmianie źródła. Embedding jest zdjęciem tekstu z momentu, w którym go policzono. Edytuj dokument bez ponownego embeddowania, a wyszukiwanie będzie dalej z pełnym przekonaniem zwracać starą wersję.

Czy potrzebujesz bazy danych wektorowej?

Na początek nie. Policz embedding kilkuset swoich prawdziwych dokumentów za pomocą sentence-transformers, trzymaj wektory w tablicy NumPy i przeszukuj je powyższą pętlą brute force. Bez bazy danych, bez indeksu. Przejdź na pgvector albo zarządzaną bazę danych wektorową, gdy brute force staje się mierzalnie zbyt wolny dla twojej ilości danych i liczby zapytań. Dodaj indeks ANN tylko wtedy, gdy skanowanie tabeli jest wąskim gardłem, które faktycznie zmierzyłeś.

Powiązane artykuły