~/ / Artykuły o AI / Lokalne LLM na VPS / Przechowywanie i wyszukiwanie embeddingów Ollamy w PostgreSQL z pgvector

Przechowywanie i wyszukiwanie embeddingów Ollamy w PostgreSQL z pgvector

Embeddingi z /api/embed Ollamy są przydatne do wyszukiwania dopiero wtedy, gdy zostaną gdzieś zapisane. pgvector to rozszerzenie open source, które dodaje do PostgreSQL wyszukiwanie wektorów według podobieństwa, dzięki czemu wektory mogą leżeć w tej samej bazie, na tym samym serwerze, co teksty i pozostałe dane. W tym artykule opisujemy instalację, tworzenie tabeli, wybór odległości i indeksu oraz ustawienia, które zmieniają wyniki, na podstawie README pgvector dla wersji 0.8.7. Następnie przekazujemy wyniki wyszukiwania do /api/chat Ollamy, aby odpowiedź wskazywała swoje źródła, i sprawdzamy te cytowania. Uruchomiliśmy SQL na pgvector 0.8.7 z PostgreSQL 16, używając prawdziwych wektorów z Ollamy, a żądania czatu na Ollamie 0.40.11 z qwen2.5:7b. Cztery narzędzia na tej stronie piszą SQL i żądanie oraz sprawdzają odpowiedź w Twojej przeglądarce.

Opublikowano: · 9 min czytania · Fakty sprawdzono w README i kodzie źródłowym pgvector w wersji 0.8.7 oraz w dokumentacji i kodzie źródłowym Ollamy (0.40.11) dnia 2026-11-05. Zanim na nich polegasz, sprawdź aktualną dokumentację.

Co pgvector dodaje do PostgreSQL

README pgvector opisuje go jako open source'owe wyszukiwanie wektorów według podobieństwa dla Postgresa. Obsługuje dokładne i przybliżone wyszukiwanie najbliższych sąsiadów; wektory o pojedynczej precyzji, o połowicznej precyzji, binarne i rzadkie; oraz odległość L2, iloczyn skalarny, odległość cosinusową, odległość L1, odległość Hamminga i odległość Jaccarda. Ponieważ wektory są zwykłymi kolumnami PostgreSQL, zyskują też zgodność z ACID, odzyskiwanie do punktu w czasie (point-in-time recovery) i JOIN-y.

Aby przeszukiwać dokumenty, zapisujesz po jednym wierszu na fragment, z jego tekstem i wektorem, tworzysz embedding pytania tym samym modelem i sortujesz wiersze według odległości od wektora pytania.

Instalacja

pgvector obsługuje PostgreSQL 13 i nowszy. README podaje kilka sposobów instalacji, między innymi te:

SposóbCo mówi README
Kompilacja ze źródełgit clone --branch v0.8.7, potem make i make install (może być potrzebne sudo)
DockerObraz pgvector/pgvector dodaje pgvector do obrazu Postgresa. Tagi takie jak pg18-trixie czy 0.8.7-pg16 wybierają wersję Postgresa.
APT (Debian, Ubuntu)sudo apt install postgresql-18-pgvector z repozytorium PostgreSQL APT, z 18 zastąpionym Twoją wersją Postgresa
Homebrewbrew install pgvector, co dodaje go tylko do formuł postgresql@18 i postgresql@17

Potem włącz rozszerzenie jednorazowo w każdej bazie, w której chcesz go używać, poleceniem CREATE EXTENSION vector;. Na potrzeby tego artykułu użyliśmy obrazu Dockera pgvector/pgvector:0.8.7-pg16.

Utworzenie tabeli

Kolumna typu vector(n) przechowuje wektory o dokładnie n wymiarach, a n musi być równe długości wektorów z Twojego modelu embeddingów. Wektor o innej długości zostaje odrzucony: gdy wstawiliśmy wektor o 2 wymiarach do kolumny vector(3), PostgreSQL zwrócił "expected 3 dimensions, not 2". all-minilm zwraca 384 wymiary, więc jego kolumna to vector(384).

README podaje, że każdy wektor zajmuje 4 × wymiary + 8 bajtów, przy czym każdy element jest liczbą zmiennoprzecinkową o pojedynczej precyzji, i dopuszcza do 16,000 wymiarów w kolumnie. Indeks obsługuje mniej wymiarów, jak pokazano niżej. Typ halfvec przechowuje wartości o połowicznej precyzji w 2 × wymiary + 8 bajtów.

Wymiaryvectorhalfvec
3841,544 bajtów776 bajtów
7683,080 bajtów1,544 bajtów
10244,104 bajtów2,056 bajtów

W naszej tabeli testowej pg_column_size dało 1,544 bajtów dla vector(384) i 776 bajtów dla halfvec(384), zgodnie ze wzorem. To dotyczy samego wektora; kolumna tekstowa i indeksy zajmują dodatkowe miejsce.

Wczytanie wektorów z Ollamy

pgvector odczytuje wektor jako tekst w nawiasach kwadratowych, na przykład '[1,2,3]', a README mówi, że wszystkie elementy muszą być skończone (bez NaN, Infinity ani -Infinity). Każda tablica w polu embeddings odpowiedzi /api/embed ma już taką postać, a odpowiedź zachowuje kolejność danych wejściowych, więc pierwszy tekst odpowiada pierwszemu wektorowi.

README pokazuje INSERT dla kilku wierszy, INSERT … ON CONFLICT dla upsertów i COPY dla ładowania zbiorczego. Konwerter poniżej łączy Twoje teksty z odpowiedzią i pisze polecenia INSERT z poprawnie zapisanymi cudzysłowami. Uruchomiliśmy jego wynik z tekstami zawierającymi cudzysłowy, ukośniki wsteczne, podziały wierszy i emoji, a wróciły one bez zmian.

Wypróbuj: Embeddings to pgvector INSERT

Przykład używa zmyślonych wektorów o długości 1 i 4 wymiarach, aby dało się je przeczytać. Prawdziwe embeddingi mają setki lub tysiące wymiarów.

3 wierszy z 4 wymiarami. Teksty odczytano jako treść żądania /api/embed, wektory jako odpowiedź Ollama /api/embed.

Polecenia INSERT

Uruchom je w psql lub innym kliencie. Wiersze są wstawiane w jednej transakcji.

BEGIN;

INSERT INTO documents (content, embedding) VALUES
  ('Embeddings turn text into numeric vectors.', '[0.620186,0.410123,0.550165,0.380114]'),
  ('pgvector stores vectors in PostgreSQL, next to the rest of your data.', '[0.578815,0.469039,0.518937,0.419142]'),
  ('Ollama''s /api/embed returns one vector per input, in the same order.', '[0.119982,0.809879,-0.329951,0.46993]');

COMMIT;

Format wektora z README pgvector, sprawdzony na pgvector 0.8.7 dnia 2026-11-04. pgvector README

Wszystko dzieje się w Twojej przeglądarce. Narzędzie pisze SQL i z niczym się nie łączy.

Wybór odległości

Dokumentacja Ollamy zaleca podobieństwo cosinusowe w większości zastosowań wyszukiwania semantycznego i mówi, że /api/embed zwraca wektory o długości 1. Dla takich wektorów odległość cosinusowa, odległość L2 i iloczyn skalarny ustawiają wiersze w tej samej kolejności, ponieważ kwadrat odległości L2 to 2 minus 2 razy podobieństwo cosinusowe. W naszym teście z all-minilm trzy pierwsze wiersze były takie same dla wszystkich trzech, a odległość L2 równa 0.931 odpowiadała podobieństwu cosinusowemu 0.566.

Każda odległość ma własny operator. Wyszukiwanie sortuje według operatora i bierze pierwsze wiersze za pomocą LIMIT, a indeks jest budowany dla jednej klasy operatorów.

OperatorZwracaKlasa operatorów indeksu
<=>Odległość cosinusowa. Podobieństwo cosinusowe to 1 - (a <=> b).vector_cosine_ops
<->Odległość L2 (euklidesowa)vector_l2_ops
<#>Ujemny iloczyn skalarny. Pomnóż przez -1, aby uzyskać iloczyn skalarny.vector_ip_ops
<+>Odległość L1vector_l1_ops

README wyjaśnia, że <#> zwraca ujemny iloczyn skalarny, ponieważ Postgres obsługuje dla operatorów tylko skanowanie indeksu w kolejności ASC. Dla halfvec klasy operatorów to halfvec_cosine_ops, halfvec_l2_ops i tak dalej.

Wyszukiwanie dokładne czy indeks

Bez indeksu pgvector wykonuje dokładne wyszukiwanie najbliższych sąsiadów, które według README zapewnia doskonały recall. Przybliżony indeks wymienia część recallu na szybkość, a README ostrzega, że po dodaniu indeksu zapytania mogą zwracać inne wyniki.

Indeks jest używany tylko wtedy, gdy zapytanie ma ORDER BY i LIMIT, a ORDER BY jest samym operatorem odległości w kolejności rosnącej. ORDER BY embedding <=> $1 LIMIT 5 może użyć indeksu; ORDER BY 1 - (embedding <=> $1) DESC nie może. README zaznacza też, że skan całej tabeli może być szybszy, gdy tabela jest mała.

CechaHNSWIVFFlat
Jak działaWielowarstwowy grafDzieli wektory na listy i przeszukuje listy najbliższe zapytaniu
W porównaniu z drugimLepszy kompromis między szybkością a recallem, ale wolniej się buduje i zużywa więcej pamięciSzybciej się buduje i zużywa mniej pamięci, ale ma gorszy kompromis między szybkością a recallem
Kiedy go utworzyćMożna go utworzyć na pustej tabeli (bez etapu trenowania)Gdy tabela zawiera już trochę danych
Opcje budowaniam (domyślnie 16) i ef_construction (domyślnie 64)lists: zacznij od rows / 1000 dla do 1M wierszy, a powyżej tego od sqrt(rows)
Opcja wyszukiwaniahnsw.ef_search, domyślnie 40ivfflat.probes, domyślnie 1; zacznij od sqrt(lists)
Wymiaryvector do 2,000, halfvec do 4,000vector do 2,000, halfvec do 4,000

Na pgvector 0.8.7 potwierdziliśmy też, że kolumna vector(2001) nie może mieć indeksu HNSW, że IVFFlat nie ma klasy operatorów dla odległości L1 oraz że ef_construction musi wynosić co najmniej 2 × m. Dla większej liczby wymiarów FAQ w README wymienia halfvec, kwantyzację binarną i inne możliwości.

Wypróbuj: pgvector SQL Builder

Każdy wektor zajmuje 1,544 bajtów, więc 10,000 wierszy zajmuje około 15.4 MB samych wektorów (bez indeksów i innych kolumn).

1. Rozszerzenie i tabela

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id bigserial PRIMARY KEY,
  content text NOT NULL,
  embedding vector(384) NOT NULL
);

2. Indeks

CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

3. Wyszukiwanie

PREPARE przyjmuje wektor pytania z /api/embed jako $1. Przekaż go jako tekst, na przykład '[0.01,-0.02,…]'.

PREPARE search_documents(vector(384)) AS
  SELECT id, content, 1 - (embedding <=> $1) AS cosine_similarity
  FROM documents
  ORDER BY embedding <=> $1
  LIMIT 5;

-- EXECUTE search_documents('[0.0123, -0.0456, ...]');

Ograniczenia i wartości domyślne z README i kodu źródłowego pgvector 0.8.7, sprawdzone dnia 2026-11-04. pgvector README

Wszystko dzieje się w Twojej przeglądarce. Narzędzie pisze SQL i z niczym się nie łączy.

Dlaczego wyszukiwanie może zwrócić mniej wierszy niż LIMIT

W HNSW wyniki są ograniczone przez hnsw.ef_search, domyślnie 40. Na tabeli testowej z 200 wierszami i indeksem HNSW LIMIT 100 zwrócił 40 wierszy; po SET hnsw.ef_search = 100 zwrócił 100. Ustaw hnsw.ef_search na co najmniej tyle, ile wierszy potrzebujesz. SET LOCAL wewnątrz transakcji zmienia go tylko dla jednego zapytania.

Filtrowanie również zmniejsza wyniki. Przy indeksach przybliżonych, jak mówi README, warunek WHERE jest stosowany po przeskanowaniu indeksu: jeśli warunek pasuje do 10% wierszy, HNSW z domyślnym hnsw.ef_search równym 40 zwraca średnio tylko 4 wiersze. Od wersji 0.8.0 iteracyjne skanowanie indeksu (SET hnsw.iterative_scan = strict_order lub relaxed_order) przeszukuje większą część indeksu, aż znajdzie się dość wierszy, do hnsw.max_scan_tuples (domyślnie 20,000).

README dodaje, że wektory NULL nie są indeksowane, podobnie jak wektory zerowe dla odległości cosinusowej.

Przekaż wyniki do /api/chat z ponumerowanymi źródłami

Gdy wyszukiwanie zwróci najbliższe wiersze, model potrzebuje ich w swoim prompcie. /api/chat Ollamy przyjmuje listę komunikatów, każdy z rolą (system, user, assistant lub tool) i treścią. Prosty układ to komunikat systemowy z zasadami oraz komunikat użytkownika, który wymienia wiersze, każdy z numerem, na przykład [1] i [2], a po nich pytanie. Dzięki numerom odpowiedź może wskazać, z których wierszy skorzystała, a każdy numer można odwzorować z powrotem na jego wiersz.

Aby cytowania miały stały kształt, nadaj polu format schemat JSON Schema z odpowiedzią i listą cytowań, każde z numerem źródła i krótkim cytatem. Strona Ollamy o ustrukturyzowanych wyjściach zaleca, by zapisać schemat także w prompcie i obniżyć temperaturę (temperature), na przykład do 0, aby wynik był bardziej deterministyczny. W psql funkcja json_agg zbiera wiersze zapytania w tablicę JSON, którą narzędzie poniżej odczytuje bezpośrednio.

Wypróbuj: RAG Prompt Builder

Przykładowe źródła są napisane ręcznie w kształcie, jaki zwraca json_agg.

Liczba źródeł: 3. Rozpoznany format: tablica JSON z obiektami.

Polecenie curl

Wysyła żądanie do Ollamy na tym komputerze (localhost:11434).

curl http://localhost:11434/api/chat -d '{
  "model": "qwen2.5:7b",
  "messages": [
    {
      "role": "system",
      "content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
    },
    {
      "role": "user",
      "content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
    }
  ],
  "stream": false,
  "format": {
    "type": "object",
    "properties": {
      "answer": {
        "type": "string"
      },
      "citations": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "source": {
              "type": "integer"
            },
            "quote": {
              "type": "string"
            }
          },
          "required": [
            "source",
            "quote"
          ]
        }
      }
    },
    "required": [
      "answer",
      "citations"
    ]
  },
  "options": {
    "temperature": 0
  },
  "truncate": false
}'

Treść żądania (JSON)

{
  "model": "qwen2.5:7b",
  "messages": [
    {
      "role": "system",
      "content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
    },
    {
      "role": "user",
      "content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
    }
  ],
  "stream": false,
  "format": {
    "type": "object",
    "properties": {
      "answer": {
        "type": "string"
      },
      "citations": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "source": {
              "type": "integer"
            },
            "quote": {
              "type": "string"
            }
          },
          "required": [
            "source",
            "quote"
          ]
        }
      }
    },
    "required": [
      "answer",
      "citations"
    ]
  },
  "options": {
    "temperature": 0
  },
  "truncate": false
}

W odpowiedzi odpowiedź modelu jest w message.content. Jeśli prompt_eval_count jest znacznie niższe, niż się spodziewasz, prompt mógł zostać ucięty.

Pola żądania z dokumentacji API i kodu źródłowego Ollamy, sprawdzone na Ollamie 0.40.11 dnia 2026-11-05. Ollama API: /api/chat

Wszystko dzieje się w twojej przeglądarce. Narzędzie pisze żądanie i z niczym się nie łączy.

Gdy źródła się nie mieszczą, Ollama ucina początek promptu

Zasady, wiersze i pytanie muszą zmieścić się w długości kontekstu. Strona Ollamy o długości kontekstu podaje domyślnie 4k tokenów przy mniej niż 24 GiB VRAM, a jej FAQ podaje 4096 tokenów; ollama ps na naszej maszynie testowej pokazało 4096. Zmienia to num_ctx w options albo zmienna środowiskowa OLLAMA_CONTEXT_LENGTH.

Gdy prompt jest dłuższy, /api/chat domyślnie nie zwraca błędu. W kodzie źródłowym Ollamy (0.40.11) serwer najpierw odrzuca całe wcześniejsze komunikaty, zachowując komunikaty systemowe i ostatni komunikat. Jeśli pozostały prompt nadal jest za długi, zachowuje kilka pierwszych tokenów (num_keep, domyślnie 4) oraz koniec promptu, a środkową część odrzuca. W naszym teście z qwen2.5:7b i num_ctx 1024 prompt o 2,068 tokenach został obcięty do 514. Komunikat systemowy i pierwsze źródła zostały utracone: gdy odpowiedź była w źródle [1], model odpowiedział słowem kodowym, którego nie było w żadnym źródle, a gdy odpowiedź była w ostatnim źródle, odpowiedział poprawnie, ale bez numeru źródła, o który prosił komunikat systemowy. Odpowiedź nie zawierała błędu; w logu serwera było ostrzeżenie "truncating input prompt".

Kod źródłowy Ollamy ma też pole truncate dla żądań czatu, którego nie ma na stronie /api/chat. Z "truncate": false to samo żądanie zwróciło HTTP 400 z błędem "request (2068 tokens) exceeds the available context size (1024 tokens), try increasing it". Pole prompt_eval_count w odpowiedzi to liczba tokenów promptu; w teście z obcięciem wynosiło 514, a nie 2,068.

Ponieważ koniec promptu jest zachowywany, umieszczaj pytanie po źródłach. Aby dowiedzieć się, kiedy źródła się nie mieszczą, wyślij "truncate": false, a potem zwiększ num_ctx albo wyślij mniej wierszy. Większe num_ctx wymaga więcej pamięci.

Sprawdź cytowania

Cytowanie można sprawdzić mechanicznie: numer musi wskazywać wysłane źródło, a cytat powinien występować w tym źródle. W naszym teście z czterema źródłami i qwen2.5:7b cytaty w odpowiedziach JSON zgadzały się ze źródłami słowo w słowo. To nie dowodzi, że odpowiedź jest poprawna: zapytany o rozmiar wektora o 384 wymiarach model zacytował właściwą regułę (4 razy liczba wymiarów plus 8 bajtów) i mimo to odpowiedział 1552 bajty zamiast 1,544.

Patrz też na odpowiedzi bez cytowań. Gdy źródła nie zawierały odpowiedzi, model raz odpowiedział z własnej wiedzy bez cytowań, a raz powiedział, że nie wie, ale i tak powołał się na niezwiązane źródło. Jeśli done_reason w odpowiedzi to "length", odpowiedź została ucięta przez num_predict i może jej brakować cytowań.

Poniższy sprawdzacz porównuje tylko tekst. To, czy źródło naprawdę potwierdza zdanie, nadal musi ocenić człowiek.

Wypróbuj: RAG Citation Checker

Przykładowa odpowiedź jest napisana ręcznie, aby pokazać cytat, którego nie ma w jego źródle.

Liczba źródeł: 4 (tablica JSON ze stringami). Odpowiedź rozpoznana jako: odpowiedź /api/chat Ollamy; postać: JSON z cytowaniami.

Tokeny w prompcie (prompt_eval_count): 306

  • Cytatu z cytowania #2 nie ma w źródle [4].

Cytowania

ŹródłoCytatWynik
[2]hnsw.ef_search is 40 by defaultZnaleziono dokładnie
[4]4 bytes per dimension plus 8Nie ma w tym źródle

To sprawdza tylko, czy numery i cytaty zgadzają się ze źródłami. Nie powie, czy odpowiedź jest prawidłowa ani czy źródło naprawdę potwierdza zdanie.

Format odpowiedzi z dokumentacji API i kodu źródłowego Ollamy, sprawdzony na Ollamie 0.40.11 dnia 2026-11-05. Ollama API: /api/chat

Wszystko dzieje się w twojej przeglądarce. Narzędzie z niczym się nie łączy.

Pamięć na małym serwerze

README mówi, że indeks HNSW buduje się znacznie szybciej, gdy graf mieści się w maintenance_work_mem, pokazuje komunikat, gdy już się nie mieści, i ostrzega, by nie ustawiać maintenance_work_mem tak wysoko, że wyczerpie pamięć serwera. Podobnie jak w przypadku innych indeksów, szybciej jest utworzyć indeks po wczytaniu początkowych danych. Indeksy nie muszą mieścić się w pamięci, ale prawdopodobnie osiągną lepszą wydajność, jeśli się mieszczą; SELECT pg_size_pretty(pg_relation_size('index_name')) pokazuje rozmiar indeksu.

Aby uzyskać mniejszy zbiór roboczy i mniejsze indeksy, README sugeruje halfvec, indeksowanie o połowicznej precyzji albo kwantyzację binarną. Jeśli Ollama działa na tym samym serwerze, PostgreSQL i modele dzielą tę samą pamięć; nasz artykuł o obserwowaniu pamięci Ollamy wyjaśnia, od czego zależy, ile pamięci zużywają modele.

Źródła

Czytaj dalej