SSD Nodes Learn 🎉 VPS od $5.50/mies.
Przewodniki Matt ConnorAutor: Matt Connor

Własny potok RAG na VPS: konfiguracja krok po kroku

Dowiedz się jak zbudować system RAG na własnym serwerze VPS. Artykuł zawiera schemat pgvector, optymalizację indeksów HNSW oraz gotowe zapytania SQL do obsługi lokalnych embeddingów.

Jak wygląda potok RAG hostowany samodzielnie

Potok RAG (retrieval augmented generation) składa się z pięciu etapów: podziału dokumentów na fragmenty (chunking), tworzenia ich reprezentacji wektorowych (embedding), przechowywania wektorów, wyszukiwania fragmentów najbardziej zbliżonych do pytania oraz przekazania tych fragmentów do modelu językowego, który generuje odpowiedź. Na wynajmowanym serwerze VPS pierwsze cztery etapy działają lokalnie. PostgreSQL z rozszerzeniem pgvector przechowuje wektory, a niewielki model embeddingowy obsługiwany przez Ollama zamienia tekst na postać wektorową. Tylko ostatni etap musi odbywać się poza serwerem.

Ten podział stanowi główny argument niniejszego przewodnika. Podział na fragmenty to standardowe zadanie dla procesora. Embedding to model o 137 milionach parametrów, który zajmuje kilkaset megabajtów pamięci RAM. Przechowywanie danych to tabela w Postgres, której rozmiar można obliczyć matematycznie przed zapisaniem pierwszego wiersza. Dla korpusu liczącego setki tysięcy fragmentów całość działa na zwykłym serwerze VPS. Generowanie odpowiedzi jest inne, ponieważ wiąże się z kosztami przy każdym zapytaniu, bezterminowo.

Które elementy potoku RAG generują koszty

Samouczek RAG typu end-to-end od DigitalOcean wykorzystuje zarządzaną bazę wektorową oraz hostowany model osadzeń (embedding model), a sekcja dotycząca kosztów ma charakter jakościowy: buforuj powtarzające się zapytania, ograniczaj liczbę pobieranych fragmentów, stosuj reranking przed generowaniem. Te rady są poprawne. Pomijają jednak opcję, która zmienia rachunek ekonomiczny: uruchomienie modelu osadzeń na serwerze, za który już płacisz.

Liczy się tokeny, a nie dolary, ponieważ liczba tokenów nie dezaktualizuje się wraz ze zmianą cennika. Przyjmijmy korpus 100 000 fragmentów po 400 tokenów każdy, 10 000 pytań, 8 fragmentów przesyłanych do modelu na każdą odpowiedź, blok pytania i instrukcji o długości 100 tokenów oraz odpowiedzi o długości 400 tokenów.

ChartToken load for a 100,000 chunk corpus and 10,000 questions
The data behind this chart
[
  {
    "label": "Embed the corpus (once)",
    "tokens_millions": 40,
    "tokens_per_question": "4,000"
  },
  {
    "label": "Embed each question",
    "tokens_millions": 0.2,
    "tokens_per_question": "20"
  },
  {
    "label": "Generation input",
    "tokens_millions": 33,
    "tokens_per_question": "3,300"
  },
  {
    "label": "Generation output",
    "tokens_millions": 4,
    "tokens_per_question": "400"
  }
]

Osadzenie całego korpusu to 40 milionów tokenów, co wykonuje się jednorazowo. W przeliczeniu na 10 000 pytań daje to 4,000 tokenów na pytanie. Przy stu tysiącach pytań wartość ta spada do 400. Koszt generowania nie spada nigdy. Wynosi on 3,300 tokenów wejściowych i 400 tokenów wyjściowych przy każdym pytaniu, na które odpowiesz.

Pieniądze podążają więc za etapem, który się powtarza. Przejmij kontrolę nad etapem osadzania, ponieważ płacisz za niego raz, a VPS i tak pracuje. Kupuj etap generowania, ponieważ to tam lepszy model jest wart realnych pieniędzy. Buforowanie ma znaczenie z tego samego powodu: trafienie w cache pozwala pominąć jedyny etap, którego koszt nigdy się nie amortyzuje. Różnica między KV cache a prompt cache decyduje o tym, którą połowę tego procesu można wykorzystać ponownie, a prompt RAG posiada stały blok instrukcji, po którym następuje zmienny blok fragmentów, co jest strukturą odnoszącą największe korzyści.

Dzielenie na fragmenty: dlaczego stały rozmiar z zakładką jest właściwym ustawieniem domyślnym

Fragment (chunk) to jednostka pobierana z bazy, dlatego jego rozmiar determinuje wszystkie kolejne etapy przetwarzania. Musi być na tyle mały, aby jego embedding dotyczył jednego zagadnienia, ponieważ embedding jest pojedynczym punktem w przestrzeni wektorowej. Fragment obejmujący cztery różne tematy znajdzie się pomiędzy nimi i nie będzie blisko żadnego z nich. Jednocześnie musi być na tyle duży, aby samodzielnie odpowiedzieć na pytanie, ponieważ model językowy widzi tylko fragment, a nie cały dokument.

Należy zacząć od 300 słów z zakładką (overlap) wynoszącą 50 słów. Język angielski to w przybliżeniu 1,3 tokena na słowo, więc 300 słów to około 400 tokenów. Zakładka jest niezbędna, ponieważ zdanie przypadające na granicę podziału zostałoby przecięte na pół, przez co żadna z części nie zawierałaby pełnej odpowiedzi.

W dokumentach posiadających strukturę należy w pierwszej kolejności dokonywać podziału zgodnie z nią. Należy dzielić tekst według nagłówków, następnie według akapitów, a zasadę stałego rozmiaru stosować tylko wewnątrz sekcji, które nadal są zbyt długie. Fragment rozpoczynający się w połowie zdania pogarsza jakość odpowiedzi, ponieważ model cytuje dokładnie to, co otrzymał w danych wejściowych.

Nie należy optymalizować procesu dzielenia na fragmenty przed opracowaniem metody jego pomiaru. Stały rozmiar z zakładką jest deterministyczny i tani w ponownym uruchomieniu, co czyni go punktem odniesienia, który można następnie ulepszać. Najpierw należy zbudować mechanizm oceny zapytań, a dopiero potem wprowadzać zmiany, modyfikując tylko jeden parametr na raz.

Osadzanie na tym samym serwerze: koszty pamięci RAM i opóźnienia

curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text

nomic-embed-text posiada 137 milionów parametrów, a jego pobranie w sierpniu 2026 roku wymaga 274 MB. Przed zaprojektowaniem tabeli sprawdź, co zwraca model.

curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model": "nomic-embed-text", "input": "search_document: hello"}' |
  python3 -c 'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'

To polecenie wypisuje 768. Typ kolumny musi dokładnie odpowiadać tej liczbie.

Dwa ustawienia tego modelu często sprawiają problemy.

Prefiks zadania nie jest opcjonalny. Karta modelu Nomic wskazuje, że dane wejściowe „muszą zawierać prefiks instrukcji zadania”. Dokumenty są osadzane z search_document: na początku, a pytania z search_query: . Pominięcie ich nie powoduje błędu: otrzymasz wektory, jakość wyszukiwania spadnie, a w żadnym dzienniku nie pojawi się informacja o przyczynie.

Długie dane wejściowe są cicho ucinane. Punkt końcowy /api/embed przyjmuje pole truncate, którego wartością domyślną jest true, a model w paczce Ollama deklaruje kontekst 2K. Fragment dłuższy niż ten limit jest ucinany i osadzany mimo wszystko, przez co jego końcówka staje się niemożliwa do wyszukania. Podczas testów przesyłaj "truncate": false, aby zbyt duże fragmenty kończyły się błędem zamiast być przetwarzane częściowo.

Grupuj żądania w partie i utrzymuj model w pamięci.

curl -s http://127.0.0.1:11434/api/embed -d '{
  "model": "nomic-embed-text",
  "input": ["search_document: first chunk", "search_document: second chunk"],
  "keep_alive": "30m"
}' > /dev/null

input akceptuje listę, a jedno żądanie zawierające 32 fragmenty jest wydajniejsze niż 32 osobne żądania, ponieważ cykl HTTP i wyszukiwanie modelu odbywają się raz, a nie 32 razy. keep_alive kontroluje, jak długo model pozostaje w pamięci po zakończeniu żądania; wartość domyślna to 5 minut. Po wygaśnięciu tego czasu kolejne żądanie ponownie obciąża system czasem ładowania.

Zmierz dwie kluczowe wartości na własnym serwerze. Zależą one od liczby vCPU, więc żadne opublikowane dane nie będą identyczne z Twoimi.

ollama ps
time curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model":"nomic-embed-text","input":"search_document: ... one real chunk ..."}' > /dev/null

ollama ps wypisuje rozmiar modelu załadowanego do pamięci RAM, którą rezerwujesz na czas określony przez keep_alive. Wynik time podzielony przez rozmiar partii to czas w sekundach na fragment. Pomnóż to przez liczbę fragmentów, aby uzyskać jednorazowy koszt indeksowania. W konfiguracji opartej wyłącznie na CPU, indeksowanie korpusu 100 000 fragmentów może zająć godziny, a nie minuty. Jest to dopuszczalne, ponieważ proces ten wykonuje się raz i może działać w nocy pod kontrolą nice -n 19. Jeśli czas liczony w godzinach jest nieakceptowalny, należy rozważyć, czy wynajęcie GPU się opłaca, co sprowadza się do obliczenia progu rentowności względem tokenów API, a nie do kwestii preferencji.

Jeśli serwer obsługuje już model czatowy, model osadzania jest drugim modelem rezydującym w pamięci, co zwiększa jej zużycie. Uruchamianie Ollama na VPS omawia dobór rozmiaru dla generowania, a zachowanie modelu self-hosted przy wielu użytkownikach opisuje sytuację, gdy kilka osób wysyła zapytania jednocześnie. Model osadzania jest na tyle mały, że może działać obok nich.

Skrypt indeksujący, od początku do końca

W systemie Ubuntu 24.04 zwykłe pip install poza środowiskiem wirtualnym kończy się błędem error: externally-managed-environment, ponieważ systemowy Python jest zarządzany przez apt.

python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvector
import json, urllib.request
import psycopg
from pgvector.psycopg import register_vector
from pgvector import Vector

OLLAMA = "http://127.0.0.1:11434/api/embed"
MODEL = "nomic-embed-text"

def embed(texts, prefix="search_document: "):
    payload = {"model": MODEL,
               "input": [prefix + t for t in texts],
               "truncate": False,
               "keep_alive": "30m"}
    req = urllib.request.Request(OLLAMA, data=json.dumps(payload).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as resp:
        return json.load(resp)["embeddings"]

def split(text, size=300, overlap=50):
    words = text.split()
    step = size - overlap
    return [" ".join(words[i:i + size]) for i in range(0, len(words), step)]

with psycopg.connect("dbname=rag user=rag") as conn:
    register_vector(conn)
    for doc_id, text in documents():          # your loader
        pieces = split(text)
        for start in range(0, len(pieces), 32):
            batch = pieces[start:start + 32]
            vectors = embed(batch)
            with conn.cursor() as cur:
                cur.executemany(
                    "INSERT INTO chunks (doc_id, seq, body, embedding)"
                    " VALUES (%s, %s, %s, %s)",
                    [(doc_id, start + i, body, Vector(vec))
                     for i, (body, vec) in enumerate(zip(batch, vectors))])
        conn.commit()

documents() należy do Ciebie: to dowolny kod, który przeszukuje pliki lub wiersze i zwraca identyfikator dokumentu oraz jego treść. Cała reszta to potok przetwarzania.

Pamięć masowa: schemat pgvector i jego rozmiar

Ubuntu 24.04 dostarcza postgresql-16-pgvector w wersji 0.6.0, która jest starsza niż typ halfvec. Należy użyć oficjalnego repozytorium projektu PostgreSQL, aby uzyskać aktualną wersję.

sudo apt update && sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17 postgresql-17-pgvector

Numer w nazwie pakietu musi być zgodny z główną wersją serwera. Następnie należy utworzyć rolę, bazę danych oraz rozszerzenie.

sudo -u postgres createuser --pwprompt rag
sudo -u postgres createdb --owner rag rag
sudo -u postgres psql -d rag -c 'CREATE EXTENSION vector;'
CREATE TABLE chunks (
  id        bigserial PRIMARY KEY,
  doc_id    text NOT NULL,
  seq       int  NOT NULL,
  body      text NOT NULL,
  embedding vector(768) NOT NULL,
  fts       tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);

CREATE INDEX chunks_fts ON chunks USING gin (fts);

vector(768) musi być zgodny z wyjściem modelu. Wstawienie wektora o 1024 wymiarach do takiej kolumny spowoduje odrzucenie operacji przez Postgres z błędem expected 768 dimensions, not 1024, co jest najbardziej czytelnym komunikatem w całym procesie. Wygenerowana kolumna fts nie generuje kosztów utrzymania i umożliwia późniejsze wyszukiwanie słów kluczowych.

Pamięć masowa wynika z obliczeń arytmetycznych. Dokumentacja pgvector określa vector jako 4 * dimensions + 8 bajtów, a halfvec jako 2 * dimensions + 8. Liczby wymiarów poniżej odpowiadają opublikowanym rozmiarom wyjściowym poszczególnych modeli.

ChartVector column size per 100,000 chunks, by embedding dimension
The data behind this chart
[
  {
    "label": "384 (all-minilm)",
    "bytes_per_vector": "1,544",
    "vector_mib_per_100k": 147,
    "halfvec_mib_per_100k": 74
  },
  {
    "label": "768 (nomic-embed-text)",
    "bytes_per_vector": "3,080",
    "vector_mib_per_100k": 294,
    "halfvec_mib_per_100k": 147
  },
  {
    "label": "1024 (mxbai-embed-large)",
    "bytes_per_vector": "4,104",
    "vector_mib_per_100k": 391,
    "halfvec_mib_per_100k": 196
  },
  {
    "label": "1536 (hosted API model)",
    "bytes_per_vector": "6,152",
    "vector_mib_per_100k": 587,
    "halfvec_mib_per_100k": 294
  }
]

Przy 768 wymiarach każdy wektor zajmuje 3,080 bajtów, więc 100 000 fragmentów zajmuje 294 MiB danych wektorowych. Ten sam korpus osadzony przez model hostowany o 1536 wymiarach wymaga 587 MiB, a indeks nad nim rośnie proporcjonalnie. Zastosowanie połowicznej precyzji zmniejsza oba te parametry o połowę: halfvec(768) przechowuje ten korpus w 147 MiB. Kwestię ewentualnego wpływu na skuteczność wyszukiwania (recall) rozstrzyga poniższe zapytanie oceniające, wykonane w jednym przebiegu.

Powyższe dane dotyczą wyłącznie kolumny wektorowej. Tekst, narzut wiersza i indeksy stanowią dodatkowe obciążenie, dlatego należy mierzyć rzeczywisty rozmiar tabeli.

SELECT pg_size_pretty(pg_total_relation_size('chunks')) AS total,
       pg_size_pretty(pg_relation_size('chunks'))       AS heap,
       count(*) AS n_rows
FROM chunks;

Jeśli wymagane jest to samo rozszerzenie wraz z API i obsługą kont użytkowników, samodzielnie hostowany stos Supabase stanowi gotowe rozwiązanie Postgres z włączonym pgvector, w którym każde zapytanie z tego przewodnika działa bez zmian.

Indeksowanie: istotne ustawienia HNSW

Poniżej kilku tysięcy wierszy należy zrezygnować z indeksowania. Wyszukiwanie dokładne odczytuje każdy wiersz, co przy tej skali jest wystarczająco szybkie i zapewnia pełną precyzję. Indeks należy dodać, gdy skanowanie sekwencyjne przestaje być wydajne. Należy pamiętać o kompromisie: indeks przybliżony zwraca przybliżone wyniki.

SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 3;
CREATE INDEX chunks_embedding ON chunks
  USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

m = 16 oraz ef_construction = 64 to wartości domyślne pgvector. Ich zwiększenie poprawia precyzję, ale wydłuża czas budowania indeksu i zwiększa jego rozmiar. Należy używać vector_cosine_ops z operatorem <=>, chyba że model generuje wektory o jednostkowej długości. Odległość cosinusowa ignoruje długość wektora, w przeciwieństwie do iloczynu skalarnego.

Należy monitorować proces budowania. Gdy graf przekroczy rozmiar maintenance_work_mem, pgvector zgłosi stosowny komunikat:

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.

Nie jest to błąd i proces budowania zostanie ukończony, jednak przejdzie on na znacznie wolniejszą ścieżkę. Należy zwiększyć maintenance_work_mem w sesji, w której budowany jest indeks, pozostawiając ustawienie globalne bez zmian. Parametr ten dotyczy operacji konserwacyjnych, a wysoka wartość globalna może doprowadzić do wyczerpania pamięci RAM serwera. Postęp długotrwałego budowania można śledzić z poziomu drugiej sesji.

SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;

Następnie należy porównać rozmiar gotowego indeksu z dostępną pamięcią RAM.

SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;

Wyszukiwanie HNSW polega na przechodzeniu przez graf, co powoduje odczytywanie rozproszonych stron indeksu zamiast odczytu ciągłego. Indeks, który nie mieści się w pamięci, wymusza odczyty z dysku przy każdym zapytaniu, co skutkuje zauważalnym wzrostem opóźnień. Podstawowa zasada doboru rozmiaru serwera brzmi: suma rozmiaru indeksu oraz obsługiwanych wierszy powinna mieścić się w pamięci RAM. Należy porównać free -m z rozmiarem uzyskanym powyżej.

W czasie wykonywania zapytania, hnsw.ef_search służy do regulacji precyzji; wartość domyślna wynosi 40.

BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;

Wyższa wartość oznacza przeszukanie większej części grafu, co pozwala znaleźć lepsze dopasowania kosztem wyższego opóźnienia. Jest to ustawienie sesyjne, więc można je zwiększyć dla wybranego zapytania bez modyfikowania indeksu.

Jeśli zapytanie nie korzysta z indeksu, informacja ta będzie widoczna w planie wykonania.

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;

Skanowanie sekwencyjne często wynika z ograniczeń pamięci masowej. Wektor o 768 wymiarach zajmuje 3,080 bajtów, co przekracza limit przechowywania wierszy wewnątrz strony Postgres, dlatego wartość ta trafia do tabeli TOAST (zewnętrznego magazynu dla dużych obiektów). Zgodnie z dokumentacją pgvector, optymalizator nie uwzględnia przechowywania zewnętrznego w szacunkach kosztów, co może sprawiać, że skanowanie sekwencyjne wydaje się tańsze niż w rzeczywistości. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; pozwala przechowywać wektory wewnątrz strony. Ustawienie to dotyczy wierszy zapisanych po zmianie, dlatego istniejące wiersze wymagają przebudowania tabeli.

Wyszukiwanie: jedno zapytanie, dwa sygnały

Wyszukiwanie wektorowe znajduje tekst o znaczeniu zbliżonym do pytania. Metoda ta słabo radzi sobie z dokładnymi ciągami znaków: numerami części, kodami błędów czy nazwiskami. Wyszukiwanie słów kluczowych działa odwrotnie, a Postgres już je obsługuje. Należy połączyć obie metody w jednym zapytaniu, zamiast uruchamiać drugi system.

Reciprocal rank fusion to najprostszy skuteczny mechanizm łączenia wyników. Każdy wynik otrzymuje 1 / (60 + rank) z każdej listy, na której występuje, a obie wartości są sumowane. Metoda ta nie wymaga normalizacji wyników, ponieważ opiera się na pozycjach, a nie na odległościach.

WITH semantic AS (
  SELECT id, row_number() OVER (ORDER BY distance) AS rank
  FROM (SELECT id, embedding <=> $1 AS distance
        FROM chunks ORDER BY embedding <=> $1 LIMIT 40) s
),
keyword AS (
  SELECT id, row_number() OVER (ORDER BY score DESC) AS rank
  FROM (SELECT c.id, ts_rank_cd(c.fts, q) AS score
        FROM chunks c, websearch_to_tsquery('english', $2) q
        WHERE c.fts @@ q
        ORDER BY score DESC LIMIT 40) k
)
SELECT c.id, c.body,
       coalesce(1.0 / (60 + s.rank), 0) + coalesce(1.0 / (60 + k.rank), 0) AS rrf
FROM (SELECT id FROM semantic UNION SELECT id FROM keyword) u
JOIN chunks c ON c.id = u.id
LEFT JOIN semantic s ON s.id = u.id
LEFT JOIN keyword  k ON k.id = u.id
ORDER BY rrf DESC
LIMIT 8;

$1 to embedding pytania wygenerowany przez ten sam model, utworzony z prefiksem search_query: . $2 to pytanie w formie tekstowej. Obie wartości są przekazywane z poziomu aplikacji. websearch_to_tsquery akceptuje rzeczywiste pytanie użytkownika bez błędów wynikających z interpunkcji, z którymi nie radzi sobie to_tsquery. Należy pamiętać o jeszcze jednej kwestii: dodanie filtra WHERE do skanowania HNSW może zwrócić mniej wierszy niż oczekiwano, ponieważ indeks jest przeszukiwany w pierwszej kolejności, a filtr stosowany później. SET hnsw.iterative_scan = relaxed_order; sprawia, że pgvector kontynuuje skanowanie do momentu uzyskania wystarczającej liczby wierszy.

Jak ocenić skuteczność wyszukiwania?

Jest to krok, który pomija niemal każdy przewodnik po RAG, a jest on jedynym sposobem na sprawdzenie, czy pozostałe wybory przyniosły korzyści. Nie wymaga on frameworka do ewaluacji. Wymaga 30 pytań oraz identyfikatorów fragmentów (chunk), które zawierają odpowiedzi na każde z nich.

Należy przygotować je ręcznie. Należy wybrać pytania, które użytkownicy faktycznie zadają w odniesieniu do tego korpusu, uruchomić każde z nich, przeczytać zwrócone wyniki i zapisać identyfikator fragmentu, który powinien zostać wybrany. 30 pytań nie pozwoli wykryć drobnych różnic. Pozwoli jednak wychwycić różnice istotne, ponieważ są one wyraźne.

CREATE TABLE gold (
  id        bigserial PRIMARY KEY,
  question  text   NOT NULL,
  chunk_id  bigint NOT NULL REFERENCES chunks(id),
  embedding vector(768) NOT NULL
);

Należy wygenerować embedding dla każdego pytania z prefiksem search_query: , zapisać go, a następnie ocenić cały zestaw w jednym zapytaniu.

WITH hits AS (
  SELECT g.id,
         min(r.rank) FILTER (WHERE r.id = g.chunk_id) AS hit_rank
  FROM gold g
  CROSS JOIN LATERAL (
    SELECT top.id, row_number() OVER (ORDER BY top.distance) AS rank
    FROM (SELECT c.id, c.embedding <=> g.embedding AS distance
          FROM chunks c
          ORDER BY c.embedding <=> g.embedding
          LIMIT 10) top
  ) r
  GROUP BY g.id
)
SELECT count(*)         AS questions,
       count(hit_rank)  AS found_in_top_10,
       round(avg(coalesce(1.0 / hit_rank, 0)), 3) AS mrr
FROM hits;

found_in_top_10 podzielone przez questions to recall at 10: jak często poprawna odpowiedź znajdowała się w oknie przekazanym do modelu. MRR (mean reciprocal rank) wylicza średnią z 1 podzielonego przez pozycję właściwego fragmentu i traktuje brak trafienia jako zero, dzięki czemu promuje sytuację, w której odpowiedź znajduje się na pierwszej, a nie na ósmej pozycji. Obie wartości zmieniają się po modyfikacji rozmiaru fragmentu, zmianie modelu embeddingów lub dodaniu wyszukiwania słów kluczowych, co pozwala zaobserwować kierunek tych zmian.

Należy priorytetowo traktować recall at 10, ponieważ generator nie może wykorzystać fragmentu, którego nigdy nie otrzymał. Jeśli recall at 10 wynosi 0.9, a odpowiedzi nadal są błędne, przyczyna leży w prompcie lub w modelu, a nie w wyszukiwaniu. Ten jeden podział pozwala zaoszczędzić dni domysłów.

Indeks należy sprawdzać oddzielnie. Wyszukiwanie przybliżone obniża recall, a pgvector pozwala sprawdzić skalę tego zjawiska: należy uruchomić to samo zapytanie z wyszukiwaniem dokładnym i porównać identyfikatory.

BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

Dziewięć wspólnych identyfikatorów na dziesięć oznacza, że ef_search jest ustawione poprawnie. Cztery na dziesięć oznaczają konieczność zwiększenia tej wartości.

Reranking i generowanie: gdzie API zarabia pieniądze

Reranker to inny rodzaj modelu. Odczytuje on pytanie oraz jeden fragment jednocześnie i ocenia taką parę, co przewyższa porównywanie dwóch niezależnie obliczonych embeddingów, jednak jest zbyt wolny, aby uruchamiać go dla całego korpusu. Właśnie dlatego znajduje się w tym miejscu. Analizuje on 40 kandydatów zwróconych przez wyszukiwanie, a nie 100,000 fragmentów z tabeli, więc hostowane API rerankingu nalicza opłaty za 40 krótkich par na pytanie i odrzuca najgorsze fałszywe trafienia, zanim dotrą one do kosztownego etapu.

Generowanie to powtarzający się koszt, na który wpływają dwa czynniki. Wysyłaj mniej fragmentów, używając wskaźnika recall at 10, aby sprawdzić, jak małą ich liczbę możesz przesłać bez utraty odpowiedzi. Utrzymuj początek promptu stabilny bajt po bajcie, aby cache promptu dostawcy mógł z niego skorzystać, i umieszczaj pobrane fragmenty za tą stabilną częścią. Zapisuj w pamięci podręcznej gotowe odpowiedzi według pytania, ponieważ najtańszy wygenerowany token to ten, który został wygenerowany w zeszłym tygodniu.

Dobór rozmiaru serwera i moment, w którym przestaje on wystarczać

Każda zasada doboru rozmiaru przedstawiona w tym miejscu opiera się na pomiarach, a nie na szacunkach.

  • Pamięć RAM jest głównym ograniczeniem: rozmiar modelu rezydentnego z ollama ps, powiększony o rozmiar indeksu HNSW oraz shared_buffers, z zachowaniem zapasu na połączenia i pamięć podręczną stron (page cache).
  • Dysk wymaga dwukrotności pg_total_relation_size('chunks'), ponieważ przebudowa indeksu wymaga jednoczesnego przechowywania obu kopii.
  • Procesor CPU determinuje czas reindeksacji, obliczany jako zmierzona liczba sekund na fragment pomnożona przez liczbę fragmentów.
  • Reindeksacja występuje częściej, niż można się spodziewać, ponieważ zmiana modelu osadzeń (embedding model) unieważnia każdy już zapisany wektor.

Ten projekt przestaje wystarczać w momencie, który można przewidzieć. Gdy indeks HNSW przestaje mieścić się w dostępnej pamięci RAM, opóźnienia zapytań wynikają z operacji odczytu z dysku i żadne ustawienie tego nie naprawi. Gdy jedna tabela obsługuje wielu najemców, a każde zapytanie jest filtrowane według najemcy, rozwiązaniem staje się partycjonowanie tabeli, co stanowi poważne zadanie. Gdy operacje zapisu indeksu i zapytania użytkowników rywalizują o zasoby tego samego serwera, należy przenieść proces roboczy osadzeń (embedding worker) na drugi serwer, zanim nastąpi przeniesienie bazy danych. Dopóki żadna z tych sytuacji nie wystąpi, Postgres z pgvector na wynajmowanym VPS jest rozwiązaniem produkcyjnym, a powyższe liczby wskazują, jak blisko znajduje się limit wydajności.

FAQ

Czy mogę uruchomić potok RAG na jednym VPS, czy potrzebuję bazy wektorowej?

Jeden VPS wystarczy dla korpusów liczących setki tysięcy fragmentów. Przy 768 wymiarach, 100 000 fragmentów zajmuje 294 MiB danych wektorowych plus tekst i indeks HNSW, co mieści się w pamięci RAM standardowego planu. Ograniczeniem jest pamięć, a nie liczba wierszy, ponieważ wyszukiwanie HNSW skacze po indeksie, więc opóźnienia rosną, gdy indeks przestaje mieścić się w RAM. Porównaj pg_relation_size dla indeksu z free -m, aby określić bieżący stan.

Czy potrzebuję GPU do osadzania (embeddingu) dokumentów?

Nie, jeśli osadzasz dane jednorazowo, a następnie wykonujesz zapytania. Model o 137 milionach parametrów, taki jak nomic-embed-text, działa na CPU, a pełne przetworzenie dużego korpusu zajmuje godziny, które można zaplanować na noc. GPU staje się istotne, gdy dokumenty napływają w sposób ciągły lub gdy chcesz uruchomić generowanie na tej samej maszynie. Zmierz czas jednej partii za pomocą /api/embed na własnym serwerze i pomnóż przez liczbę fragmentów, ponieważ liczba vCPU różni się zbyt znacząco, aby opublikowane dane były miarodajne.

Dlaczego moje zapytanie wektorowe używa skanowania sekwencyjnego zamiast indeksu HNSW?

Odczytaj plan za pomocą EXPLAIN (ANALYZE, BUFFERS). Częstą przyczyną jest przechowywanie: pgvector zauważa, że optymalizator nie uwzględnia przechowywania poza wierszem (out-of-line storage) w swoich szacunkach kosztów, co sprawia, że skanowanie sekwencyjne wydaje się tańsze niż w rzeczywistości, a wektor o 768 wymiarach ma 3,080 bajtów, więc domyślnie trafia do tabeli TOAST. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; utrzymuje nowe wiersze wewnątrz wiersza (inline). Dwie pozostałe przyczyny to operator, który nie pasuje do indeksu, ponieważ indeks zbudowany za pomocą vector_cosine_ops jest używany tylko przez <=>, oraz zapytanie bez ORDER BY ... LIMIT, ponieważ indeks przybliżony obsługuje tylko zapytania o uporządkowanych najbliższych sąsiadach.

Skąd mam wiedzieć, czy moje wyszukiwanie działa poprawnie?

Stwórz złoty zestaw 30 pytań, z których każde jest powiązane z identyfikatorem fragmentu zawierającego odpowiedź, i przechowuj osadzenia pytań wraz z nimi. Następnie zmierz wskaźnik recall at 10, czyli częstotliwość, z jaką właściwy fragment pojawia się w pierwszej dziesiątce wyników, oraz MRR, który premiuje umieszczenie go na pierwszym miejscu. Te dwie liczby informują, czy zmiana rozmiaru fragmentu, modelu osadzania lub rank fusion przyniosła poprawę. Bez nich zmieniasz ustawienia, polegając jedynie na subiektywnym wrażeniu po sprawdzeniu kilku odpowiedzi.