Strona główna / Artykuły

Hybrydowe wyszukiwanie w Postgres dla asystenta zdrowotnego: schemat, dzielenie na fragmenty, RRF oraz metody oceny.

Hybrydowe wyszukiwanie w Postgresie dla asystenta zdrowotnego: dzielenie na fragmenty z uwzględnieniem struktury, pgvector z RRF, kwantyfikacja binarna, progi oceny oraz właściwa ewaluacja.

Wydawca: REACTAPP.TOP19 min czytania4283 słów

Źródła: RAG in Postgres: What I Learned Building a Health Assistant Where Being Wrong Is Expensive

Demony „sztucznej inteligencji zdrowotnej” zazwyczaj wyglądają identycznie. Zbiór danych publicznych trafia do magazynu wektorowego, prompt informuje, że model jest pomocnym asystentem medycznym, a na pierwsze trzy pytania otrzymuje się przyjemne odpowiedzi. Potem pojawia się czwarte pytanie.

Zawiera ono negację. Albo prawidłowa odpowiedź zależy od dokumentu zrewidowanego osiemnaście miesięcy temu, który zastąpił ten napisany w 2011 roku. Albo dwie wiarygodne źródła publiczne się różnią, a właściwym postępowaniem jest to podkreślić i wyjaśnić, dlaczego to ma znaczenie, zamiast wybrać jedno rozwiązanie. Albo uczciwa odpowiedź brzmi „Nie wiem, zapytaj klinicystę”, a model szkolony do bycia pomocnym i tak pisze pewny siebie akapit.

To właśnie w tym czwartym pytaniu kryje się istota produktu, a prawie cała praca potrzebna do jego odpowiedzi nie odbywa się w modelu językowym. Odbywa się ona w procesie wydobywania informacji, który w tym przypadku oznacza Postgres. Poniżej opisano tę warstwę: schemat, dzielenie na fragmenty, hybrydowy zapytanie wyszukiwawcze, kwantyzację oraz narzędzie oceny, które zapobiega wprowadzaniu błędów w finalnym produkcie. Jest ona niezależna od żadnego konkretnego źródła danych, a te zasady można zastosować w każdym korpusie, w którym błędna odpowiedź ma poważne konsekwencje.

Cztery sposoby, w jakie teksty medyczne pokonują prosty model RAG

Korpusy medyczne otwarte charakteryzują się czterema cechami, które potajemnie niszczą standardową procedurę składającą się z dzielenia tekstu na fragmenty, wstawiania embeddingów, obliczania podobieństwa kosinowego i włączania tych informacji do promptu.

  1. Znaczenie nie ma formy akapitu. Monografia leku zawiera oddzielne sekcje dotyczące przeciwwskazań, interakcji i dawek. Gdy dzieli się ją na okna przesuwne po 512 tokenów, niektóre fragmenty zawierają połowę informacji o przeciwwskazaniach oraz połowę tabeli z dawkami. Takie fragmenty dają doskonałe wyniki pod względem podobieństwa kosinowego, ale słabe wartości prawdziwości. Jest to najniebezpieczniejszy błąd w całym systemie, ponieważ metryki wyszukiwania nadal wyglądają dobrze.
  2. Słownictwo ma charakter dwumodalny. Użytkownicy opisują pieczenie w żołądku po posiłkach, podczas gdy korpus używa określenia „ból epigastryczny po posiłku”. Wyszukiwanie semantyczne dobrze pokrywa tę luki. Jednak użytkownicy wpisują również dokładne identyfikatory, takie jak nazwa leku, kod czy związek chemiczny, a w takich przypadkach metody oparte na embeddingach radzą sobie gorzej niż indeksy słów kluczowych. Model, który umieszcza metoprolol i metoprolol succinate w sąsiednich pozycjach, może pomóc komuś popełnić błąd. Potrzebne są obie metody wyszukiwania, połączone ze sobą, a nie wybierane pojedynczo.
  3. Nowość to aspekt semantyczny. W innych dziedzinach stary dokument jest po prostu nieco gorszy. W ochronie zdrowia może być wręcz szkodliwy, ponieważ zalecenia są aktualizowane właśnie dlatego, że wcześniejsza wersja była błędna. Nowość nie może być czynnikiem rozstrzygającym dodanym na końcu; musi być ważnym elementem, który bierze pod uwagę zarówno warstwa wyszukiwania, jak i warstwa generowania odpowiedzi.
  4. Odpowiedzi muszą mieć określoną formę prawną. Niektóre pytania wymagają kierowania, a nie dokładnej odpowiedzi: objawy nagłych sytuacji, decyzje dotyczące dawek dla konkretnej osoby oraz wszystko, co stanowi diagnozę. W takich przypadkach potrzebna jest ustalona, oparta na regułach ścieżka, której nie może wpłynąć to, co model akurat odczuwa.

Każdy z tych przypadków to problem warstwy wyszukiwania, co prowadzi do nudnej decyzji, która ostatecznie ma największe znaczenie.

Dlaczego Postgres, a nie baza danych wektorowych?

Ta kwestia wymaga jasnego sformułowania, ponieważ twierdzenie „nie potrzebujesz bazy danych wektorowych” stało się zbyt uproszczone, a istnieją rzeczywiste przypadki, w których jest ona konieczna.

Specjalistyczna baza danych wektorowych zapewnia lepszą dokładność wyszukiwania na milisekundę przy bardzo dużych skali, bogatsze typy indeksów, wyszukiwanie filtrowane zaprojektowane do filtrowania od samego początku oraz skalowanie poziome, które nie wymaga dodatkowych rozważań. Przy setkach milionów wektorów pod dużym obciążeniem zapytań stanowi to prawdziwą przewagę, którą należy wykorzystać.

Postgres oferuje tylko jedną granicę transakcji. Może to brzmieć błahe, ale tak nie jest. W korpusie, który się zmienia, architektura dwusystemowa wiąże się z poważnymi konsekwencjami:

  • Gdy dokument źródłowy jest poprawiany, jego stare fragmenty muszą zostać usunięte, a nowe wstawione, embeddingi ponownie obliczone, a metadane zaktualizowane – wszystko to atomowo. W przypadku oddzielnej bazy danych wektorowych atomowość nie istnieje. W najgorszym przypadku indeks słów kluczowych i indeks wektorowy nie zgadzają się co do tego, co istnieje, w rezultacie asystent może przytaczać dokument, który już nie zawiera informacji, dla których został przytoczony.
  • Filtrowanie według licencji, jurysdykcji, źródła danych, daty rewizji i języka, a następnie sortowanie według odległości wektorowej, to klauzula WHERE w Postgresie. W obu systemach oznacza to albo replikę metadanych utrzymywaną w synchronizacji, albo procedurę pobierania większej ilości danych, a następnie ich filtrowania, która w tajemnicy zwraca mniej wyników, niż zostało poproszono.
  • Określenie, dlaczego dana odpowiedź została pobrała sześć tygodni temu, to operacja łączenia danych.

Próg skalowalności istnieje, ale jest wyższy, niż przypuszczają ludzie – na dobrze dostrojonym instancji z obecnym pgvector można wygodnie obsłużyć dziesiątki milionów elementów. Rozsądną zasadą jest rozpoczęcie pracy w Postgresie i traktowanie późniejszej migracji jako kamienia milowego sukcesu, a nie celu projektowego. Projekt, który nigdy nie osiąga tego progu, unika całego problemu związanego z systemami rozproszonymi. Ten, który go osiąga, posiada gotowy produkt, rzeczywiste logi zapytań oraz jasny obraz swoich potrzeb.

Wersje są istotne. pgvector 0.7.0 wprowadził funkcje halfvec, sparsevec, indeksowanie typu bit oraz binary_quantize. Wersja 0.8.0 dodała iteracyjne skanowanie indeksów, które ułatwiają rozwiązywanie problemu filtrowania opisanego poniżej, a wersje 0.8.x umożliwiły pracę z Postgres 18. W wersjach 0.6.x większość z opisanych poniżej funkcji nie będzie działać tak, jak zakładano.

Schemat

Projekt opiera się na czterech tabelach. Zasada brzmi, że informacje o pochodzeniu muszą mieć strukturę, a nie być zwykłymi obiektami JSON: wszystko, co warstwa odpowiedzi musi cytować, filtrować lub analizować, otrzymuje własną kolumnę.

CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Where knowledge comes from, and how much we trust it.
CREATE TABLE source (
  id bigserial PRIMARY KEY,
  slug text UNIQUE NOT NULL,
  license text NOT NULL, -- redistribution rules are a query concern
  authority smallint NOT NULL CHECK (authority BETWEEN 1 AND 5),
  base_url text
);
-- One row per upstream document, at whatever granularity upstream uses.
CREATE TABLE document (
  id bigserial PRIMARY KEY,
  source_id bigint NOT NULL REFERENCES source(id),
  external_id text NOT NULL, -- the stable ID assigned upstream
  title text NOT NULL,
  url text,
  published_at date,
  revised_at date, -- recency is a column, not a vibe
  superseded_by bigint REFERENCES document(id),
  lang text NOT NULL DEFAULT 'en',
  raw_hash bytea NOT NULL, -- skip work on unchanged re-ingests
  meta jsonb NOT NULL DEFAULT '{}',
  UNIQUE (source_id, external_id)
);
-- Retrieval unit. Note heading_path: structure survives chunking.
CREATE TABLE chunk (
  id bigserial PRIMARY KEY,
  document_id bigint NOT NULL REFERENCES document(id) ON DELETE CASCADE,
  ord int NOT NULL,
  heading_path text[], -- {Contraindications,"Renal impairment"}
  body text NOT NULL,
  body_tsv tsvector GENERATED ALWAYS AS
    (to_tsvector('english', body)) STORED,
  token_count int NOT NULL,
  content_hash bytea NOT NULL,
  lang text NOT NULL, -- denormalized from document
  is_current boolean NOT NULL, -- denormalized: superseded_by IS NULL
  UNIQUE (document_id, ord)
);
CREATE INDEX chunk_tsv_idx ON chunk USING gin (body_tsv);
CREATE INDEX chunk_trgm_idx ON chunk USING gin (body gin_trgm_ops);
-- Embeddings are versioned by model. This is not optional.
-- lang and is_current are DENORMALIZED from document -- see below.
CREATE TABLE embedding (
  chunk_id bigint NOT NULL REFERENCES chunk(id) ON DELETE CASCADE,
  model text NOT NULL,
  vec halfvec(1536) NOT NULL,
  lang text NOT NULL,
  is_current boolean NOT NULL,
  PRIMARY KEY (chunk_id, model)
);

Cztery elementy pełnią funkcję nośną.

Wkładki są powiązane z określonymi fragmentami danych i modelem. Modele wkładek mogą ulegać zmianie. Gdy tak się stanie, obie wersje muszą być dostępne jednocześnie, aby można je było testować w trybie A/B na rzeczywistym ruchu i wymieniać bez konieczności przerwy w obsłudze. Od samego początku umieść model w kluczu głównym i utwórz częściowy indeks dla każdego modelu, aby każdy z nich pozostał kompaktowy:

CREATE INDEX embedding_hnsw_v3_en
  ON embedding USING hnsw (vec halfvec_cosine_ops)
  WITH (m = 16, ef_construction = 128)
  WHERE model = 'emb-v3' AND lang = 'en' AND is_current;

Język oraz aktualna flaga są celowo kopiowane zarówno do chunka, jak i do embeddingu. To jedyny przypadek, w którym normalizacja została celowo złamana, a powód tego faktu uświadomienie sobie zajęło niezwykle długi czas. Predykat indeksu częściowego w Postgresie może odnosić się tylko do kolumn tabeli, która jest indeksowana. Indeks embeddingu nie może zawierać wyrażenia WHERE document.is_current; Postgres odrzuca je bez wahania, włączając w to podzapytania. Aby włączyć filtry kluczowe do indeksu wektorowego – co jest konieczne z powodów opisanych poniżej w kontekście pułapki filtrowania – te kolumny muszą fizycznie znajdować się w wierszu embeddingu.

Zachowanie wierności kopii jest skomplikowane, a pierwsza próba zawiodła dwa razy. Obecny flag nie jest ustawiany podczas samego importu dokumentu. Zmienia się on, gdy jakiś późniejszy dokument zastąpi ten oryginalny, więc aktualizacja odbywa się w ramach transakcji dokumentu zastępującego i musi dotrzeć do każdego fragmentu oraz wiersza embedowania należącego do usuniętego dokumentu. Umieść tę logikę w jednej funkcji z jednym miejscem wywołania i napisz test potwierdzający, że wartości denormalizowane zawsze są równe wartościom z dokumentu nadrzędnego. Gdy obie metody wyszukiwania nie są zgodne co do tego, które wiersze są aktualne, proces fuzji łączy pozycje z dwóch różnych korpusów, co powoduje stopniowe pogorszenie jakości wyników bez żadnego błędu, na który można by wskazać.

To również nie jest bezkosztowe. Odwrócenie flagi usuwa te wiersze z obu częściowych indeksów HNSW, a indeks HNSW zwalnia przestrzeń tylko podczas czyszczenia, więc intensywne modyfikacje powodują stopniowe powiększanie się indeksu.

Kolumna wektorowa to halfvec, a nie vector. Przechowywanie liczb zmiennoprzecinkowych 16-bitowych zmniejsza surową objętość danych z 6 152 bajtów do 3 076 przy 1 536 wymiarach, a indeks HNSW kurczy się jeszcze bardziej, ponieważ małe tuple mieszczą się bardziej zwartо na stronach. W tym przypadku wyszukiwanie musi dostarczyć zaledwie około 50 kandydatów do ponownego sortowania, a w testach różnica w stopniu odzyskania wyników pozostawała w granicach szumu. Dodatkowo: halfvec może być indeksowany do 4 000 wymiarów, podczas gdy zwykły vector kończy się na 2 000, co jest przydatne przy dużych modelach embeddingowych.

Supersesja jest przechowywana jako wskaźnik do tej samej tabeli, a nie jako wartość logiczna. Sam znak przestarzałości mówi asystentowi zbyt mało. Wiedza o tym, który dokument zastąpił ten obecny, pozwala mu wyjaśnić, że wskazówki się zmieniły, i udostępnić link do aktualnej wersji. To jest połączenie, możliwe tylko wtedy, gdy krawędź została zarejestrowana podczas importu.

Takie podział na fragmenty daje największą wartość

Jeśli istnieje tu jedna lekcja: kluczowy jest narzędzie do dzielenia tekstu na fragmenty, a nie model embeddingowy. Lepszy model przynosi niewielką poprawę w wynikach. Narzędzie, które uwzględnia strukturę tekstu, odróżnia asystenta, który udziela poprawnych odpowiedzi, od tego, który błędnie odpowiada, mimo że robi to z przekonaniem. Obowiązują tu trzy zasady.

Najpierw struktura, potem rozmiar. Należy przetworzyć każdy dokument na drzewo nagłówków i umieścić każdy fragment w ramach jednej sekcji najwyższego poziomu. Gdy sekcja jest mniejsza od wymaganego rozmiaru, należy ją tak zostawić, zamiast łączyć z sąsiednią sekcją: istnieje ograniczenie 60 tokenów, które wskazuje jedynie, że taka sekcja jest bardziej przydatna niż fragment składający się z 500 tokenów, w którym informacje giną. Ograniczenia liczby tokenów są rozwiązaniem awaryjnym, gdy struktura tekstu nie wystarcza, a nie zasadą podstawową.

Nigdy nie dziel tabeli. Tabele dotyczące zdrowia zazwyczaj przedstawiają siatki dawek, matryce interakcji lub kryteria przyznawania świadczeń – to właśnie w takich przypadkach połowa wierszy jest gorsza od niczego. Jeśli budżet się wyczerpie, podziel tabelę na grupy wierszy, powtórz nagłówek w każdej grupie i spłaszcz wynik w zdania przed wstawieniem. Zdanie typu „Wiek 6 do 11 lat: 5 mg raz dziennie” wstawia się znacznie lepiej niż siatka oddzielona przecinkami.

Umieść kontekst na początku przed wstawieniem tekstu. Wstawiony tekst nie powinien być surowym fragmentem, lecz fragmentem z przywróconym kontekstem: linia nagłówkowa z nazwą źródła, tytułem dokumentu i datą rewizji, następnie ścieżką do sekcji, np. Przeciwwskazania, potem Upośledzenie funkcji nerek, a na końcu treść. Wstaw taką strukturę i zachowaj surową treść do wyświetlania i cytowania. Fragment brzmiący „nie stosować u pacjentów z obniżoną klirensą” sam w sobie niewiele mówi, ponieważ nie wskazuje żadnego leku, stanu chorobowego ani kontekstu. Staje się dostępny dopiero wtedy, gdy nagłówek poinformuje model, o czym mowa. Ta jedna zmiana poprawiła wyniki wyszukiwania bardziej niż jakakolwiek próba zmiany modelu, a kosztuje jedynie tokeny w momencie indeksowania.

Poniżej znajdują się uwagi dotyczące implementacji. Oblicz hash treści dla tekstu skontekstualizowanego, aby ponowne wprowadzenie danych zmieniające tylko nagłówek poprawnie wywoływało ponowne wgranie. Ponadto przeprowadź całą procedurę przekształcania dokumentu na fragmenty, a następnie na embedding, w ramach jednej transakcji, aby korpus nigdy nie był częściowo zaktualizowany:

BEGIN;
  DELETE FROM chunk WHERE document_id = $1; -- cascades to embedding
  -- insert new chunks + embeddings
COMMIT;

To właśnie ta transakcja jest głównym powodem, dla którego dane te znajdują się w Postgresie.

Hybrydowe wyszukiwanie z fuzją rankingu wzajemnego

Każde zapytanie przeprowadza osobno wyszukiwanie semantyczne oraz wyszukiwanie według słów kluczowych, a następnie łączy wyniki na podstawie rankingu, a nie surowej oceny. Łączenie ocen nie jest skuteczne: odległość kosinowa oraz ts_rank_cd odnoszą się do różnych skali, nie mogą być porównywane pomiędzy zapytaniami, a wszelkie wagi dostosowane do jednego zestawu zapytań spowodują ich nadprzystosowanie. Fuzja rankingu wzajemnego całkowicie unika tego problemu, analizując wyłącznie pozycje.

WITH params AS (
  SELECT $1::halfvec(1536) AS qvec,
         $2::text AS qtext,
         60::int AS rrf_k, -- damping constant
         50::int AS pool
),
-- Two rules in this CTE, both non-obvious, both explained below:
-- 1. qvec comes from a SCALAR subquery, never a joined relation.
-- 2. Filters live INSIDE each arm, not in the outer query.
semantic AS (
  SELECT chunk_id, row_number() OVER (ORDER BY dist) AS rank
  FROM (
    SELECT e.chunk_id,
           e.vec <=> (SELECT qvec FROM params) AS dist
    FROM embedding e
    WHERE e.model = 'emb-v3'
      AND e.lang = 'en'
      AND e.is_current
    ORDER BY e.vec <=> (SELECT qvec FROM params)
    LIMIT (SELECT pool FROM params)
  ) s
),
lexical AS (
  SELECT chunk_id, row_number() OVER (ORDER BY score DESC) AS rank
  FROM (
    SELECT c.id AS chunk_id,
           ts_rank_cd(c.body_tsv,
                      websearch_to_tsquery('english',
                        (SELECT qtext FROM params))) AS score
    FROM chunk c
    WHERE c.body_tsv @@ websearch_to_tsquery('english',
                          (SELECT qtext FROM params))
      AND c.lang = 'en' -- same predicate as the semantic arm,
      AND c.is_current -- from the same denormalized source
    ORDER BY score DESC
    LIMIT (SELECT pool FROM params)
  ) l
),
fused AS (
  SELECT chunk_id,
         SUM(1.0 / ((SELECT rrf_k FROM params) + rank)) AS score
  FROM (
    SELECT chunk_id, rank FROM semantic
    UNION ALL
    SELECT chunk_id, rank FROM lexical
  ) u
  GROUP BY chunk_id
)
SELECT c.id, d.title, d.url, d.revised_at, s.slug, s.authority,
       c.heading_path, c.body, f.score
FROM fused f
JOIN chunk c ON c.id = f.chunk_id
JOIN document d ON d.id = c.document_id
JOIN source s ON s.id = d.source_id
ORDER BY f.score DESC, s.authority DESC, d.revised_at DESC NULLS LAST
LIMIT 20;

Fragment, który zajął trzecie miejsce pod względem znaczenia i siódme pod względem słów kluczowych, przewyższa ten, w którym tylko jeden element zajął pierwsze miejsce. Tak właśnie ma być, ponieważ dwa niezależne sygnały zgadzające się ze sobą mają większą wagę niż jeden sygnał krzyczący. websearch_to_tsquery jest rozsądnym parserem, ponieważ toleruje rzeczywiste dane wprowadzone przez użytkownika, frazy w cudzysłowach oraz wykluczenia oznaczone minusem, nie zawodząc przy niepoprawnie sformatowanym tekście. ts_rank_cd przewyższa tutaj zwykły ts_rank, ponieważ gęstość pokrycia faworyzuje terminy pojawiające się blisko siebie, co odpowiada pytaniom łączącym kilka koncepcji.

Dwie cechy tego zapytania wyglądają jak wybory stylistyczne, ale takimi nie są.

Pierwsza pułapka: wektor zapytania musi być skalarnym podzapytaniem

W modułowaniu semantycznym wektor zapytania przychodzi jako skalarny podzapytanie, a nie jako kolumna z połączonej relacji parametrów. To różnica między milisekundą a minutą.

Wersja intuicyjna wybiera jednocześnie z tabeli embeddingów oraz relacji parametrów, a następnie sortuje według odległości od kolumny parametrów. Działa dobrze, jest poprawna i zwraca właściwe wiersze, co jest dokładnie powodem, dla którego przetrwała przegląd kodu. Problem polega na tym, że planer może używać indeksu najbliższego sąsiada tylko wtedy, gdy instrukcja ORDER BY porównuje kolumnę indeksowaną z czymś stałym dla całego skanowania. Wartość pobrana poprzez połączenie nie spełnia tych warunków, więc w ogóle nie ma uporządkowanej ścieżki: Postgres czyta każdy wiersz i go sortuje.

Przy pięćdziesięciu tysiącach fragmentów kosztuje to kilkaset milisekund, co jest wystarczająco wolne, by to odczuć, oraz na tyle niejednoznaczne, by winić za to sieć. Przy pięciu milionach występuje timeout.

Weryfikuj zamiast ufać. Wyjaśnij zapytanie wyszukiwawcze w CI i upewnij się, że plan zawiera skan indeksu w indeksie HNSW z polem Order By. Sekwencyjny skan połączony z sortowaniem oznacza, że indeks wektorowy ma jedynie charakter dekoracyjny. Test, który nie udaje się podczas budowania, jest warte przeprowadzenia, ponieważ dobrze intencjonowana refaktoryzacja może ponownie spowodować ten problem, nie dając ani jednej błędnej odpowiedzi, a jedynie wolniejsze wyniki w środowisku produkcyjnym.

Pułapka numer dwa: filtrowanie po ustawieniu limitu

Filtrzy, mianowicie aktualny, językowy oraz „niezastąpiony”, powinny znajdować się w podzapytaniu każdego z ram, przed polem LIMIT. Pierwsza wersja umieściła je w zewnętrznym zapytaniu, w oczywistym, ale błędnym miejscu.

HNSW zwraca przybliżone najbliższe sąsiedzie. Bierze się 50 najbliższych pod względem odległości, następnie je filtrowuje, a ostatecznie może przetrwać tylko 12. Grupa 50 elementów po cichu zmienia się w 12, fuzja rankingów teraz działa na zbiór zanieczyszczony wierszami przeznaczonymi do odrzucenia, a końcowe ograniczenie LIMIT 20 po cichu zwraca 9 wyników. Zjawisko załamywania wyników pozostaje niezauważone, a w dziennikach nie ma żadnych informacji na ten temat. Każdy pojedynczy wynik nadal wydaje się wiarygodny, więc nikt nie zgłasza błędu. Asystent po prostu stopniowo słabnie przy pytaniach, w których korpus został nierównomiernie przefiltrowany, co w przypadku korpusu dotyczącego tematyki zdrowotnej oznacza pytania o materiały niedawno zrewidowane.

Trzy rozwiązania, w kolejności preferencji:

  1. Wbuduj filtr do częściowego indeksu. To jest powód, dla którego język oraz aktualny flag zostały skopiowane do wiersza embedding. Gdy predykat indeksu będzie równy klauzuli WHERE zapytania, indeks będzie zawierał już tylko wiersze przefiltrowane, a problem zniknie. Metoda ta nadaje się do wymiarów o niskiej kardynalności i wolnym rozwoju, takich jak język, waluta czy poziom źródła, ale nie nadaje się do elementów z wieloma wartościami, ponieważ oznaczałoby to konieczność utrzymywania setki indeksów.
  2. Iteracyjne skanowanie indeksu (pgvector 0.8.0 i nowsze wersje) obejmuje również filtry zbyt dynamiczne, by móc je włączyć bezpośrednio:
SET hnsw.iterative_scan = relaxed_order;
SET hnsw.max_scan_tuples = 100000; -- default is 20000
SET hnsw.scan_mem_multiplier = 4; -- raise if the above is not enough

Gdy jest to włączone, indeks nadal szuka kolejnych kandydatów, aż wystarczająca ich liczba przejdzie filtr. Obowiązują dwa ograniczenia: filtr musi znajdować się na tym samym poziomie zapytania co skanowanie indeksu, w przeciwnym razie nic się nie zmieni, a skanowanie ma swoją górną granicę, więc może nadal zwrócić mniej wierszy, niż prosiło się o to. To łagodzi problem, ale go nie usuwa. Wybór opcji relaxed_order zamiast strict_order poprawia dokładność wyników, ale kosztem takich rezultatów, które nie są dokładnie posortowane według odległości, co jest akceptowalne, jeśli następuje ponowna sortacja.

Szybkość dzięki kwantyzacji binarnej

W dużych skali najtańszym rozwiązaniem jest dwuetapowe wyszukiwanie: pobieranie kandydatów z indeksu binarnego na podstawie odległości Hamminga, a następnie dokładna ponowna ocena tej krótkiej listy.

CREATE INDEX embedding_bit_idx ON embedding
  USING hnsw ((binary_quantize(vec)::bit(1536)) bit_hamming_ops)
  WHERE model = 'emb-v3' AND lang = 'en' AND is_current;
SELECT chunk_id
FROM (
  SELECT chunk_id, vec
  FROM embedding
  WHERE model = 'emb-v3' AND lang = 'en' AND is_current
  ORDER BY binary_quantize(vec)::bit(1536)
           <~> binary_quantize($1::halfvec(1536))
  LIMIT 400
) candidates
ORDER BY vec <=> $1::halfvec(1536)
LIMIT 50;

W pierwszym etapie należy przeliczyć dane w sposób przesadnie obfity, około osiem razy większy niż ostateczna liczba elementów. Dokładna ponowna ocena w drugim etapie koryguje kolejność, więc w etapie binarnym wystarcza jedynie obecność kandydatów, a nie ich prawidłowe pozycje.

Ważna jest dokładność dotycząca oszczędności, ponieważ internet nie gwarantuje ich stabilności. Przy 1 536 wymiarach wektor bitowy zajmuje około 200 bajtów, podczas gdy halfvec potrzebuje około 3 KB, co stanowi 16-krotnie mniejszą ilość danych surowych. Podawana wszędzie wartość 32 razy jest mierzona w odniesieniu do wektora pełnej precyzji, którego ten schemat nie wykorzystuje.

Indeks nie kurczy się 16 razy, a powód jest pouczający. Każda para danych HNSW zawiera stałe obciążenie w postaci kilkuset bajtów, powiązań grafowych oraz nagłówka pary, niezależnie od zawartości. W przypadku 3 KB danych typu halfvec to obciążenie stanowi zbędny element; natomiast przy wektorze bitowym o długości 200 bajtów przewyższa ono samą zawartość. Dodając jeszcze straty spowodowane pakowaniem danych na stronach, rzeczywiste stopień kompresji pozostaje znacznie poniżej pierwotnego stosunku. W tych danych indeks binarny okazał się około 4 do 5 razy mniejszy od indeksu typu halfvec. To nadal decyduje o tym, czy indeks mieści się w pamięci RAM, co jest głównym celem, ale każdy, kto oczekuje 32-krotnego zmniejszenia rozmiaru, będzie nieprzyjemnie zaskoczony. Przed planowaniem pojemności należy zmierzyć rozmiar bazy za pomocą funkcji pg_relation_size oraz używać realistycznych wektorów: jeden test pochłonął całe popołudnie, ponieważ wektory syntetyczne miały wyłącznie wartości dodatnie, funkcja binary_quantize generowała wyłącznie wartość 1, a system Postgres kompresował wszystko do zera.

Pozostała część to rutyna dobrze udokumentowana. Przyszykuj większą ilość pamięci maintenance_work_mem przed tworzeniem indeksów HNSW, w przeciwnym razie proces przenosi się na dysk i trwa godziny zamiast minut. Dla każdej zapytania wybierz wartość hnsw.ef_search: od 100 do 200, gdy błędy są kosztowne, około 40 dla funkcji autodopasowywania. Twórz indeksy produkcyjne JEDNOCZEŚNIE. Testuj je na własnych danych, ponieważ publikowane wskaźniki trafności rzadko się przenoszą.

Ponowna sortowanie i odwaga na nieprzyniesienie żadnych wyników

Fusion zwraca przyzwoite pierwsze 20 wyników, a nie pierwsze 5, a pole zapytania mieści tylko 5 pozycji. Ponowny sorter typu cross-encoder, który analizuje zapytanie i kandydatów jako parę zamiast porównywać przechowywane wektory, zapewnia największy wzrost jakości po hybrydowym wyszukiwaniu. Jest powolny, więc rozpatruje 20 kandydatów, a nie milion.

To, co ludzie pomijają: należy używać wyników ponownego rankowania jako bezwzględnego progu, a nie tylko do ustalania kolejności. Jeśli najlepszy kandydat znajduje się poniżej tego progu, odpowiedzią nie powinien być najlepszy element z niskiej jakości zbioru – to oznaczałoby przyznanie, że korpus nie zawiera żadnych wiarygodnych informacji na dany temat.

W tym miejscu większość asystentów zdrowotnych zawodzi, a rozwiązaniem jest wybór odpowiedniego produktu, który jest zaimplementowany w kodzie. System stworzony do odpowiadania na wszystko będzie wymyślać odpowiedzi na rzadkie pytania. Jeśli ponowne rankowanie służy jedynie do sortowania, model zawsze otrzymuje jakąś odpowiedź, a przyjazny model zawsze coś na to odpisze. Prognozowany minimalny wynik przekształca brak informacji w wyraźną odmowę, zamiast w wymyśloną odpowiedź. Ustal ten próg na podstawie oznaczonych pytań, których odpowiedzi faktycznie brakuje w korpusie, i zachowaj go, gdy użytkownicy skarżą się na zbyt wiele odmów.

Najpierw zrozumienie zapytania

Przed samym wyszukiwaniem znajduje się warstwa, która dodaje opóźnienie: kompaktowy, szybki model, który przeformułowuje pytanie, zanim indeks je zobaczy.

  • Rozszerz i znormalizuj. Przetłumacz język potoczny na słownictwo korpusu, nie odrzucając oryginału. Oba teksty trafiają do hybrydowego zapytania – oryginał do części leksykalnej, a rozszerzenie do części semantycznej.
  • Rozdziel złożone pytania. Zapytanie o to, czy dwa leki można łączyć w czasie ciąży, w rzeczywistości polega na trzech poszukiwaniach: dla każdego leku osobno oraz z uwzględnieniem aspektu ciąży. Wykonaj je oddzielnie, połącz wyniki rankingu i ponownie sklasyfikuj cały zbiór.
  • Rozwiąż problem kontekstu. Dodatkowe pytanie, np. o wyższą dawkę, samo w sobie nie ma sensu. Włącz poprzedni etap rozmowy i sformułuj je ponownie jako odrębne pytanie przed wstawieniem do modelu, ponieważ modele te nie przechowują żadnej pamięci.
  • Klasyfikuj i kieruj. Znaki alarmowe, prośby o indywidualną dawkę oraz wszystko, co przypomina diagnozę, omijają proces wyodrębniania informacji i obierają określoną ścieżkę odpowiedzi. Klasyfikator powinien reagować szybko na wszelkie sygnały. Fałszywie pozytywny wynik to jedynie lekko irytująca informacja „proszę skonsultować się z lekarzem”, natomiast fałszywie negatywny wynik to błąd, który kończy działanie produktu.

Ugruntowanie w odniesieniach strukturalnych

Krok generowania jest krótki. Większość wysiłku pochłania ustalenie formatu wyjściowego, a nie sformułowanie promptu. Każdy wyodrębniony fragment w promptu należy oznaczyć stabilną etykietą oraz wymagać strukturyzowanego wyniku, w którym każde twierdzenie wymienia etykiety, na których się opiera:

{
  "claims": [
    { "text": "…", "sources": [2, 5] },
    { "text": "…", "sources": [1] }
  ],
  "uncertain": true,
  "route_to_clinician": false
}

Sprawdź wynik w kodzie, a nie w instrukcji. Twierdzenie, którego lista źródeł jest pusta, jest usuwane przed wyświetleniem, a jeśli jest zbyt wiele takich twierdzeń, cała odpowiedź jest odrzucana i wtedy aktywuje się ścieżka odrzucenia. To oddziela żądanie uzasadnienia od jego gwarantowania: instrukcja to nadzieja, a walidator to zasada. Wskaźnik odpadów służy również jako bezpłatna metryka produkcji, wskaźnik rzeczywistych halucynacji, który może uruchomić alarmy, nawet jeśli nikt nie czyta transkrypcji.

Ważne były dwie drobne decyzje. Należy pokazywać datę rewizji dokumentu obok każdego odwołania, aby użytkownicy mogli sami ocenić jego aktualność. Ponadto, gdy odzyskane źródła są sprzeczne, należy je ujawnić zamiast je rozstrzygać. Stwierdzenie „Wskazówki się różnią: źródło A mówi X, źródło B mówi Y” jest bardziej przydatne i uczciwe niż syntetyczna średnia.

Ocena czy domysły

Zmiany w procesie wysyłki oparte wyłącznie na intuicji nie są tu do przyjęcia. Możliwe błędy są subtelne, zakres ich występowania jest szeroki, a każda ustawienie wpływa na wszystkie pozostałe. System, który sprawdza się, składa się z kilku warstw.

Tylko pobieranie danych, bez LLM. Kilkaset zapytań z ręcznie oznaczonymi identyfikatorami istotnych fragmentów tekstu, służących do pomiaru wskaźników recall@20 i nDCG@10. Proces trwa kilka sekund, wykrywa większość regresji i, co kluczowe, oddziela jakość pobierania danych od jakości generowania odpowiedzi. Gdy odpowiedzi się pogarszają, natychmiast widać, czy winowajcą jest korpus danych, czy prompt.

Wierność. W przypadku generowanych odpowiedzi należy sprawdzić, czy każde twierdzenie jest poparte fragmentami tekstu, na które się odwołuje. Ocena modelu działa doskonale, o ile oceniający ma prosty zadanie: jedno zdanie, jeden akapit oraz odpowiedź „tak” lub „nie” co do poparcia twierdzenia.

Prawidłowość odrzucenia. Odrębna grupa zawiera pytania, których prawidłowe rozwiązanie polega na odrzuceniu lub kierowaniu do innej ścieżki, dzięki czemu zmiany progu nie mogą po cichu podważyć tego zachowania.

Literatura pokrewna

  • Notatki praktyczne: Bezpieczeństwo bazy danych wektorowych: nowa powierzchnia ataku w systemach RAG — Szczegółowy przewodnik po Notatkach praktycznych: Bezpieczeństwo bazy danych wektorowych: nowa powierzchnia ataku w systemach RAG: kontrakty, sprawdzania oraz gotowe elementy kodu dla zespołów wdrażających ten wzorzec.
  • Docling Pipelines: Budowanie pipeline’ów danych RAG klasy korporacyjnej — Jak docling-pipelines zastępuje kruche skrypty łączące elementy komponentami składalnymi oraz strumieniami JSON: pobieranie danych, wydobywanie informacji, kontrola jakości, generowanie embeddingów oraz przechowywanie wektorów.
  • Tworzenie asystenta RAG w Slack bez oddzielnego bazę danych wektorowej — Bot Slack odpowiada na pytania społeczności na podstawie dokumentów przechowywanych w jednej tabeli StarRocks. Wyświetlanie informacji odbywa się za pomocą zwykłego SQL, a sześć praktycznych wskazówek dotyczy zadań do wykonania, dzielenia treści na fragmenty oraz kosztów.
  • Pełny plan rozwoju RAG: od podstaw do problemów w produkcji — Dwa procesy przetwarzania, trzy kroki oraz lista określonych awarii: jak działa generowanie wzbogacone o dane z bazy, gdzie występują błędy oraz jakie rozwiązania je naprawiają.
  • Przenoszenie RAG z Pinecone do Postgres pgvector: sześć miesięcy później — Wskaźniki w produkcji po przeniesieniu 9 milionów wektorów z Pinecone: koszt, opóźnienie, dokładność odzyskiwania danych, niezawodność, to, co się pogorszyło, oraz kiedy pgvector jest właściwym wyborem.