Kurs Python · Moduł 11: RAG i systemy wieloagentowe

Embeddings i Vector Search

7 min czytania
W tej lekcji5

Użytkownik pyta: "gdzie odpoczywają koty?", a w notatniku masz zdanie "Kotek leży na dywanie". Wyszukiwanie po słowach kluczowych nie znajdzie tu żadnego wspólnego wyrazu, choć sens jest prawie ten sam. Potrzebujemy sposobu, żeby porównywać znaczenie, a nie litery. Tym sposobem są embeddings.

Embeddings to wektorowe reprezentacje tekstu, które pozwalają maszynom "rozumieć" znaczenie słów i zdań. Myśl o nich jak o odcisku tropu: każde zwierzę zostawia inny ślad, a tropiciel po kształcie odcisku pozna, kto przeszedł szlakiem. Embedding to taki odcisk tekstu - sekwencja liczb, która opisuje jego semantykę.

Czym są embeddings?

Embedding to wektor liczb rzeczywistych reprezentujący tekst w przestrzeni semantycznej. Teksty o podobnym znaczeniu dostają wektory leżące blisko siebie. Najprościej wygenerować go przez API OpenAI, metodą client.embeddings.create:

1from openai import OpenAI
2
3client = OpenAI()
4
5def get_embedding(text: str, model: str = "text-embedding-3-small") -> list[float]:
6    """Generuje embedding dla podanego tekstu."""
7    response = client.embeddings.create(
8        model=model,
9        input=text
10    )
11    return response.data[0].embedding
12
13# Przykład
14text = "Python to świetny język programowania"
15embedding = get_embedding(text)
16
17print(f"Wymiar wektora: {len(embedding)}")  # 1536 dla text-embedding-3-small
18print(f"Pierwsze 5 wartości: {embedding[:5]}")

Funkcja zwraca zwykłą listę floatów. Model text-embedding-3-small domyślnie daje wektory o 1536 wymiarach, a text-embedding-3-large 3072. Sam tekst nigdzie się nie zapisuje, dostajemy tylko jego liczbowy odcisk. Cały proces zawsze ma ten sam rytm: przygotuj tekst, wyślij go do modelu embeddingów, odbierz wektor i zapisz go w bazie wektorowej.

Modele embeddingów

Nie każdy obóz ma budżet na płatne API. Biblioteka sentence-transformers uruchamia modele open-source lokalnie, na Twoim komputerze. Poniższa klasa zbiera w jednym miejscu model lokalny i model OpenAI:

1from sentence_transformers import SentenceTransformer
2
3# Modele open-source
4class EmbeddingModels:
5    """Różne modele embeddingów."""
6
7    def __init__(self):
8        # Szybki i lekki
9        self.minilm = SentenceTransformer('all-MiniLM-L6-v2')
10
11        # Większy, lepszy dla polskiego
12        self.multilingual = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2')
13
14    def embed_with_openai(self, texts: list[str]) -> list[list[float]]:
15        """OpenAI embeddings - najwyższa jakość."""
16        from openai import OpenAI
17        client = OpenAI()
18
19        response = client.embeddings.create(
20            model="text-embedding-3-large",  # 3072 wymiarów
21            input=texts
22        )
23        return [item.embedding for item in response.data]
24
25    def embed_with_sentence_transformers(self, texts: list[str]) -> list[list[float]]:
26        """Lokalne embeddings - bez kosztów API."""
27        return self.minilm.encode(texts).tolist()

all-MiniLM-L6-v2 jest szybki i lekki (384 wymiary), paraphrase-multilingual-mpnet-base-v2 jest większy i radzi sobie z wieloma językami, także z polskim. Metoda encode zwraca tablicę NumPy, dlatego wołamy .tolist().

Teraz porównajmy teksty w dwóch językach i jedno zdanie zupełnie z innej bajki:

1# Porównanie modeli
2models = EmbeddingModels()
3
4texts = [
5    "Uczenie maszynowe to dziedzina AI",
6    "Machine learning is a field of AI",
7    "Lubię jeść pizzę"
8]
9
10# Open-source embeddings
11embeddings = models.embed_with_sentence_transformers(texts)
12print(f"Lokalne embeddings: {len(embeddings[0])} wymiarów")

Wynik pokaże 384 wymiary. Zwróć uwagę, że wymiar zależy od modelu, a nie od długości tekstu: krótkie zdanie i długi akapit dostają wektory tej samej długości.

Metryki podobieństwa

Mamy wektory, więc trzeba je porównać. Oto trzy najpopularniejsze miary, każda w jednej linijce NumPy:

1import numpy as np
2from typing import Callable
3
4def cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
5    """Podobieństwo kosinusowe - najczęściej używane."""
6    return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))
7
8def euclidean_distance(vec1: np.ndarray, vec2: np.ndarray) -> float:
9    """Odległość euklidesowa."""
10    return np.linalg.norm(vec1 - vec2)
11
12def dot_product(vec1: np.ndarray, vec2: np.ndarray) -> float:
13    """Iloczyn skalarny."""
14    return np.dot(vec1, vec2)

Podobieństwo kosinusowe patrzy tylko na kąt między wektorami i to ono jest najczęściej używane do porównywania embeddingów. Odległość euklidesowa mierzy dystans, więc tu mniejsza liczba oznacza większe podobieństwo. Iloczyn skalarny zależy i od kąta, i od długości wektorów.

Sprawdźmy to na trzech zdaniach, z których dwa mówią o kotach:

1# Demonstracja
2def demonstrate_similarity():
3    """Pokazuje jak działają metryki podobieństwa."""
4    from sentence_transformers import SentenceTransformer
5
6    model = SentenceTransformer('all-MiniLM-L6-v2')
7
8    sentences = [
9        "Kot siedzi na macie",           # 0
10        "Kotek leży na dywanie",         # 1 - podobne znaczenie
11        "Programowanie w Pythonie",       # 2 - inne znaczenie
12    ]
13
14    embeddings = model.encode(sentences)
15
16    print("Podobieństwo kosinusowe:")
17    print(f"  Zdanie 0 vs 1: {cosine_similarity(embeddings[0], embeddings[1]):.3f}")
18    print(f"  Zdanie 0 vs 2: {cosine_similarity(embeddings[0], embeddings[2]):.3f}")
19    print(f"  Zdanie 1 vs 2: {cosine_similarity(embeddings[1], embeddings[2]):.3f}")
20
21demonstrate_similarity()

Para zdań 0 i 1 powinna dostać wyraźnie wyższy wynik niż pary ze zdaniem o Pythonie, mimo że "Kot" i "Kotek" to różne słowa. Dokładnych liczb nie wpisuję, bo zależą od wersji modelu.

Jeszcze jedna przydatna opcja. Jeśli poprosisz model o znormalizowane wektory (długość równa 1), iloczyn skalarny staje się dokładnie podobieństwem kosinusowym:

1from sentence_transformers import SentenceTransformer
2
3model = SentenceTransformer('all-MiniLM-L6-v2')
4document_text = "Lwy polują najczęściej o zmierzchu"
5
6# Wektor o długości 1 - iloczyn skalarny od razu daje podobieństwo kosinusowe
7embedding = model.encode(document_text, normalize_embeddings=True)
8print(embedding.shape)  # (384,)

Parametr nazywa się normalize_embeddings, a pojedynczy string daje jednowymiarową tablicę. Treść wektora wskazuje ten sam kierunek co bez normalizacji, zmienia się tylko jego długość.

Złóżmy te klocki w wyszukiwarkę semantyczną. Najpierw mały kontener na wynik, zrobiony dekoratorem @dataclass, i klasa, która raz indeksuje dokumenty:

1from dataclasses import dataclass
2import numpy as np
3
4@dataclass
5class SearchResult:
6    """Wynik wyszukiwania semantycznego."""
7    text: str
8    score: float
9    index: int
10
11class SemanticSearch:
12    """Prosta implementacja wyszukiwania semantycznego."""
13
14    def __init__(self, model_name: str = 'all-MiniLM-L6-v2'):
15        from sentence_transformers import SentenceTransformer
16        self.model = SentenceTransformer(model_name)
17        self.documents: list[str] = []
18        self.embeddings: np.ndarray | None = None
19
20    def index_documents(self, documents: list[str]) -> None:
21        """Indeksuje dokumenty."""
22        self.documents = documents
23        self.embeddings = self.model.encode(documents)
24        print(f"Zaindeksowano {len(documents)} dokumentów")

index_documents liczy embeddingi wszystkich dokumentów jednym wywołaniem encode i trzyma je w macierzy. To odpowiedź na słabość z poprzedniej lekcji: dokumenty embedujemy raz, a nie przy każdym pytaniu.

Metoda search liczy podobieństwo pytania do całej macierzy naraz, bez pętli:

1    def search(self, query: str, top_k: int = 5) -> list[SearchResult]:
2        """Wyszukuje najbardziej podobne dokumenty."""
3        if self.embeddings is None:
4            raise ValueError("Brak zaindeksowanych dokumentów!")
5
6        query_embedding = self.model.encode([query])[0]
7
8        # Oblicz podobieństwa
9        similarities = np.dot(self.embeddings, query_embedding)
10        similarities /= np.linalg.norm(self.embeddings, axis=1)
11        similarities /= np.linalg.norm(query_embedding)
12
13        # Top-K wyników
14        top_indices = np.argsort(similarities)[::-1][:top_k]
15
16        results = []
17        for idx in top_indices:
18            results.append(SearchResult(
19                text=self.documents[idx],
20                score=float(similarities[idx]),
21                index=int(idx)
22            ))
23
24        return results

np.argsort sortuje rosnąco, więc [::-1] odwraca kolejność, a [:top_k] bierze najlepsze wyniki. Dwa dzielenia przez normy zamieniają iloczyn skalarny w podobieństwo kosinusowe.

Pora na test na pięciu dokumentach:

1# Przykład użycia
2search = SemanticSearch()
3
4documents = [
5    "Python jest językiem programowania ogólnego przeznaczenia",
6    "Machine Learning wykorzystuje algorytmy do uczenia się z danych",
7    "RAG łączy wyszukiwanie z generowaniem tekstu",
8    "FastAPI to nowoczesny framework do budowania API",
9    "Docker konteneryzuje aplikacje",
10]
11
12search.index_documents(documents)
13
14results = search.search("Jak budować aplikacje webowe?")
15for r in results:
16    print(f"Score: {r.score:.3f} | {r.text}")

Na pytanie o aplikacje webowe najwyżej powinien wypaść dokument o FastAPI, choć nie dzieli z pytaniem ani jednego słowa kluczowego.

Batch Processing

Tysiąc dokumentów wysyłanych pojedynczo to tysiąc zapytań HTTP. API OpenAI przyjmuje listę tekstów w parametrze input, więc wysyłamy je partiami:

1def batch_embed(texts: list[str], batch_size: int = 100) -> list[list[float]]:
2    """Embedowanie dużej ilości tekstów w partiach."""
3    from openai import OpenAI
4    client = OpenAI()
5
6    all_embeddings = []
7
8    for i in range(0, len(texts), batch_size):
9        batch = texts[i:i + batch_size]
10
11        response = client.embeddings.create(
12            model="text-embedding-3-small",
13            input=batch
14        )
15
16        batch_embeddings = [item.embedding for item in response.data]
17        all_embeddings.extend(batch_embeddings)
18
19        print(f"Przetworzono {min(i + batch_size, len(texts))}/{len(texts)}")
20
21    return all_embeddings

Kolejność wyników w response.data odpowiada kolejności tekstów w partii, dlatego możemy po prostu doklejać je przez extend.

Drugi sposób na oszczędność to cache. Ten sam tekst i ten sam model zawsze dają ten sam embedding, więc zapisujemy wynik na dysku pod kluczem z hasha:

1# Cache'owanie embeddingów
2import hashlib
3import json
4from pathlib import Path
5
6class EmbeddingCache:
7    """Cache dla embeddingów."""
8
9    def __init__(self, cache_dir: str = ".embedding_cache"):
10        self.cache_dir = Path(cache_dir)
11        self.cache_dir.mkdir(exist_ok=True)
12
13    def _get_cache_key(self, text: str, model: str) -> str:
14        """Generuje klucz cache."""
15        content = f"{model}:{text}"
16        return hashlib.md5(content.encode()).hexdigest()
17
18    def get(self, text: str, model: str) -> list[float] | None:
19        """Pobiera embedding z cache."""
20        key = self._get_cache_key(text, model)
21        cache_file = self.cache_dir / f"{key}.json"
22
23        if cache_file.exists():
24            return json.loads(cache_file.read_text())
25        return None
26
27    def set(self, text: str, model: str, embedding: list[float]) -> None:
28        """Zapisuje embedding do cache."""
29        key = self._get_cache_key(text, model)
30        cache_file = self.cache_dir / f"{key}.json"
31        cache_file.write_text(json.dumps(embedding))

MD5 służy tu wyłącznie jako szybki identyfikator pliku, nie jako zabezpieczenie, więc jego słabości kryptograficzne nie mają znaczenia. Model jest częścią klucza celowo: po zmianie modelu stare wektory nie pasują do nowych. Polecam cache'ować embeddingi od pierwszego dnia, bo przy ponownym indeksowaniu oszczędza to i czas, i pieniądze.

Embeddings to fundament każdego systemu RAG. W następnej lekcji poznasz vector databases - specjalizowane bazy danych do przechowywania i wyszukiwania wektorów - które robią to, co nasza macierz NumPy, ale dla milionów dokumentów.

Zapamiętaj: embedding to odcisk tropu tekstu, a podobne znaczenia zostawiają podobne ślady.

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Czym jest embedding w kontekście NLP?

  2. 2. Która metryka jest najczęściej używana do porównywania embeddingów?

Zadania praktyczne w grze

  • Edytor kodu

    Napisz funkcję cosine_similarity

  • Układanie w pionie

    Uporządkuj kroki:

  • Układanie w poziomie

    Ułóż wywołanie metody tworzenia embeddingu:

  • Klikanie w kolejności

    Ułóż wywołanie embeddingu:

Przydatne artykuły