Kurs Python · Moduł 11: RAG i systemy wieloagentowe
Embeddings i Vector Search
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ść.
Semantic Search
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 resultsnp.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_embeddingsKolejność 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. Czym jest embedding w kontekście NLP?
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: