SSD Nodes Learn Hosting plans →
Przewodniki Matt ConnorAutor: Matt Connor

Który model embeddingów Ollama do tekstów po polsku

nomic-embed-text ma na karcie tylko tag English. Porównaj bge-m3, qwen3-embedding i inne modele Ollama testem na polskiej odmianie i słowach bez ogonków, zanim zbudujesz indeks.

Który model embeddingów Ollama wybrać do polskiego tekstu

Który model embeddingów Ollama wybrać do tekstów po polsku? Na start weź bge-m3. Ollama pisze, że ten model obsługuje „more than 100 working languages” i przyjmuje wejście do 8192 tokenów. Drugi kandydat to qwen3-embedding:0.6b. Ma mniejszy plik i dłuższe okno kontekstu. Żadna z tych stron nie wymienia jednak polskiego z nazwy. Dlatego o wyborze decyduje krótki test na Twoich własnych dokumentach. Opisuję go niżej.

Embedding to wektor liczb, który opisuje znaczenie fragmentu tekstu. Wyszukiwanie semantyczne porównuje te wektory zamiast słów. Tak samo działa RAG (retrieval-augmented generation, czyli generowanie odpowiedzi na podstawie znalezionych fragmentów). Jeśli model słabo rozumie polski, paragraf o karze umownej i paragraf o terminie płatności faktury mogą dostać podobne wektory. Wtedy nawet dobry model językowy, na przykład Bielik, dostaje w kontekście złe fragmenty. Odpowiada wtedy na ich podstawie.

Stan faktów: strony modeli w bibliotece Ollama, karty modeli na Hugging Face i dokumentację sprawdziłem 24 października 2026. Najnowsze wydanie Ollama w tym dniu to v0.40.10, opublikowane 23 października 2026. Nie podaję tu wyników pomiarów, bo w tym tekście nie mierzyłem żadnego modelu. Wymiary wektorów i wyniki podobieństwa odczytasz sam, na swojej wersji Ollama i swoim serwerze.

Dlaczego nomic-embed-text z przewodnika RAG nie pasuje do polskiego

W przewodniku po własnym potoku RAG na VPS embeddingi liczy nomic-embed-text. W bibliotece Ollama ten model ma tag v1.5. Jego karta nomic-ai/nomic-embed-text-v1.5 na Hugging Face ma tylko jeden tag językowy: English. Karta nie mówi, że polski nie zadziała. Nie obiecuje też, że zadziała. Model, którego karta wymienia tylko angielski, to zły punkt wyjścia dla umów, regulaminów, faktur i pism z urzędu po polsku. Dotyczy to także sytuacji, w której wyniki na kilku prostych zdaniach wyglądają rozsądnie.

Druga połowa wyszukiwania hybrydowego, czyli wyszukiwanie po słowach kluczowych, ma osobny problem. Jeśli kolumnę tsvector budujesz przez to_tsvector('english', ...), PostgreSQL przepuszcza polskie słowa przez angielski stemmer Snowball. Stemmer to algorytm, który sprowadza odmienione formy słowa do wspólnego rdzenia. Dokumentacja PostgreSQL mówi, że słownik Snowball wymaga parametru language, który wybiera stemmer. Każdy algorytm rozumie odmianę słów „within its language”. Angielskie reguły nie znają polskich końcówek, więc nie możesz liczyć na to, że umowa, umowy i umowie trafią do jednego rdzenia. Wracam do tego w sekcji o pg_ts_config.

Jakie wielojęzyczne modele embeddingów ma Ollama

Poniżej opisuję modele z biblioteki Ollama, które deklarują wiele języków. Każda liczba pochodzi ze strony modelu w bibliotece Ollama albo z karty modelu na Hugging Face. Liczby wymiarów pochodzą z kart, a nie z Ollama. Dlatego i tak wypisz je u siebie. Pokazuję, jak to zrobić, w następnej sekcji.

  • bge-m3: 567m parametrów, architektura XLM-RoBERTa, według Ollama „more than 100 working languages”. Karta BAAI/bge-m3 podaje licencję MIT, wektor gęsty o 1024 wymiarach i trzy tryby wyszukiwania: dense, multi-vector oraz sparse. Ani strona Ollama, ani karta nie wymieniają polskiego z nazwy.
  • qwen3-embedding:0.6b: Ollama pisze, że seria obsługuje „over 100 languages”. Karta Qwen/Qwen3-Embedding-0.6B podaje do 1024 wymiarów (do wyboru od 32 do 1024) i licencję Apache-2.0. Zapytanie wymaga jednozdaniowej instrukcji w formacie Instruct: ... i Query:. Dokument jej nie potrzebuje.
  • nomic-embed-text-v2-moe: MoE (mixture of experts, model złożony z ekspertów), 475M parametrów, z czego 305M jest aktywnych podczas liczenia. Ollama pisze „Supports ~100 languages”. Wymiar można zmniejszyć z 768 do 256. Strona każe dodawać prefiksy search_query: i search_document:.
  • embeddinggemma: model od Google z 300M parametrów, „trained with data in 100+ spoken languages”. Karta google/embeddinggemma-300m podaje 768 wymiarów (opcjonalnie 512, 256 lub 128) i licencję Gemma, którą trzeba zaakceptować. Karta zaleca prefiks task: search result | query: dla zapytań i title: none | text: dla dokumentów. Nie sprawdziłem, czy wersja w Ollama dodaje je sama.
  • granite-embedding:278m: Ollama wymienia dla tego modelu dwanaście języków: English, German, Spanish, French, Japanese, Portuguese, Arabic, Czech, Italian, Korean, Dutch i Chinese (Simplified). Polskiego na tej liście nie ma. Mniejszy wariant granite-embedding:30m Ollama opisuje jako „available in English only”.
WykresRozmiar modelu do pobrania według biblioteki Ollama, w MB
Dane, na których opiera się ten wykres
[
  {
    "label": "nomic-embed-text (v1.5)",
    "rozmiar_mb": 274
  },
  {
    "label": "bge-m3",
    "rozmiar_mb": 1200
  },
  {
    "label": "qwen3-embedding:0.6b",
    "rozmiar_mb": 639
  },
  {
    "label": "nomic-embed-text-v2-moe",
    "rozmiar_mb": 958
  },
  {
    "label": "embeddinggemma",
    "rozmiar_mb": 622
  },
  {
    "label": "granite-embedding:278m",
    "rozmiar_mb": 563
  }
]

bge-m3 jest największy w tej grupie. Strona podaje 1.2GB, czyli około 1200 MB. qwen3-embedding:0.6b zajmuje 639 MB. Ile pamięci RAM model zajmuje po załadowaniu, pokaże ollama ps po pierwszym zapytaniu.

WykresOkno kontekstu według biblioteki Ollama, w tokenach (strony podają 2K, 8K, 32K lub 512)
Dane, na których opiera się ten wykres
[
  {
    "label": "nomic-embed-text (v1.5)",
    "kontekst_tokenow": 2048
  },
  {
    "label": "bge-m3",
    "kontekst_tokenow": 8192
  },
  {
    "label": "qwen3-embedding:0.6b",
    "kontekst_tokenow": 32768
  },
  {
    "label": "nomic-embed-text-v2-moe",
    "kontekst_tokenow": 512
  },
  {
    "label": "embeddinggemma",
    "kontekst_tokenow": 2048
  },
  {
    "label": "granite-embedding:278m",
    "kontekst_tokenow": 512
  }
]

Okno kontekstu to najdłuższe wejście, jakie model przyjmie, liczone w tokenach. Ta różnica ma znaczenie, gdy dzielisz dokumenty na fragmenty. nomic-embed-text-v2-moe przyjmuje tylko 512 tokenów, a bge-m3 aż 8192. Długi paragraf regulaminu, który mieści się w bge-m3, model z małym oknem po cichu obetnie. Mechanizm opisuję w sekcji o parametrze truncate.

Przy nazwach tagów są dwie pułapki. Domyślny tag qwen3-embedding (czyli latest) to wariant 8b o rozmiarze 4.7GB, a nie 0.6b. Domyślny tag granite-embedding to wariant 30m, czyli ten tylko angielski. Zawsze podawaj pełny tag.

Przygotowanie: wersja Ollama i pobranie modeli

Jeśli Ollama jeszcze nie działa na Twoim serwerze, zacznij od instalacji Ollama na VPS. Potem zapisz wersję i pobierz kandydatów:

ollama --version
ollama pull bge-m3
ollama pull qwen3-embedding:0.6b
ollama pull nomic-embed-text-v2-moe
ollama pull embeddinggemma
ollama pull granite-embedding:278m
ollama list

ollama list powinno pokazać wszystkie pięć modeli. Zapisz wynik ollama --version obok wyników testu. API może się zmienić między wydaniami, więc za miesiąc będziesz wiedzieć, na czym mierzyłeś. Model granite-embedding:278m pobieramy tylko jako punkt odniesienia. Jego lista języków nie ma polskiego, więc w tym samym teście pokaże, jak wygląda wynik modelu bez polskiego w deklaracji.

Ile wymiarów ma wektor? Sprawdź /api/embed

Embeddingi zwraca endpoint /api/embed na porcie 11434. Jeśli nie znasz tego API, zobacz jak działa API Ollama na porcie 11434. Dokumentacja Ollama mówi, że /api/embed zwraca wektory znormalizowane L2, czyli o długości 1. Zaleca też podobieństwo cosinusowe. Do indeksowania i do zapytań używaj tego samego modelu.

Ta pętla wysyła jedno polskie zdanie do każdego modelu i wypisuje dwie liczby. Pierwsza to długość wektora. Druga to prompt_eval_count, czyli liczba tokenów, które model przetworzył.

for m in bge-m3 qwen3-embedding:0.6b nomic-embed-text-v2-moe embeddinggemma granite-embedding:278m; do
  printf '%s: ' "$m"
  curl -s http://localhost:11434/api/embed \
    -d "{\"model\": \"$m\", \"input\": \"Umowa najmu lokalu użytkowego\"}" \
    | python3 -c 'import json, sys; r = json.load(sys.stdin); print(len(r["embeddings"][0]), "wymiarów,", r["prompt_eval_count"], "tokenów")'
done

Każda linia powinna pokazać nazwę modelu, liczbę wymiarów i liczbę tokenów. Zapisz liczbę wymiarów, bo to jest N w kolumnie vector(N) w pgvector. Jeśli zamiast liczb widzisz KeyError: 'embeddings', odpowiedź serwera nie zawierała wektora. Uruchom wtedy samo polecenie curl bez potoku do Pythona. Zobaczysz pole error z przyczyną, na przykład z informacją o brakującym modelu.

Liczba tokenów jest równie ważna. Każdy model ma własny tokenizer, więc to samo zdanie może dać różną liczbę tokenów w różnych modelach. Nie zgaduj, ile tokenów ma Twój fragment polskiego tekstu. Zmierz to na swoich dokumentach.

Test po polsku: odmiana i zapytania bez polskich znaków

Polski ma dwa problemy, których angielskie testy nie pokazują. Pierwszy to odmiana: dokument mówi „umowie”, a użytkownik pyta o „umowę”. Drugi to brak polskich znaków: wiele osób wpisuje „zrodlo” zamiast „źródło”. Ten skrypt sprawdza oba. Używa tylko biblioteki standardowej Pythona, więc nie musisz niczego instalować przez pip.

Zapisz go jako test_pl.py:

import json
import math
import sys
import urllib.request

URL = "http://localhost:11434/api/embed"

# Prefiksy z kart modeli: (zapytanie, dokument). Modele spoza listy dostają pusty prefiks.
PREFIKSY = {
    "nomic-embed-text": ("search_query: ", "search_document: "),
    "nomic-embed-text-v2-moe": ("search_query: ", "search_document: "),
    "embeddinggemma": ("task: search result | query: ", "title: none | text: "),
    "qwen3-embedding:0.6b": ("Instruct: Given a search query, retrieve relevant passages that answer the query\nQuery:", ""),
}

DOKUMENTY = [
    "Najemca zapłaci karę umowną w wysokości 0,5% czynszu za każdy dzień opóźnienia.",
    "Wynajmujący oddaje najemcy w używanie lokal użytkowy opisany w niniejszej umowie.",
    "Źródłem finansowania inwestycji jest kredyt bankowy oraz środki własne spółki.",
    "Faktura VAT zostanie wystawiona w ciągu 7 dni od wykonania usługi.",
    "Umowy nie można wypowiedzieć przed upływem dwóch lat od dnia jej zawarcia.",
]

# (zapytanie, numer dokumentu, który powinien wygrać)
ZAPYTANIA = [
    ("umowa najmu lokalu", 1),
    ("kara za spóźnioną zapłatę czynszu", 0),
    ("źródło finansowania", 2),
    ("zrodlo finansowania", 2),
    ("kiedy mogę wypowiedzieć umowę", 4),
    ("termin wystawienia faktury", 3),
]


def embed(model, teksty):
    dane = json.dumps({"model": model, "input": teksty, "truncate": False}).encode()
    zadanie = urllib.request.Request(URL, data=dane, headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(zadanie) as odp:
        return json.load(odp)["embeddings"]


def cosinus(a, b):
    iloczyn = sum(x * y for x, y in zip(a, b))
    return iloczyn / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))


model = sys.argv[1]
pz, pd = PREFIKSY.get(model, ("", ""))
wektory_dok = embed(model, [pd + d for d in DOKUMENTY])
wektory_zap = embed(model, [pz + z for z, _ in ZAPYTANIA])
print(f"model: {model}, wymiary: {len(wektory_dok[0])}")

trafienia = 0
for (zapytanie, oczekiwany), wz in zip(ZAPYTANIA, wektory_zap):
    wyniki = [cosinus(wz, wd) for wd in wektory_dok]
    najlepszy = max(range(len(wyniki)), key=lambda i: wyniki[i])
    inne = max(w for i, w in enumerate(wyniki) if i != oczekiwany)
    margines = wyniki[oczekiwany] - inne
    trafienia += najlepszy == oczekiwany
    znak = "OK " if najlepszy == oczekiwany else "ŹLE"
    print(f"{znak} {zapytanie!r}: wygrał dok. {najlepszy}, oczekiwany {oczekiwany} "
          f"({wyniki[oczekiwany]:.3f}), margines {margines:+.3f}")

print(f"trafienia: {trafienia}/{len(ZAPYTANIA)}")

Uruchom go dla każdego modelu:

for m in bge-m3 qwen3-embedding:0.6b nomic-embed-text-v2-moe embeddinggemma granite-embedding:278m; do
  python3 test_pl.py "$m"
  echo
done

Każdy blok zaczyna się od nazwy modelu i liczby wymiarów. Potem jest jedna linia na każde zapytanie, a na końcu suma trafień. Tak czytaj wyniki:

  • OK oznacza, że wygrał oczekiwany dokument. ŹLE oznacza, że wygrał inny dokument.
  • Margines to wynik oczekiwanego dokumentu minus najlepszy wynik spośród pozostałych. Dodatni margines oznacza poprawną odpowiedź. Im jest większy, tym lepiej. Przy tysiącach dokumentów konkurencja jest dużo gęstsza niż przy pięciu zdaniach.
  • Porównaj linie 'źródło finansowania' i 'zrodlo finansowania'. Jeśli wersja bez polskich znaków przegrywa albo ma dużo mniejszy margines, model źle znosi zapytania pisane bez ogonków.
  • Linie z umowa i umowę trafiają w dokumenty z umowie i Umowy. Pokazują, czy model łączy odmienione formy tego samego słowa.

Nie porównuj surowych wartości cosinusa między modelami. Każdy model ma własną przestrzeń wektorów i własny typowy zakres wyników. Wynik 0.6 w jednym modelu nie znaczy więc tego samego co 0.6 w drugim. Porównuj trafienia i marginesy.

Pięć zdań to za mało, żeby wybrać model do produkcji. Zamień DOKUMENTY na 20 do 50 prawdziwych fragmentów ze swoich dokumentów. Zamień ZAPYTANIA na pytania, które naprawdę zadają Twoi użytkownicy. Przy embeddinggemma uruchom test dwa razy: z prefiksem i bez niego (usuń wpis z PREFIKSY). Jeśli Ollama dodaje prefiks sam, podwójny prefiks może pogorszyć wynik, a test to pokaże. Nowszy model z tej rodziny opisuję osobno w tekście o uruchamianiu EmbeddingGemma 2 na VPS.

Czy /api/embed po cichu obcina długie polskie fragmenty?

Tak, domyślnie tak. Dokumentacja API Ollama mówi, że parametr truncate ma domyślną wartość true. Obcina wtedy koniec każdego wejścia, żeby zmieściło się w długości kontekstu. Gdy ustawisz truncate na false, serwer zwróci błąd po przekroczeniu kontekstu.

Obcięcie jest ciche, bo odpowiedź nie ma pola, które by o nim mówiło. Zawiera tylko model, embeddings, total_duration, load_duration i prompt_eval_count. Wektor powstaje z początku tekstu, a reszta paragrafu nie trafia do indeksu. Jeśli kluczowe zdanie umowy stoi na końcu długiego fragmentu, wyszukiwanie go nie znajdzie. W logach nic nie zobaczysz.

Sprawdź to na sztucznie długim tekście:

python3 -c 'print("Najemca zobowiązuje się do terminowej zapłaty czynszu. " * 400)' > dlugi.txt
python3 - <<'EOF'
import json
import urllib.error
import urllib.request

tekst = open("dlugi.txt", encoding="utf-8").read()
for truncate in (True, False):
    dane = json.dumps({"model": "nomic-embed-text-v2-moe", "input": tekst, "truncate": truncate}).encode()
    zadanie = urllib.request.Request("http://localhost:11434/api/embed", data=dane,
                                     headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(zadanie) as odp:
            r = json.load(odp)
            print("truncate", truncate, "wektor", len(r["embeddings"][0]), "tokenów", r["prompt_eval_count"])
    except urllib.error.HTTPError as e:
        print("truncate", truncate, "błąd", e.code, e.read().decode())
EOF

Przy truncate True dostaniesz wektor, a prompt_eval_count pokaże liczbę tokenów, która zmieściła się w kontekście. Przy truncate False dostaniesz linię z kodem błędu i komunikatem serwera zamiast wektora. Ile tokenów przejdzie, zależy od konfiguracji Twojego serwera. Odczytaj tę liczbę zamiast przepisywać wartość ze strony modelu.

Wniosek dla potoku indeksowania: wysyłaj fragmenty z "truncate": false. Za długi fragment zatrzyma wtedy indeksowanie z błędem, a Ty podzielisz go na mniejsze części. Skrypt testowy z poprzedniej sekcji już tak robi. Przy modelach z oknem 512 tokenów od początku planuj krótkie fragmenty, bo polskie paragrafy prawne bywają długie.

Czy Ollama zwraca wektory rzadkie i multi-vector z bge-m3?

Karta BAAI/bge-m3 opisuje trzy tryby. Dense to jeden wektor na fragment. Multi-vector to osobny wektor dla każdego tokena. Sparse to wagi dla pojedynczych słów, podobne do wyszukiwania po słowach kluczowych. Dokumentacja /api/embed nie wymienia żadnej opcji, która zwracałaby tryb sparse albo multi-vector. Odpowiedź ma tylko pole embeddings.

Na GitHubie Ollama otwarte jest zgłoszenie #6230 „Add Generate Embedding for Sparse vector”, założone 7 sierpnia 2024. W komentarzu z 28 lipca 2026 użytkownik pisze: „ollama only exposes the dense one”. To głos społeczności, a nie deklaracja zespołu Ollama. Zanim zaplanujesz wyszukiwanie na wektorach rzadkich, sprawdź stan tego zgłoszenia i aktualną dokumentację API. Dziś w Ollama licz tylko na wektor gęsty. Wyszukiwanie po słowach kluczowych zrób w PostgreSQL.

Ile kosztuje zmiana modelu w pgvector

Kolumna w pgvector ma stały wymiar: vector(N). Wektor o innej liczbie wymiarów nie trafi do takiej kolumny. Kod źródłowy pgvector zgłasza wtedy błąd w formacie expected %d dimensions, not %d. Gdy wstawiasz wektor 768-wymiarowy do kolumny vector(1024), zobaczysz:

ERROR:  expected 1024 dimensions, not 768

Porównanie dwóch wektorów o różnej długości daje osobny błąd w formacie different vector dimensions %d and %d. Groźniejszy jest przypadek bez błędu: dwa różne modele o tym samym wymiarze. Wektory z różnych modeli leżą w różnych przestrzeniach. Jeśli porównasz zapytanie z jednego modelu z dokumentami z drugiego, dostaniesz przypadkowe wyniki bez żadnego komunikatu. Dlatego dokumentacja Ollama zaleca ten sam model do indeksowania i do zapytań.

Zmiana modelu oznacza więc cztery kroki:

  1. Dodaj nową kolumnę o wymiarze, który wypisał test z /api/embed.
  2. Przelicz embeddingi wszystkich wierszy nowym modelem, z prefiksem dokumentu, jeśli model go wymaga.
  3. Zbuduj nowy indeks HNSW na nowej kolumnie i przełącz zapytania.
  4. Po sprawdzeniu wyników usuń starą kolumnę i jej indeks.
ALTER TABLE fragmenty ADD COLUMN embedding_nowy vector(1024);

-- tu skrypt przelicza wszystkie wiersze nowym modelem

CREATE INDEX fragmenty_embedding_nowy_hnsw
    ON fragmenty USING hnsw (embedding_nowy vector_cosine_ops);

SELECT id, tresc
FROM fragmenty
ORDER BY embedding_nowy <=> '[...]'
LIMIT 8;

Liczba 1024 to tylko przykład. Wpisz tę, którą wypisał Twój test. Nazwy fragmenty, tresc i embedding_nowy zamień na swoje. W miejsce '[...]' Twój kod wstawia wektor zapytania policzony nowym modelem. Stara kolumna działa do końca przeliczania, więc wyszukiwanie nie ma przerwy.

Kolumna zadeklarowana jako samo vector, bez liczby, przyjmie wektory dowolnej długości, bo pgvector pomija wtedy sprawdzanie wymiaru. To nie jest dobre obejście. README pgvector mówi, że indeks da się zbudować tylko na wierszach o tej samej liczbie wymiarów. Jest też górna granica. Indeks HNSW na typie vector obsługuje do 2000 wymiarów, a na typie halfvec do 4000. Według swoich kart bge-m3, qwen3-embedding:0.6b, nomic-embed-text-v2-moe i embeddinggemma mieszczą się poniżej 2000. Domyślne parametry HNSW to m = 16 i ef_construction = 64. Jak je dobrać na małym serwerze, opisuję w tekście o indeksie HNSW w pgvector na małym VPS. Jeśli dopiero wybierasz bazę, zobacz porównanie baz wektorowych na VPS.

Przeliczenie całego zbioru kosztuje dużo czasu procesora. Dlatego wybierz model przed zaindeksowaniem dużego archiwum, a nie po nim.

Czy PostgreSQL ma konfigurację 'polish' do wyszukiwania pełnotekstowego?

Sprawdź to na swoim serwerze jednym zapytaniem. Katalog pg_ts_config przechowuje konfiguracje wyszukiwania pełnotekstowego, a kolumna cfgname przechowuje ich nazwy.

sudo -u postgres psql -c "SELECT cfgname FROM pg_ts_config ORDER BY cfgname;"

Wynik to lista konfiguracji, w większości nazwanych od języków. Szukaj na niej polish. W kodzie źródłowym PostgreSQL 17 i 18 (gałęzie REL_17_STABLE i REL_18_STABLE) plik src/backend/snowball/Makefile nie zawiera polskiego stemmera. W PostgreSQL 19 polski stemmer już jest: w tagu REL_19_RC1 ten sam plik zawiera stem_ISO_8859_2_polish.o i stem_UTF_8_polish.o. Nie ma tam jednak pliku polskich słów pomijanych (polish.stop). PostgreSQL 19 RC 1 wydano 15 października 2026. Czy wersja z Twojego repozytorium ma konfigurację polish, rozstrzyga tylko powyższe zapytanie.

Różnicę między stemmerem angielskim a brakiem stemmera zobaczysz sam:

sudo -u postgres psql -c "SELECT to_tsvector('english', 'umowa umowy umowie');"
sudo -u postgres psql -c "SELECT to_tsvector('simple', 'umowa umowy umowie');"

Konfiguracja simple zamienia litery na małe i nie zmienia końcówek. Gdy nie masz polskiego stemmera, to uczciwszy wybór dla polskiego tekstu niż english, bo nie udaje, że rozumie odmianę. Odmianę obsłużysz wtedy zapytaniem z prefiksem, na przykład umow:*. Takie zapytanie pasuje do umowa, umowy i umowie.

Problem brakujących ogonków rozwiązuje rozszerzenie unaccent. Dokumentacja PostgreSQL opisuje je jako słownik, który usuwa znaki diakrytyczne. Oznacza je też jako „trusted”, więc zainstaluje je także użytkownik bez uprawnień superużytkownika, jeśli ma prawo CREATE w bazie. Poniższy wzór pochodzi z przykładu w dokumentacji, ze słownikiem simple w miejscu francuskiego stemmera:

CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE TEXT SEARCH CONFIGURATION pl_proste (COPY = simple);
ALTER TEXT SEARCH CONFIGURATION pl_proste
    ALTER MAPPING FOR hword, hword_part, word
    WITH unaccent, simple;

SELECT unaccent('źródło łódź żółć');
SELECT to_tsvector('pl_proste', 'Źródło finansowania umowy')
       @@ to_tsquery('pl_proste', 'zrodlo & umow:*');

Pierwsze SELECT pokazuje, które polskie litery reguły unaccent zamieniają na Twoim serwerze. Dokumentacja nie wymienia polskich znaków z nazwy, więc patrz szczególnie na ł. Drugie SELECT powinno zwrócić t, czyli prawdę. Oznacza to, że zapytanie bez ogonków i z prefiksem umow:* znajduje tekst ze słowami „Źródło” i „umowy”. Jeśli zwraca f, wróć do wyniku pierwszego SELECT. Któraś litera ze słowa „Źródło” nie została wtedy zamieniona. Dopisz dla niej regułę do własnego pliku .rules w katalogu tsearch_data. Ścieżkę do katalogu nadrzędnego pokaże pg_config --sharedir.

PL-MTEB: polski benchmark modeli embeddingów

Do porównań na większą skalę służy PL-MTEB (Polish Massive Text Embedding Benchmark). Opisuje go praca Rafała Poświaty, Sławomira Dadasa i Michała Perełkiewicza (arXiv 2405.10138). Pierwsza wersja pochodzi z 16 maja 2024, a wersja v2 z 24 kwietnia 2026. Benchmark ma 30 zadań w pięciu kategoriach: klasyfikacja, klastrowanie, klasyfikacja par, wyszukiwanie informacji i podobieństwo semantyczne tekstów. Dla RAG najważniejsza jest kategoria wyszukiwania informacji.

Ranking jest na Hugging Face, w przestrzeni PL-MTEB/leaderboard. Nie podaję z niego żadnej kolejności, bo 24 października 2026 nie udało się odczytać z tej strony rankingu. Według API Hugging Face przestrzeń zmieniono ostatnio 19 lutego 2025, więc nowszych modeli może w niej nie być. Zanim oprzesz decyzję na rankingu, sprawdź, czy są w nim modele, które naprawdę uruchomisz w Ollama. Tabele w pracy na arXiv opisują stan z konkretnej wersji pracy, a nie żywy ranking. Jeśli je cytujesz, podaj wersję.

Osobna sprawa to polskie modele mmlw, na przykład sdadas/mmlw-retrieval-roberta-large. Karta tego modelu ma tag języka polskiego, licencję Apache-2.0 i wektory o 1024 wymiarach. Model powstał z polskiego checkpointu RoBERTa. Jego pliki to tylko model.safetensors i pytorch_model.bin, bez pliku GGUF. 24 października 2026 nie znalazłem modelu mmlw w bibliotece Ollama ani gotowego pliku GGUF na Hugging Face. Znalazłem tylko konwersje do ONNX. Ollama importuje pliki GGUF przez Modelfile z linią FROM /path/to/file.gguf i polecenie ollama create. Opisuję to w tekście o imporcie modelu GGUF do Ollama. Nie sprawdziłem, czy ten model da się poprawnie przekonwertować do GGUF. Dopóki ktoś tego nie zrobi, a model nie przejdzie testu z tego tekstu, traktuj mmlw jako model do uruchamiania poza Ollama.

Jak wybrać model: procedura na jedno popołudnie

  1. Odrzuć modele bez polskiego w deklaracji: nomic-embed-text (tylko tag English), granite-embedding:30m (tylko angielski) i granite-embedding:278m (lista języków bez polskiego).
  2. Pobierz bge-m3 i qwen3-embedding:0.6b. Jeśli masz krótkie fragmenty, dodaj nomic-embed-text-v2-moe i embeddinggemma.
  3. Wypisz wymiary i liczbę tokenów pętlą z /api/embed.
  4. Uruchom test_pl.py na 20 do 50 prawdziwych fragmentach i pytaniach, także na pytaniach bez polskich znaków.
  5. Wybierz model z największą liczbą trafień i największymi marginesami. Przy remisie wybierz ten z większym oknem kontekstu, bo podzielisz wtedy mniej fragmentów.
  6. Utwórz kolumnę vector(N) z wypisanym N, indeksuj z "truncate": false i zbuduj indeks HNSW.
  7. Wyszukiwanie po słowach kluczowych przestaw z english na polish, jeśli Twój serwer ma tę konfigurację. W przeciwnym razie użyj konfiguracji opartej na simple i unaccent.

FAQ

Czy nomic-embed-text nadaje się do polskich dokumentów?

Karta nomic-embed-text-v1.5 na Hugging Face ma tylko tag językowy English i nie deklaruje obsługi innych języków. Nie oznacza to, że polski w ogóle nie zadziała. Nic jednak tego nie obiecuje. Do polskich umów i regulaminów lepiej zacząć od modelu wielojęzycznego, na przykład bge-m3 albo qwen3-embedding:0.6b. Porównaj je testem z odmianą słów i zapytaniami bez polskich znaków.

Czy Ollama obcina zbyt długie fragmenty przy liczeniu embeddingów?

Tak. Parametr truncate w /api/embed ma domyślną wartość true i obcina koniec wejścia do długości kontekstu. Odpowiedź nie informuje o obcięciu, więc koniec długiego paragrafu po cichu znika z indeksu. Wysyłaj fragmenty z "truncate": false. Wtedy za długi fragment zwróci błąd zamiast okrojonego wektora. Liczbę przetworzonych tokenów pokazuje pole prompt_eval_count.

Czy mogę zmienić model embeddingów bez przeliczania wszystkich wektorów?

Nie. Kolumna pgvector ma stały wymiar vector(N). Wektor o innym wymiarze daje błąd w rodzaju expected 1024 dimensions, not 768, a liczby zależą od modeli. Nawet przy tym samym wymiarze wektorów z różnych modeli nie da się sensownie porównać. Zmiana modelu to nowa kolumna, przeliczenie wszystkich dokumentów i nowy indeks HNSW.

Czy PostgreSQL ma polską konfigurację wyszukiwania pełnotekstowego?

W kodzie źródłowym PostgreSQL 17 i 18 nie ma polskiego stemmera Snowball. W PostgreSQL 19, którego RC 1 wydano 15 października 2026, już jest. Sprawdź swój serwer zapytaniem SELECT cfgname FROM pg_ts_config;. Jeśli na liście nie ma polish, użyj konfiguracji opartej na simple z rozszerzeniem unaccent. Do tego używaj zapytań z prefiksem, na przykład umow:*.

Czy polskie modele mmlw działają w Ollama?

24 października 2026 nie było ich w bibliotece Ollama. Nie znalazłem też dla nich gotowego pliku GGUF, tylko pliki safetensors, pliki PyTorch i konwersje do ONNX. Ollama importuje modele z plików GGUF, więc najpierw potrzebna byłaby udana konwersja. Tego nie sprawdziłem. Ranking polskich modeli embeddingów znajdziesz w przestrzeni PL-MTEB na Hugging Face.