pgvector, czyli baza wektorowa, którą już masz
Zanim dołożysz do projektu osobną usługę do wyszukiwania wektorowego, warto sprawdzić, czy nie wystarczy rozszerzenie do bazy, której i tak używasz. pgvector dodaje do PostgreSQL typ kolumny na wektory, operatory odległości i indeksy przybliżone, a wszystko pozostaje w tej samej transakcji i tej samej kopii zapasowej.
Ta ostatnia sprawa bywa rozstrzygająca. Osobna baza wektorowa to kolejna usługa do monitorowania, kolejny plan awaryjny i kolejne miejsce, w którym dane mogą rozjechać się z bazą główną. Rozszerzenie takich problemów nie tworzy.
Instalacja i pierwsze zapytanie
CREATE EXTENSION vector;
CREATE TABLE fragmenty (
id bigserial PRIMARY KEY,
dokument_id bigint REFERENCES dokumenty(id),
tresc text NOT NULL,
jezyk text NOT NULL,
osadzenie vector(1536)
);Liczba w nawiasie to wymiarowość wektora i musi odpowiadać modelowi, którym liczysz osadzenia. Zmiana modelu na taki o innej wymiarowości oznacza zmianę typu kolumny i przeliczenie wszystkich wierszy.
Wyszukiwanie sprowadza się do sortowania po odległości. Operator zależy od miary, przy czym dla większości współczesnych modeli właściwa jest odległość kosinusowa.
SELECT id, tresc, 1 - (osadzenie <=> $1) AS podobienstwo
FROM fragmenty
WHERE jezyk = 'pl'
ORDER BY osadzenie <=> $1
LIMIT 5;Zapisany do zmiennej wektor pytania liczysz po stronie aplikacji tym samym modelem, którym liczyłeś fragmenty. To jedyny warunek poprawności, o którym baza nie ostrzeże: wektory z dwóch różnych modeli można porównać technicznie, a wynik jest bezwartościowy.
Indeksy, czyli różnica między prototypem a produkcją
Bez indeksu każde zapytanie przegląda całą tabelę. Przy dziesięciu tysiącach wierszy to nadal milisekundy, przy milionie to sekundy i obciążenie procesora, którego nie chcesz.
CREATE INDEX ON fragmenty
USING hnsw (osadzenie vector_cosine_ops)
WITH (m = 16, ef_construction = 64);Indeks HNSW jest domyślnym wyborem. Buduje graf powiązań między wektorami i daje wysoką trafność przy krótkim czasie zapytania, kosztem pamięci i czasu budowania. Parametr m określa liczbę połączeń na węzeł, a ef_construction dokładność budowy grafu.
Alternatywą jest IVFFlat, który dzieli przestrzeń na listy i przeszukuje tylko najbliższe. Buduje się szybciej i zajmuje mniej pamięci, ale wymaga, żeby w tabeli były już dane w chwili tworzenia indeksu, i daje niższą trafność przy tym samym czasie zapytania. W praktyce sięga się po niego przy naprawdę dużych zbiorach albo przy ograniczonej pamięci.
Budowanie indeksu HNSW korzysta z wielu rdzeni, ale domyślnie tylko z dwóch procesów pomocniczych, więc maszyna szesnastordzeniowa buduje indeks niewiele szybciej niż czterordzeniowa, dopóki tego nie zmienisz. Liczbę procesów podnosi max_parallel_maintenance_workers, a przy większych wartościach trzeba też podnieść max_parallel_workers, domyślnie ustawiony na osiem.
Drugi próg jest ważniejszy od liczby rdzeni. Rozszerzenie buduje graf w pamięci roboczej sesji, a gdy przestaje się w niej mieścić, przechodzi na wariant dyskowy i wypisuje o tym komunikat wprost, z podpowiedzią, żeby tę pamięć zwiększyć. Różnica w czasie jest wtedy wielokrotna, więc przed pierwszym indeksowaniem dużego zbioru ustaw maintenance_work_mem na tyle wysoko, żeby graf się zmieścił, pilnując, by nie wyczerpać pamięci serwera.
Strojenie trafności
Indeks przybliżony ma parametr decydujący o tym, jak szeroko szuka. Im wyżej, tym większa szansa znalezienia rzeczywiście najbliższych wektorów i tym dłuższe zapytanie.
SET hnsw.ef_search = 100;Domyślna wartość to czterdzieści, czyli nastawienie na szybkość. Podniesienie jej do stu albo dwustu zwykle zauważalnie poprawia trafność przy niewielkim koszcie czasu, ale konkretna liczba zależy od zbioru i trzeba ją zmierzyć. Wewnątrz transakcji ustawiaj ją przez SET LOCAL, żeby nie zostawiać zmienionej wartości na całe połączenie.
Pomiar wygląda tak: dla zestawu stu zapytań policz wynik dokładny bez indeksu, potem wynik z indeksem i sprawdź, jaki procent wyników się pokrywa. To jedyna miara, na której da się oprzeć decyzję, a jednocześnie ta, którą prawie nikt nie liczy.
Warto wiedzieć, że trafność indeksu i trafność wyszukiwania to dwie różne rzeczy, które łatwo pomylić. Pierwsza mówi, czy indeks znalazł te same wektory co przeszukanie dokładne. Druga mówi, czy znalezione fragmenty rzeczywiście odpowiadają na pytanie użytkownika. Indeks może działać idealnie, a wyniki i tak być bezużyteczne, jeśli zawodzi model osadzeń albo sposób cięcia dokumentów. Diagnozując słabe wyniki, sprawdź najpierw drugą wartość, bo strojenie indeksu przy złych osadzeniach nie daje nic.
Filtrowanie, czyli najczęstsza pułapka
Zapytanie łączące wyszukiwanie wektorowe z warunkiem na inną kolumnę wygląda niewinnie, a bywa źródłem cichych błędów. Indeks przybliżony zwraca pewną liczbę kandydatów, a dopiero potem działa filtr, więc przy wąskim warunku może zostać zero wyników mimo pasujących wierszy w tabeli.
Nowsze wersje łagodzą to skanowaniem iteracyjnym, które dobiera kolejnych kandydatów, dopóki nie zbierze żądanej liczby wyników po filtrze. Mechanizm jest domyślnie wyłączony, więc trzeba go włączyć świadomie, i ma własny sufit: skanowanie kończy się po dwudziestu tysiącach odwiedzonych wierszy albo po wyczerpaniu przydzielonej pamięci, więc przy bardzo wąskim filtrze nadal może zwrócić mniej wyników, niż prosiłeś. Progi te regulują hnsw.max_scan_tuples i hnsw.scan_mem_multiplier.
SET hnsw.iterative_scan = relaxed_order;Drugi sposób to indeks częściowy, budowany tylko dla wierszy spełniających typowy warunek. Sprawdza się, gdy filtr jest stały i przewidywalny, na przykład jeden język albo jeden typ dokumentu.
Trzeci to podział na tabele partycjonowane po kluczu, którego dotyczy filtr. Przy aplikacji wielodostępnej, gdzie każde zapytanie ogranicza się do jednego klienta, taka struktura rozwiązuje problem u źródła i przy okazji upraszcza usuwanie danych.
Oszczędzanie miejsca
Wektor o wymiarowości tysiąc pięćset trzydzieści sześć zajmuje ponad sześć kilobajtów na wiersz w pełnej precyzji. Milion fragmentów to sześć gigabajtów samych osadzeń, nie licząc indeksu.
Typ o połowicznej precyzji zmniejsza to o połowę przy niewielkiej stracie trafności, zwykle nieodczuwalnej w zastosowaniach wyszukiwania.
ALTER TABLE fragmenty
ALTER COLUMN osadzenie TYPE halfvec(1536);
CREATE INDEX ON fragmenty
USING hnsw (osadzenie halfvec_cosine_ops);Drugą drogą jest skrócenie samego wektora, jeśli używany model to obsługuje. Część modeli pozwala pobrać osadzenie o mniejszej liczbie wymiarów bez utraty jakości proporcjonalnej do redukcji, co zmniejsza i tabelę, i indeks.
Trzecia to typ rzadki, przydatny przy osadzeniach, w których większość pozycji ma wartość zero. To sytuacja typowa dla modeli leksykalnych, nie dla klasycznych osadzeń gęstych.
Wyszukiwanie mieszane bez dokładania usług
Baza ma wbudowane wyszukiwanie pełnotekstowe od lat, więc obok wektorów masz drugi mechanizm bez żadnej dodatkowej instalacji. To ważniejsze, niż wygląda, bo wyszukiwanie znaczeniowe zawodzi dokładnie tam, gdzie pełnotekstowe jest mocne: przy nazwach własnych, kodach błędów i numerach.
ALTER TABLE fragmenty ADD COLUMN tresc_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('simple', tresc)) STORED;
CREATE INDEX ON fragmenty USING gin (tresc_tsv);Połączenie obu ścieżek sprowadza się do dwóch zapytań i scalenia wyników. Prosty sposób polega na przypisaniu każdemu wynikowi punktacji odwrotnie proporcjonalnej do pozycji na obu listach i zsumowaniu jej dla fragmentów obecnych w obu.
WITH wektorowe AS (
SELECT id, row_number() OVER (ORDER BY osadzenie <=> $1) AS poz
FROM fragmenty ORDER BY osadzenie <=> $1 LIMIT 30
), tekstowe AS (
SELECT id, row_number() OVER (ORDER BY ts_rank(tresc_tsv, $2) DESC) AS poz
FROM fragmenty WHERE tresc_tsv @@ $2 LIMIT 30
)
SELECT id, sum(1.0 / (60 + poz)) AS punkty
FROM (SELECT * FROM wektorowe UNION ALL SELECT * FROM tekstowe) z
GROUP BY id ORDER BY punkty DESC LIMIT 5;Ta konstrukcja podnosi trafność bardziej niż podmiana modelu osadzeń na większy, zwłaszcza w dokumentacji technicznej. Osobna baza wektorowa wymagałaby do tego drugiego systemu, tutaj wszystko dzieje się w jednym zapytaniu.
Utrzymanie i koszty operacyjne
Indeks wektorowy potrafi zajmować więcej niż same dane, więc warto go zmierzyć, zanim zabraknie miejsca.
SELECT pg_size_pretty(pg_relation_size('fragmenty')) AS tabela,
pg_size_pretty(pg_indexes_size('fragmenty')) AS indeksy;Przy dużych zbiorach pojawiają się dwa problemy operacyjne. Pierwszy to pamięć: indeks HNSW działa szybko wtedy, gdy mieści się w pamięci podręcznej bazy, a gdy zaczyna być czytany z dysku, czasy zapytań rosną skokowo. Drugi to czas budowania przy odtwarzaniu bazy z kopii zapasowej, który przy milionach wektorów liczy się w godzinach.
Rozsądna praktyka to trzymanie wektorów w osobnej tabeli powiązanej kluczem obcym, a nie jako kolumny w tabeli głównej. Dzięki temu zapytania niedotyczące wyszukiwania nie ciągną za sobą sześciu kilobajtów na wiersz, a operacje na indeksie nie blokują pracy na danych podstawowych.
Warto też pamiętać o zwykłej higienie: statystyki po masowym wstawieniu, autoodkurzanie przy częstych aktualizacjach i monitorowanie planu zapytań. To te same rzeczy co przy każdej innej tabeli, tylko konsekwencje ich zaniedbania są tu bardziej odczuwalne.
pgvector kontra osobna baza wektorowa
| Rozwiązanie | Mocna strona | Słabość | Kiedy wybrać |
|---|---|---|---|
| pgvector | Jedna baza, transakcje, złączenia z danymi relacyjnymi | Strojenie po Twojej stronie, granica przy bardzo dużych zbiorach | Projekt już na PostgreSQL, do kilku milionów fragmentów |
| Pinecone | Skala i operacje po stronie dostawcy | Osobna usługa, koszt rośnie ze zbiorem | Duży ruch bez własnego zespołu bazodanowego |
| Qdrant | Zaawansowane filtrowanie, wysoka wydajność | Kolejna usługa do utrzymania | Złożone filtry przy dużym wolumenie |
| Chroma | Najprostszy start, ta sama ścieżka do chmury | Mniej dojrzałe operacyjnie | Prototyp i wczesna produkcja |
Największą przewagą rozszerzenia jest coś, czego żadna osobna baza nie da: możliwość złączenia wyników wyszukiwania z danymi relacyjnymi w jednym zapytaniu. Pobranie fragmentów razem z autorem, statusem dokumentu i uprawnieniami to jedno zapytanie, a nie wyszukiwanie w jednym systemie i dobieranie danych w drugim.
Granica przebiega przy skali i częstotliwości zapisu. Kilka milionów wektorów przy umiarkowanym ruchu to obszar, w którym rozszerzenie radzi sobie dobrze. Dziesiątki milionów wektorów z ciągłym dopisywaniem to moment, w którym wyspecjalizowana baza zaczyna wygrywać.
Druga granica dotyczy tego, kto to utrzymuje. Rozszerzenie oddaje w Twoje ręce wszystkie pokrętła, ale też cały obowiązek ich ustawienia: dobór parametrów indeksu, pamięć, monitorowanie planu zapytań. Usługa zarządzana bierze to na siebie w zamian za abonament i mniejszą kontrolę. Zespół, który ma administratora bazy albo kogoś, kto lubi zaglądać w plany zapytań, zyska na rozszerzeniu więcej niż zespół, który wolałby o bazie nie myśleć.
Trzecia rzecz to koszt. Rozszerzenie jest darmowe, więc płacisz wyłącznie za maszynę, na której i tak stoi baza. Przy zbiorach rzędu setek tysięcy fragmentów różnica wobec usługi rozliczanej za wektory bywa wielokrotna i to często ona przesądza o wyborze na wczesnym etapie projektu.
Pełny przepływ w aplikacji
Warto zobaczyć, jak to wygląda od strony kodu, bo wtedy widać, że rozszerzenie nie wymaga żadnej osobnej warstwy dostępu.
import { sql } from './db'
export async function znajdzFragmenty(pytanie: string, jezyk: string) {
const wektor = await policzOsadzenie(pytanie)
return sql`
SELECT f.tresc, d.tytul, d.url
FROM fragmenty f
JOIN dokumenty d ON d.id = f.dokument_id
WHERE f.jezyk = ${jezyk} AND d.opublikowany = true
ORDER BY f.osadzenie <=> ${JSON.stringify(wektor)}::vector
LIMIT 5
`
}Zwróć uwagę na złączenie i warunek publikacji. Osobna baza wektorowa zwróciłaby identyfikatory, po których trzeba by dopiero pobrać tytuły i sprawdzić status w drugim systemie, z ryzykiem, że dokument wycofano, a indeks o tym nie wie. Tutaj jedno zapytanie odpowiada na wszystko naraz i widzi ten sam stan danych.
Indeksowanie nowego dokumentu również mieści się w transakcji. Zapisujesz dokument, tniesz go na fragmenty, liczysz osadzenia i wstawiasz je jednym poleceniem. Jeśli cokolwiek zawiedzie, nie zostaje połowa fragmentów bez dokumentu ani dokument bez fragmentów.
Ta spójność jest głównym argumentem praktycznym za rozszerzeniem. W układzie z osobną bazą wektorową trzeba samodzielnie pilnować, żeby oba systemy opisywały ten sam stan, a każda awaria w połowie operacji zostawia je w rozjeździe, który trzeba potem naprawiać skryptem.
Typowe błędy
Pierwszy to brak indeksu na produkcji. Zapytanie działa, więc nikt nie sprawdza planu, a baza po cichu przegląda całą tabelę przy każdym pytaniu.
Drugi to porównywanie wektorów z różnych modeli. Nic tego nie wykryje, a wyniki są losowe. Zapisuj nazwę modelu w kolumnie obok osadzenia, wtedy pomyłka wychodzi od razu.
Trzeci to niedopasowana klasa operatorów w indeksie. Indeks zbudowany dla odległości euklidesowej nie zostanie użyty przy zapytaniu kosinusowym, a baza cicho wróci do przeglądania tabeli.
Czwarty to zbyt niska pamięć robocza przy budowaniu indeksu. Operacja kończy się poprawnie, tylko trwa wielokrotnie dłużej i produkuje gorszy graf.
Piąty to ignorowanie autoodkurzania. Tabela z częstymi aktualizacjami osadzeń puchnie od martwych wierszy, co zwiększa i rozmiar, i czas zapytań.
Szósty to trzymanie tekstu fragmentu wyłącznie w innej usłudze. Skoro baza i tak przechowuje wektor, trzymanie obok treści i metadanych upraszcza wszystko i usuwa ryzyko rozjechania się dwóch źródeł.
FAQ
Czy pgvector wystarczy zamiast osobnej bazy wektorowej?
W większości projektów tak. Do kilku milionów fragmentów przy umiarkowanym ruchu rozszerzenie daje porównywalne czasy zapytań, a oszczędza całą osobną usługę. Osobna baza zaczyna się opłacać przy dziesiątkach milionów wektorów albo bardzo wysokiej częstotliwości zapisu.
HNSW czy IVFFlat?
Domyślnie HNSW, bo daje wyższą trafność przy tym samym czasie zapytania i nie wymaga danych w tabeli przed utworzeniem. IVFFlat wybierzesz przy ograniczonej pamięci albo gdy czas budowania indeksu jest krytyczny.
Jak dobrać wielkość fragmentu?
Podobnie jak przy każdej bazie wektorowej: pięćset do tysiąca znaków z niewielkim zachodzeniem, cięte po nagłówkach i akapitach, a nie co równą liczbę znaków. Reguła nie zależy od tego, gdzie trzymasz wektory, tylko od tego, jak model czyta kontekst.
Czy działa z Supabase?
Tak, rozszerzenie jest dostępne w Supabase i włącza się jednym poleceniem. To zresztą najczęstszy sposób, w jaki zespoły trafiają na pgvector, bo baza już tam jest i nie trzeba dokładać usługi.
Skąd wziąć osadzenia?
Z modelu dostawcy chmurowego albo z modelu lokalnego uruchomionego przez Ollamę. Rozszerzenie ich nie liczy, tylko przechowuje i porównuje, więc wybór modelu jest niezależny od bazy i można go zmienić, pamiętając o przeliczeniu wszystkich wierszy.
Dokumentacja i kod stoją w repozytorium na GitHubie, a zmiany w kolejnych wydaniach rozszerzenia opisuje dziennik zmian.