Embedding to lista liczb (wektor), którą model zamienia tekst tak, że teksty o podobnym znaczeniu leżą blisko siebie. W Pythonie liczysz go biblioteką sentence-transformers lokalnie albo przez API OpenAI, a podobieństwo dwóch tekstów mierzysz kosinusem kąta między wektorami w numpy. Dla polskich tekstów użyj modelu wielojęzycznego, bo modele „tylko angielskie” dają wyniki, które trudno od siebie odróżnić.Zwykłe wyszukiwanie po słowach kluczowych nie znajdzie odpowiedzi na pytanie „Zapomniałem hasła”, jeśli w dokumentacji stoi „Aby zresetować hasło…”. Dla człowieka to to samo, dla if słowo in tekst zupełnie coś innego. Embeddingi rozwiązują ten problem: porównują znaczenie, a nie litery.
W tym artykule policzysz embeddingi dla polskich zdań, porównasz je podobieństwem cosinusowym w numpy i zobaczysz, na jakie pułapki wpadają początkujący: złe modele, niezgodne wymiary i błąd kształtu wektora. To pierwsza część cyklu. W kolejnej nauczysz się dzielić dokumenty na fragmenty i zapisywać ich źródło, a na końcu złożysz wyszukiwarkę semantyczną działającą w pamięci.
Czym jest embedding i dlaczego tekst zamienia się w wektor?
Embedding to lista liczb o stałej długości, np. 384 albo 1536 liczb. Model uczono tak, żeby teksty o podobnym sensie dostawały podobne listy. Dzięki temu pytanie „czy te dwa zdania znaczą to samo?” zamienia się w pytanie „czy te dwa wektory wskazują w podobnym kierunku?”.
Kilka rzeczy, które warto zapamiętać:
- Długość wektora (wymiar) zależy od modelu, nie od długości tekstu. Zdanie i cały akapit dają wektor tej samej długości.
- Pojedyncze liczby w wektorze nic nie znaczą. Sensu nabiera dopiero porównanie dwóch wektorów.
- Wektory z różnych modeli są ze sobą niezgodne. Nie możesz porównać wektora z jednego modelu z wektorem z drugiego, nawet gdy mają ten sam wymiar.
Szerzej o tym, dlaczego to w ogóle działa, piszemy w artykule o bazach wektorowych i RAG w Pythonie.
Jak policzyć embedding w Pythonie lokalnie?
Najprościej użyć biblioteki sentence-transformers. Model pobierze się przy pierwszym uruchomieniu, a potem działa bez internetu i bez klucza API.
pip install sentence-transformers numpy
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
zdania = [
"Jak zresetować hasło do konta?",
"Nie pamiętam hasła i nie mogę się zalogować.",
]
wektory = model.encode(zdania)
print(wektory.shape) # (2, 384)
print(wektory.dtype) # float32
Dwa zdania dały macierz 2 × 384: jeden wiersz na zdanie. To ważny nawyk: encode zawsze zwraca tablicę numpy, a pojedynczy tekst wraz z listą jednego tekstu mają różny kształt (więcej o tym niżej).
Czy embeddingi działają dla polskiego tekstu?
Tak, ale musisz wybrać odpowiedni model. Sprawdziliśmy to na pięciu zdaniach: dwa o resecie hasła, dwa o cenie abonamentu i jedno o pomidorowej. Poniżej podobieństwo cosinusowe dla pary „reset hasła”, pary „cena” i dla zdania o pomidorowej względem pytania o hasło:
| Model | Para „hasło” | Para „cena” | Pomidorowa vs. hasło |
|---|---|---|---|
paraphrase-multilingual-MiniLM-L12-v2 (wielojęzyczny) | 0,56 | 0,63 | 0,08 |
all-MiniLM-L6-v2 (angielski) | 0,63 | 0,44 | 0,50 |
Model wielojęzyczny wyraźnie oddziela zdania o tej samej sprawie (0,56 i 0,63) od zdania o zupełnie innym temacie (0,08). Model angielski daje dla pomidorowej aż 0,50, czyli prawie tyle, ile dla pary o haśle. Był uczony głównie na angielskim, więc polskie zdania dostają od niego wyniki, które słabo się od siebie różnią (w naszym teście od 0,36 do 0,63 dla wszystkich par).
Wniosek praktyczny: dla polskiego tekstu bierz model wielojęzyczny. Nazwa „MiniLM” i wymiar 384 nie mówią nic o tym, czy model zna polski.
Jest jeszcze druga lekcja z tej tabeli: nie porównuj wyników podobieństwa między różnymi modelami. W naszym teście model angielski dawał od 0,36 do 0,61 dla zdań o zupełnie różnych sprawach, a para o cenie abonamentu dostała tylko 0,44, czyli mniej niż niepowiązane zdania, więc próg „0,5 = podobne” przestaje cokolwiek znaczyć. Próg ustawiaj osobno dla każdego modelu, na własnych przykładach.
Jak policzyć embedding przez API OpenAI?
Jeśli nie chcesz hostować modelu, użyj API. Wywołanie wygląda podobnie do zapytań czatu, ale zwraca wektor zamiast odpowiedzi:
from openai import OpenAI
client = OpenAI() # klucz odczytany z OPENAI_API_KEY
odpowiedz = client.embeddings.create(
model="text-embedding-3-small",
input=["Jak zresetować hasło do konta?", "Ile kosztuje abonament?"],
)
wektory = [element.embedding for element in odpowiedz.data]
print(len(wektory), len(wektory[0])) # 2 1536
Model text-embedding-3-small zwraca domyślnie 1536 liczb, a text-embedding-3-large 3072. Aktualne nazwy modeli, wymiary i ceny sprawdź w dokumentacji OpenAI, bo zmieniają się częściej niż sama biblioteka. Za liczbę tokenów w input płacisz tak samo jak przy czacie. Jak je policzyć przed wysyłką, opisujemy w artykule o API OpenAI w Pythonie: tokenach, kontekście i kosztach.
sentence-transformers (lokalnie) | API OpenAI | |
|---|---|---|
| Koszt | Brak opłat za zapytania, liczysz własnym procesorem | Płacisz za tokeny |
| Prywatność danych | Tekst nie opuszcza komputera | Tekst trafia do zewnętrznego API |
| Konfiguracja | Pobranie modelu, kilkaset MB | Klucz API |
| Typowy wymiar | 384 (ten model) | 1536 (small) / 3072 (large) |
Dlaczego embeddingi mają różne wymiary i co się stanie, gdy je pomylisz?
Wymiar to długość wektora, a każdy model ma swój. Wektory z różnych modeli (np. 384 i 1536 liczb) są niezgodne, więc numpy odmówi ich porównania:
import numpy as np
wektory_lokalne = np.zeros((5, 384), dtype=np.float32)
wektor_openai = np.zeros(1536, dtype=np.float32)
wektory_lokalne @ wektor_openai
# ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0 ...
W bazach wektorowych ten sam problem wraca jako błąd o niezgodnym wymiarze kolekcji, np. 384 vs 1536. Przyczyna jest zawsze ta sama: dokumenty zindeksowano jednym modelem, a zapytanie liczysz innym. Zasada: ten sam model do dokumentów i do zapytań, a nazwę modelu zapisuj razem z indeksem.
Wymiar ma też cenę w pamięci. Milion wektorów float32 zajmuje:
| Wymiar | Rozmiar 1 000 000 wektorów |
|---|---|
| 384 | ok. 1,5 GB |
| 1536 | ok. 6,1 GB |
| 3072 | ok. 12,3 GB |
Dla projektu w pamięci, który zbudujemy w trzeciej części, to jeszcze nie problem. Przy milionach dokumentów zaczyna być.
Jak policzyć podobieństwo cosinusowe w numpy?
Podobieństwo cosinusowe mierzy kąt między dwoma wektorami: 1 oznacza ten sam kierunek, 0 brak związku, wartości ujemne przeciwne kierunki. Wzór to iloczyn skalarny podzielony przez iloczyn długości wektorów:
import numpy as np
def cosine(a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
a, b = model.encode([
"Jak zresetować hasło do konta?",
"Nie pamiętam hasła i nie mogę się zalogować.",
])
print(round(cosine(a, b), 2)) # 0.56
print(round(float(np.dot(a, b)), 2)) # 7.91
Zwróć uwagę na ostatnią linijkę. Sam iloczyn skalarny dał 7,91, a to nie jest podobieństwo. Wektory z tego modelu nie mają długości 1 (pierwszy ma długość ok. 3,5), więc iloczyn skalarny zawiera jeszcze „długość”, a nie tylko kierunek. To jedna z najczęstszych pomyłek: ktoś używa np.dot i dostaje liczby spoza zakresu od −1 do 1.
Rozwiązanie jest proste. Poproś encode o znormalizowane wektory (długość 1). Wtedy iloczyn skalarny jest podobieństwem cosinusowym:
wektory = model.encode(zdania, normalize_embeddings=True)
podobienstwo = float(wektory[0] @ wektory[1]) # 0.56
Nie każdy model zwraca wektory o długości 1: niektóre (np. all-MiniLM-L6-v2) już to robią, inne nie. Normalizacja przy zapisie ci nie zaszkodzi, więc rób ją zawsze.
Jak porównać zapytanie ze wszystkimi dokumentami bez pętli?
Zamiast porównywać wektory w pętli for, pomnóż macierz dokumentów przez wektor zapytania. Numpy policzy wszystkie podobieństwa naraz:
dokumenty = [
"Aby zresetować hasło, kliknij „Nie pamiętam hasła” na stronie logowania.",
"Abonament miesięczny kosztuje 49 zł, a roczny 490 zł.",
"Fakturę pobierzesz w zakładce „Płatności” w ustawieniach konta.",
"Konto możesz usunąć, pisząc do naszego działu obsługi.",
"Aplikacja działa na systemach iOS i Android.",
]
D = model.encode(dokumenty, normalize_embeddings=True) # (5, 384)
zapytanie = model.encode("Zapomniałem hasła", normalize_embeddings=True) # (384,)
wyniki = D @ zapytanie # (5,)
for i in np.argsort(-wyniki)[:2]:
print(round(float(wyniki[i]), 2), dokumenty[i])
Dla trzech przykładowych zapytań dostajemy:
| Zapytanie | Najlepszy dokument | Wynik |
|---|---|---|
| Zapomniałem hasła | Aby zresetować hasło, kliknij… | 0,60 |
| Ile płacę za subskrypcję? | Abonament miesięczny kosztuje 49 zł… | 0,47 |
| Gdzie jest moja faktura? | Fakturę pobierzesz w zakładce… | 0,48 |
W żadnym zapytaniu nie ma wspólnego słowa kluczowego z odpowiedzią poza odmianą („hasła” i „hasło”), a mimo to właściwy dokument jest pierwszy. Przy tak małym zbiorze różnica czasu między pętlą a macierzą jest niewielka: na 5000 wektorów o wymiarze 384 pomnożenie macierzy było u nas około 4 razy szybsze. Większą korzyść dostajesz przy dużych zbiorach.
Skąd błąd „Input vector should be 1-D” i jak go naprawić?
Ten błąd pojawia się, gdy podasz funkcji dwuwymiarową tablicę tam, gdzie oczekuje wektora. Najczęściej dzieje się to z scipy.spatial.distance.cosine:
from scipy.spatial.distance import cosine
a = model.encode(["Zapomniałem hasła"]) # (1, 384) - lista jednego tekstu
b = model.encode(["Nie pamiętam hasła"]) # (1, 384)
cosine(a, b)
# ValueError: Input vector should be 1-D.
cosine(a[0], b[0]) # działa
Różnica leży w kształcie. model.encode("tekst") zwraca wektor (384,), a model.encode(["tekst"]) macierz (1, 384). Jeśli widzisz ten błąd, sprawdź .shape i weź [0] albo .ravel(). Uwaga: scipy.spatial.distance.cosine zwraca odległość (1 − podobieństwo), więc wynik 0,26 oznacza podobieństwo 0,74.
Ten sam kształt zmienia też wynik mnożenia macierzy: D @ Q[0] daje wektor (5,), a D @ Q.T macierz (5, 1). Przy zapytaniu jednym naraz pracuj z wektorami jednowymiarowymi, żeby nie męczyć się potem z [:, 0].
Co zrobić, gdy macierz podobieństw nie mieści się w pamięci?
Pełna macierz podobieństw między N zapytaniami a N dokumentami ma N × N liczb. Dla 20 000 × 20 000 w float32 to 1,6 GB, a dla 100 000 już 40 GB. Odpowiedź na takie pytanie w sieci bywa: „użyj bazy wektorowej”, ale zwykle wystarczy liczyć porcjami i trzymać tylko k najlepszych wyników:
def top_k(zapytania: np.ndarray, dokumenty: np.ndarray, k: int = 3, porcja: int = 1000):
wyniki = []
for start in range(0, len(zapytania), porcja):
podobienstwa = zapytania[start:start + porcja] @ dokumenty.T # (porcja, N)
indeksy = np.argpartition(-podobienstwa, k, axis=1)[:, :k]
wyniki.append(indeksy)
return np.vstack(wyniki)
W każdej chwili w pamięci jest tylko macierz o rozmiarze porcja × N. Zwrócone indeksy nie są posortowane wewnątrz k najlepszych, więc jeśli potrzebujesz kolejności, posortuj je po obliczeniu. Taki sposób zadziała, dopóki wektory mieszczą się w pamięci. Gdy przestają, czas na bazę wektorową.
Co dalej po embeddingach?
Masz już wektory i potrafisz je porównywać. Zostały dwa praktyczne pytania: co dokładnie zamienić na wektor, skoro dokument ma setki stron, i jak z wyniku wrócić do źródła. Odpowiedź na pierwsze to chunking, czyli podział dokumentu na fragmenty wraz z metadanymi. Na drugie odpowiedź daje projekt wyszukiwarki semantycznej w pamięci, w którym połączymy obie części.
Jeśli chcesz zbudować takie rozwiązanie z mentorem, zajrzyj na ścieżkę Python, w której pracujesz nad realnym projektem z AI i dostajesz code review.


