Startseite / Artikel

Hybrider Suchalgorithmus in Postgres für einen Gesundheitsassistenten: Schema, Chunking, RRF und Bewertungsmethoden.

Hybride Suche in Postgres für einen Gesundheitsassistenten: strukturbezogenes Aufteilen, pgvector mit RRF, binäre Quantisierung, Bewertungsschwellenwerte sowie eine angemessene Bewertung.

Verlag: REACTAPP.TOP19 Min. Lesezeit4283 Wörter

Quellen: RAG in Postgres: What I Learned Building a Health Assistant Where Being Wrong Is Expensive

Demos einer „Gesundheits-KI“ sehen in der Regel identisch aus. Ein öffentliches Datensatz wird in einen Vektorlagerraum eingefügt, eine Anweisung gibt an, dass das Modell ein hilfreicher medizinischer Assistent ist, und auf die ersten drei Fragen erhält man schöne Antworten. Dann kommt die vierte Frage.

Sie enthält eine Verneinung. Oder die richtige Antwort hängt von einem vor achtzehn Monaten überarbeiteten Dokument ab, das eines aus dem Jahr 2011 ersetzt hat. Oder zwei angesehene öffentliche Quellen sind sich uneinig, und das richtige Vorgehen besteht darin, dies zu erwähnen und zu erklären, warum es wichtig ist, anstatt einen Gewinner auszuwählen. Oder die ehrliche Antwort lautet „Ich weiß es nicht, fragen Sie einen Arzt“, und ein auf Hilfsbereitschaft trainiertes Modell schreibt dennoch einen selbstsicheren Absatz.

Die vierte Frage ist der Punkt, an dem das Produkt tatsächlich zum Tragen kommt – und fast keine der dafür notwendigen Aufwände erfolgt im Sprachmodell. Sie liegt in der Datenerfassung, was in diesem Fall Postgres bedeutet. Im Folgenden wird diese Schicht beschrieben: Schema, Chunking, die hybride Suchanfrage, Quantisierung sowie ein Bewertungssystem, das verhindert, dass fehlerhafte Ergebnisse ausgeliefert werden. Es ist unabhängig von einer bestimmten Datengrundlage, und die Prinzipien gelten für jedes Korpus, in dem falsche Antworten teuer sind.

Vier Gründe, warum Gesundheitstexte naive RAG-Methoden überwinden

Offene Gesundheitskorpora weisen vier Eigenschaften auf, die still und heimlich den Standardprozess aus Chunking, Embedding, Kosinusähnlichkeit und Einfügen in die Prompte zerstören.

  1. Die Bedeutung hat keine paragrafenförmige Struktur. Eine Arzneimittelmonografie enthält getrennte Abschnitte für Kontraindikationen, Wechselwirkungen und Dosierung. Wenn man sie in 512-Token-„Sliding Windows“ aufteilt, enthält ein solcher Block teilweise eine Kontraindikation und teilweise eine Dosiertabelle. Der Abruf solcher Blöcke gelingt zwar gut, weist jedoch eine hohe Kosinusähnlichkeit bei geringer Treue zum Original auf. Dies ist der gefährlichste Fehler in der gesamten Systemarchitektur, da die Abrufmetriken weiterhin gut aussehen.
  • Das Vokabular ist bimodal. Die Nutzer beschreiben ein brennendes Gefühl im Magen nach den Mahlzeiten, während das Korpus „epigastrischer Schmerz, postprandial“ angibt. Semantische Suche überbrückt diese Lücke gut. Doch die Nutzer geben auch genaue Identifikatoren ein, wie den Namen eines Medikaments, einen Code oder eine Verbindung, und dabei schneiden die Embeddings schlechter ab als ein Schlüsselwortindex. Ein Modell, das Metoprolol und Metoprolol-Succinat in benachbarte Positionen setzt, hilft jemandem dabei, einen Fehler zu machen. Beide Suchmodi sind notwendig – sie müssen kombiniert werden, anstatt zwischen ihnen gewählt zu werden.
  • Die Aktualität ist semantisch bedeutsam. Anderswo gilt ein altes Dokument als etwas schlechteres Dokument. Im Gesundheitswesen kann es jedoch aktiv schädlich sein, da Anleitungen gerade deshalb überarbeitet werden, weil die frühere Version falsch war. Die Aktualität kann nicht als letzter Entscheidungsfaktor am Ende hinzugefügt werden; sie muss ein erstklassiges Feld sein, über das sowohl die Suchschicht als auch die Antwortschicht berücksichtigt werden.
  • Antworten haben eine rechtliche Form. Einige Fragen müssen weitergeleitet werden, anstatt sorgfältig beantwortet zu werden: Notfallsymptome, Dosierungsentscheidungen für eine bestimmte Person sowie alles, was einer Diagnose gleichkommt. Für diese Fälle ist ein fester, regelbasierter Ablauf erforderlich, der nicht von den Launen des Modells beeinflusst werden darf.
  • Jeder dieser Fälle stellt ein Problem der Informationsabrufschicht dar, was zu einer langweiligen Entscheidung führt, die letztendlich am meisten Gewicht hat.

    Warum Postgres und nicht eine Vektordatenbank

    Der Kompromiss verdient eine klare Darstellung, denn die Aussage „man braucht keine Vektordatenbank“ ist inzwischen unpräzise geworden, und es gibt tatsächliche Fälle, in denen eine solche doch erforderlich ist.

    Eine spezialisierte Vektordatenbank bietet eine bessere Trefferquote pro Millisekunde bei sehr hohem Umfang, reichhaltigere Indexarten, eine auf das Filtern von Anfang an ausgelegte Suchfunktion sowie eine horizontale Skalierung, bei der keine weiteren Überlegungen nötig sind. Bei Hunderten Millionen von Vektoren unter starkem Abfragedruck ist das ein echter Vorteil, den man nutzen sollte.

    Postgres bietet nur eine Transaktionsgrenze. Das klingt unbedeutend, ist es aber nicht. In einem sich ändernden Korpus kostet eine Zwei-System-Architektur echte Ressourcen:

    • Wenn ein Quelldokument überarbeitet wird, müssen seine alten Teile gelöscht, neue eingefügt, die Embeddings neu berechnet und die Metadaten aktualisiert werden – und das alles atomar. Bei einem separaten Vektorlagern gibt es keine Atomarität. Im schlimmsten Fall stimmen der Schlüsselwortindex und der Vektorindex nicht darüber überein, was vorhanden ist, wodurch der Assistent auf ein Dokument verweist, das nicht mehr das ausdrückt, wofür es zitiert wird.
    • Das Filtern nach Lizenz, Gerichtsbarkeit, Quellenautorität, Überarbeitungsdatum und Sprache sowie die anschließende Rangfolge nach Vektorentfernung stellt eine WHERE-Klausel in Postgres dar. Bei zwei Systemen bedeutet dies entweder eine synchron gehaltene Metadatenkopie oder ein Verfahren, bei dem zunächst mehr Daten abgerufen und anschließend gefiltert werden, wodurch stillschweigend weniger Ergebnisse zurückgegeben werden als angefordert.
    • Um herauszufinden, warum eine bestimmte Antwort vor sechs Wochen abgerufen wurde, wird ein Join verwendet.

    Die Skalierungsbeschränkung existiert tatsächlich, ist aber höher, als viele annehmen – mit dem aktuellen pgvector lassen sich problemlos Dutzende Millionen von Dateneinheiten auf einer gut abgestimmten Instanz verarbeiten. Eine sinnvolle Vorgehensweise ist, mit Postgres zu beginnen und eine spätere Migration als Meilenstein des Erfolgs und nicht als Designziel zu betrachten. Ein Projekt, das diese Grenze nie erreicht, vermeidet ganz das Problem der verteilten Systeme. Ein Projekt, das sie erreicht, stellt schließlich ein funktionsfähiges Produkt, echte Abfragenachweise sowie eine klare Übersicht über seine Anforderungen bereit.

    Die Versionen sind wichtig. pgvector 0.7.0 brachte halfvec, sparsevec, bit-basiertes Indexieren sowie binary_quantize mit sich. In Version 0.8.0 wurden iterative Indexsuchen hinzugefügt, die das unten beschriebene Filtern erleichtern, und die 0.8.x-Reihe fügte die Unterstützung für Postgres 18 hinzu. In den Versionen 0.6.x wird der größte Teil dessen, was folgt, nicht wie beschrieben funktionieren.

    Schema

    Vier Tabellen bilden die Grundstruktur. Es gilt die Regel, dass die Herkunft der Daten strukturell angegeben wird – niemals als JSON-Objekt: Alles, was die Antwortsschicht zitieren, filtern oder auswerten muss, erhält eigene Spalten.

    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)
    );
    

    Vier Details sind tragend.

    Embeddings werden nach Chunk und Modell gekennzeichnet. Embedding-Modelle werden sich ändern. Wenn das geschieht, müssen beide Versionen gleichzeitig verfügbar sein, damit sie im echten Traffic A/B getestet und ohne Wartungsfenster ausgetauscht werden können. Legen Sie das Modell von Anfang an in die Primärschlüssel und erstellen Sie einen Teilindex pro Modell, damit jeder kompakt bleibt:

    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;
    

    Sprache und die aktuelle Flagge werden absichtlich sowohl auf den Chunk als auch auf das Embedding kopiert. Dies ist der einzige Punkt, an dem die Normalisierung absichtlich gebrochen wurde, und der Grund dafür wurde erst nach erheblich langer Zeit erkannt. Ein Teilindex-Predicate in Postgres kann nur Spalten der indizierten Tabelle erwähnen. Ein Index auf dem Embedding kann nicht WHERE document.is_current enthalten; Postgres lehnt dies vollständig ab, einschließlich von Unterabfragen. Um heiße Filter in den Vektorindex einzubauen – was aus den im folgenden Abschnitt zur Filterfalle erläuterten Gründen gewünscht ist – müssen diese Spalten physisch in der Embedding-Spur vorhanden sein.

    Es ist schwierig, die Kopien einheitlich zu halten, und beim ersten Versuch wurden die Daten zweimal falsch übertragen. Die aktuelle Flagge wird während der eigentlichen Aufnahme des Dokuments nicht gesetzt. Sie ändert sich, wenn ein späteres Dokument dieses ersetzt, wodurch die Aktualisierung innerhalb der Transaktion des ersetzenden Dokuments stattfindet und alle Blöcke sowie Einbettungszeilen des zurückgezogenen Dokuments erreichen muss. Befassen Sie sich mit dieser Verbreitung in einer Funktion mit einem Aufrufpunkt und schreiben Sie einen Test, der sicherstellt, dass die denormalisierten Werte stets den des übergeordneten Dokuments entsprechen. Wenn die beiden Suchmechanismen sich nicht einigen können, welche Zeilen aktuell sind, mischt die Fusion Positionen aus zwei verschiedenen Korpora, wodurch die Ergebnisse leise verschlechtern, ohne dass ein Fehler auftritt.

    Auch das ist nicht kostenlos. Das Umstellen der Flagge entfernt diese Zeilen aus beiden teilweisen HNSW-Indizes, und HNSW befreit den Speicher nur bei einer vollständigen Säuberung, wodurch bei häufigen Änderungen ein Anstieg des Indexumfangs zu beobachten ist.

    Die Vektorspalte ist halfvec und nicht vector. Das Speichern von 16-Bit-Flotten reduziert die Rohdatenmenge von 6.152 Bytes auf 3.076 bei 1.536 Dimensionen, und der HNSW-Index schrumpft noch weiter, da kleine Tupel enger in die Seiten gepackt werden. Bei der Abfrage müssen hier nur etwa 50 Kandidaten an einen Reranker übergeben werden, und in Tests blieb die Recall-Lücke im Rahmen des Rauschens. Zusätzlich kann halfvec bis zu 4.000 Dimensionen indiziert werden, während normale Vektoren bei 2.000 enden – was bei großen Embedding-Modellen hilft.

    Die Übernahme wird als Zeiger auf dieselbe Tabelle gespeichert, nicht als Boolean-Wert. Ein einfacher, veralteter Marker sagt dem Assistenten zu wenig. Wenn bekannt ist, welches Dokument dieses ersetzt hat, kann er erklären, dass sich die Anleitung geändert hat, und auf die aktuelle Version verweisen. Das erfordert eine Verknüpfung, die nur möglich ist, wenn die Verbindung bei der Eingabe erfasst wurde.

    Das Chunking ist der wichtigste Aspekt

    Falls es hier eine Lektion gibt: Der Chunker ist wichtiger als das Embedding-Modell. Ein besseres Modell bringt nur wenige Punkte im Recall. Ein Chunker, der die Struktur berücksichtigt, unterscheidet einen korrekten Assistenten von einem, der sich sicherlich irrt. Es gelten drei Regeln.

    Zuerst die Struktur, dann die Größe. Analysieren Sie jedes Dokument in einen Überschriftenbaum um und halten Sie jeden Chunk innerhalb einer obersten Ebene. Wenn eine Ebene kleiner ist als die gewünschte Größe, lassen Sie sie klein – anstatt sie mit ihrem Nachbarn zu verbinden: Eine 60-Token-Beschränkung bedeutet lediglich, dass dies nützlicher ist als ein 500-Token-Chunk, in dem die Informationen untergehen. Token-Beschränkungen sind nur ein Ersatz, wenn die Struktur nicht ausreicht – niemals die Hauptregel.

    Trennen Sie eine Tabelle niemals auf. Gesundheitstabellen bestehen in der Regel aus Dosisgittern, Wechselwirkungsmatrizen oder Eignungskriterien – Inhalten, bei denen die Hälfte der Zeilen nutzloser ist als gar keine. Wenn der Budgetrahmen überschritten wird, teilen Sie die Tabelle in Zeilengruppen auf, wiederholen Sie den Überschriftsteil für jede Gruppe und bringen Sie das Ergebnis in Satzform, bevor Sie es einbetten. Ein Satz wie „Alter 6 bis 11 Jahre: 5 mg einmal täglich“ lässt sich weitaus besser einbetten als ein mit Pipe-Trennern versehenes Gitter.

    Fügen Sie vor dem Einbetten den Kontext voran. Der eingebettete Text sollte nicht der rohe Auszug sein, sondern der Auszug mit wiederhergestellter Herkunft: eine Überschriftenzeile mit Angabe der Quelle, des Dokumententitels und des Überarbeitungsdatums, anschließend der Abschnittspfad wie „Gegenanzeichen“, dann „Nierenfunktionsstörung“ und schließlich der Textinhalt. Einbetten Sie diesen, und behalten Sie den rohen Textinhalt für die Anzeige und Zitation bei. Ein Fragment wie „Nicht bei Patienten mit vermindertem Clearance verwenden“ sagt als isolierter Hinweis fast nichts, da es weder ein Medikament noch eine Erkrankung noch einen Kontext nennt. Es wird erst dann auffindbar, wenn die Überschrift dem Modell mitteilt, worum es geht. Diese einzige Änderung verbesserte die Erinnerungsfähigkeit mehr als jeder versuchte Modellwechsel, und sie kostet lediglich Token zur Verarbeitung.

    Zwei Umsetzungshinweise folgen. Berechnen Sie den Inhaltshash für den kontextualisierten Text, damit eine erneute Einpflege, die nur einen Überschriftsbereich ändert, korrekt eine erneute Einbettung auslöst. Führen Sie außerdem den gesamten Prozess der Umwandlung von Dokument in Blöcke und anschließend in Einbettungen innerhalb einer Transaktion durch, damit das Korpus niemals nur teilweise aktualisiert wird:

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

    Diese Transaktion ist der einzige Grund, warum dies in Postgres gespeichert ist.

    Hybride Abfrage mit rekursiver Rangfusion

    Jede Abfrage führt eine semantische Suche sowie eine Schlüsselwortsuche unabhängig voneinander durch und fügt die Ergebnisse anschließend nach Rang zusammen, niemals nach dem Rohwert. Das Zusammenführen der Werte funktioniert nicht: Der Kosinusabstand sowie ts_rank_cd liegen auf unverwandten Skalen, sind zwischen den Abfragen nicht vergleichbar, und jegliche Gewichte, die einer Abfragesammlung zugeordnet werden, führen zu einem Overfitting. Die rekursive Rangfusion umgeht dieses Problem vollständig, indem sie ausschließlich auf den Positionen berücksichtigt wird.

    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;
    

    Ein Element, das in Bezug auf Bedeutung auf dem dritten und in Bezug auf Schlüsselwörter auf dem siebten Platz liegt, schneidet besser ab als eines, bei dem nur ein einziger Faktor den ersten Platz belegt. Das ist beabsichtigt, da zwei übereinstimmende unabhängige Signale mehr zählen als ein einziges starkes Signal. websearch_to_tsquery ist der geeignete Parser, da er echte Benutzereingaben, in Anführungszeichen gesetzte Phrasen sowie Ausnahmen durch Minuszeichen verarbeitet, ohne bei fehlerhaften Texten zu versagen. ts_rank_cd ist hier besser als der einfache ts_rank, weil die Abdeckungsdichte Begriffe bevorzugt, die nahe beieinander vorkommen – was für Fragen geeignet ist, die mehrere Konzepte kombinieren.

    Zwei Eigenschaften dieser Abfrage wirken wie Stilentscheidungen, sind es aber nicht.

    Falle Nummer eins: Der Abfravektor muss ein skalarer Unterabfrage sein

    Im semantischen Ansatz kommt der Abfravektor als skalarer Unterabfrage an, nicht als Spalte aus einer zusammengeführten Parameterbeziehung. Das ist der Unterschied zwischen einer Millisekunde und einer Minute.

    Die intuitive Version wählt gleichzeitig aus der Einbettungstabelle und einer Parameterbeziehung aus und ordnet anschließend nach der Entfernung zur Parameterspalte. Sie funktioniert einwandfrei, ist korrekt und liefert die richtigen Zeilen – genau deshalb übersteht sie die Code-Review. Das Problem ist jedoch, dass der Planer einen Nachbarn-Index nur dann verwenden kann, wenn die ORDER BY-Anweisung die indizierte Spalte mit etwas Vergleichbarem für die gesamte Durchsuchung vergleicht. Ein Wert, der über eine Verknüpfung eingeholt wird, erfüllt diese Voraussetzung nicht, sodass es überhaupt keinen geordneten Suchweg gibt: Postgres liest jede Zeile und sortiert sie anschließend.

    Bei fünfzigtausend Blöcken kostet das einige hundert Millisekunden – langsam genug, um es zu spüren, und unklar genug, um es dem Netzwerk zuzuschreiben. Bei fünf Millionen tritt ein Timeout ein.

    Überprüfen statt zu vertrauen. Erklären Sie die Abfrage in CI und stellen Sie sicher, dass der Plan einen Indexscan über den HNSW-Index mit einer Order By-Anweisung enthält. Ein sequenzieller Scan gefolgt von einem Sortieren bedeutet, dass der Vektorindex nur dekorativ ist. Ein Test, der bei der Erstellung darauf verzichtet, lohnt sich, denn eine gut gemeinte Refaktorierung kann das Problem erneut einbringen, ohne dass es zu einem einzigen falschen Ergebnis kommt – lediglich zu langsamen Ergebnissen in der Produktion.

    Falle zwei: Filtern nach dem LIMIT

    Die Filter, nämlich aktuell, Sprache und nicht ersetzt, gehören in die Unterabfrage jedes Arms, vor dem LIMIT. Die erste Version platzierte sie in der äußeren Abfrage – am offensichtlichen, aber falschen Ort.

    HNSW liefert annähernde nächstgelegene Nachbarn. Man nimmt die 50 am weitesten entfernten, filtert anschließend – und vielleicht bleiben nur 12 übrig. Die ursprünglichen 50 werden stillschweigend auf 12 reduziert, die Rangfusion arbeitet nun mit einer durch Zeilen, die zur Ablehnung bestimmt sind, kontaminierten Menge, und ein finaler LIMIT 20 liefert leise 9 Ergebnisse. Der Recall bricht ein, doch in den Protokollen steht nichts davon. Jedes einzelne Ergebnis erscheint weiterhin plausibel, weshalb niemand einen Fehler meldet. Der Assistent wird bei Fragen, bei denen das Korpus ungleichmäßig gefiltert wird, einfach subtil schlechter – was im Kontext eines Gesundheitskorpus Fragen zu kürzlich überarbeitetem Material bedeutet.

    Drei Lösungen, in Reihenfolge der Priorität:

    1. Integrieren Sie den Filter in einen Teilindex. Deshalb wurden Sprache und die aktuelle Flagge in die Embedding-Spur kopiert. Sobald das Indexprädikat mit der WHERE-Klausel der Abfrage übereinstimmt, enthält der Index bereits nur noch die gefilterten Zeilen und das Problem ist gelöst. Dies eignet sich für Dimensionen mit geringer Kardinalität und langsamer Änderung wie Sprache, Währung oder Quellenstufe, passt aber nicht zu Elementen mit vielen Werten, da dies den Aufwand erfordern würde, hundert Indexe zu pflegen.
    2. Iterative Indexscans (pgvector 0.8.0 und neuer) decken auch solche Filter ab, die zu dynamisch sind, um direkt integriert zu werden:
    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
    

    Durch diese Einstellung sucht der Index weiter nach weiteren Kandidaten, bis ausreichend viele den Filter bestehen. Es gelten zwei Grenzen: Der Filter muss auf derselben Abfrageebene wie die Indexsuche liegen, sonst ändert sich nichts, und die Suche hat eine Obergrenze, sodass möglicherweise weniger Zeilen zurückgegeben werden als angefordert. Dadurch wird das Problem abgemildert, aber nicht beseitigt. Die Auswahl von relaxed_order anstelle von strict_order verbessert die Vollständigkeit, auf Kosten von Ergebnissen, die nicht exakt nach Entfernung sortiert sind – was in Ordnung ist, wenn anschließend ein Neusortierer verwendet wird.

    Schnelligkeit durch binäre Quantisierung

    In großem Maßstab ist der kostengünstigste Ansatz eine zweistufige Abfrage: Zuerst werden Kandidaten anhand der Hamming-Distanz aus einem binären Index ausgewählt, anschließend wird diese Kurzliste genau neu bewertet.

    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;
    

    In der ersten Phase sollte großzügig überabgerufen werden, etwa achtmal so viel wie der endgültige Pool. Die genaue Neubewertung in der zweiten Phase korrigiert die Reihenfolge, sodass die binäre Phase nur die Präsenz der Kandidaten erfordert, nicht die korrekten Ränge.

    Die Genauigkeit bezüglich der Einsparungen ist wichtig, da im Internet mit diesen Werten oft nachlässig umgegangen wird. Bei 1.536 Dimensionen beträgt die Größe eines Bit-Vektors etwa 200 Bytes, während ein HalfVec etwa 3 KB benötigt – das sind 16-mal weniger Rohdaten. Die überall angegebene Zahl von 32-mal weniger bezieht sich auf Vektoren mit voller Präzision, die in diesem Schema nicht verwendet werden.

    Der Index schrumpft nicht um das 16-fache, und der Grund dafür ist aufschlussreich. Jede HNSW-Tupel trägt unabhängig vom Payload einen festen Overhead von einigen Hundert Bytes, Graph-Verbindungen sowie einen Tupel-Header. Bei 3 KB an HalfVec-Daten ist dieser Overhead vernachlässigbar; bei einem 200-Byte-Bitvektor übersteigt er jedoch den Payload. Wenn noch der durch Seitenkomprimierung entstehende Verlust hinzukommt, bleibt die tatsächliche Kompression deutlich unter dem ursprünglichen Verhältnis. Bei diesen Daten war der binäre Index etwa 4 bis 5 Mal kleiner als der HalfVec-Index. Das bestimmt zwar weiterhin, ob ein Index in den RAM passt – was schließlich das Ziel ist –, doch wer mit einem 32-fachen Kompressionsfaktor rechnet, wird unangenehm überrascht werden. Messen Sie mit pg_relation_size, bevor Sie die Kapazität planen, und verwenden Sie realistische Vektoren: Ein Benchmark verschwendete einen Nachmittag, weil die synthetischen Vektoren ausschließlich positive Werte hatten, binary_quantize erzeugte ausschließlich Einsen, und Postgres komprimierte alles auf nichts.

    Der Rest ist Routine und gut dokumentiert. Geben Sie vor dem Erstellen von HNSW-Indizes mehr Speicherplatz für maintenance_work_mem – sonst wird die Erstellung auf die Festplatte geschrieben und dauert Stunden statt Minuten. Wählen Sie für jede Abfrage einen Wert für hnsw.ef_search: 100 bis 200, wenn Fehler teuer sind, etwa 40 für Autocomplete. Erstellen Sie die Produktions-Indizes GLEICHZEITIG. Testen Sie mit Ihren eigenen Daten, da veröffentlichte Rückerinnerungsquoten selten übertragbar sind.

    Neubewertung und der Mut, nichts zurückzugeben

    Fusion liefert eine anständige Liste der besten 20 Ergebnisse, nicht der besten 5 – und die Anfrage bietet nur Platz für 5 Ergebnisse. Ein cross-encoder-basierter Neubewertungsalgorithmus, der Abfrage und Kandidaten als Paar betrachtet anstatt gespeicherte Vektoren zu vergleichen, bringt nach der hybriden Suche den größten Qualitätszuwachs. Er ist jedoch langsam, weshalb er nur 20 Kandidaten statt einer Million betrachtet.

    Was viele übersehen: Die Bewertungen des Neurankers sollten als absoluter Schwellenwert verwendet werden, nicht nur zur Sortierung. Wenn der beste Kandidat unter dieser Schwellenwertgrenze liegt, ist die richtige Ausgabe nicht einfach der beste aus einer schlechten Menge – es handelt sich vielmehr um ein Eingeständnis, dass das Korpus zu diesem Thema keine zuverlässigen Informationen enthält.

    Genau hier versagen die meisten Assistenten für Gesundheitsfragen, und die Lösung liegt in der Wahl eines Produkts, das dies im Code umsetzt. Ein System, das darauf ausgelegt ist, auf alles eine Antwort zu geben, erfindet bei seltenen Fragen Inhalte. Wenn das Neuranking lediglich sortiert, erhält das Modell stets eine Ausgabe, und ein akzeptables Modell schreibt immer etwas zurück. Eine Schwellenwertgrenze verwandelt ein fehlgeschlagenes Auffinden in eine klare Ablehnung statt in eine flüssig erfundene Antwort. Legen Sie die Schwellenwertgrenze anhand von beschrifteten Fragen fest, deren Antworten im Korpus fehlen, und beibehalten Sie sie, wenn Nutzer über zu viele Ablehnungen klagen.

    Zuerst die Anfrage verstehen

    Vor der Abfrage liegt eine Schicht, die zu Verzögerungen führt: ein kompaktes, schnelles Modell, das die Frage umformuliert, bevor der Index sie sieht.

    • Erweitern und normalisieren. Übersetzen Sie Alltagssprache in das Vokabular des Korpus, ohne den ursprünglichen Text zu verwerfen. Beide Versionen gelangen in die hybride Abfrage – der ursprüngliche Text in den lexikalischen Teil und die Erweiterung in den semantischen Teil.
    • Komplexe Fragen aufteilen. Eine Abfrage, die fragt, ob zwei Medikamente während der Schwangerschaft kombiniert werden können, erfordert eigentlich drei Suchvorgänge: jeweils für jedes Medikament sowie für den Aspekt der Schwangerschaft. Führen Sie diese getrennt durch, fügen Sie die Ranglisten zusammen und ordnen Sie die kombinierte Liste erneut.
    • Kontext berücksichtigen. Eine Nachfrage wie die nach einer höheren Dosis ist für sich genommen sinnlos. Berücksichtigen Sie den vorherigen Schritt und formulieren Sie ihn als eigenständige Frage neu, bevor Sie ihn einbetten – schließlich speichern Embeddings kein Gedächtnis.
  • Klassifizieren und routen. Notfallzeichen, Anfragen nach individueller Dosisgabe sowie alles Diagnoseähnliche werden übersprungen und folgen einem deterministischen Antwortweg. Der Klassifikator sollte leicht ausgelöst werden. Ein falsch positives Ergebnis führt lediglich zu der leicht ärgerlichen Meldung „Bitte sprechen Sie mit einem Arzt“, während ein falsch negatives Ergebnis zum Versagen des Produkts führt.
  • Verankerung durch strukturelle Verweise

    Der Generierungsprozess ist kurz. Der größte Aufwand entfällt auf die Ausgabenvorgaben und nicht auf die Formulierung der Anfrage. Beschriften Sie jeden aus der Anfrage abgerufenen Teil mit einem stabilen Tag und verlangen Sie eine strukturierte Ausgabe, bei der jede Aussage die darauf beruhenden Tags auflistet:

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

    Überprüfen Sie das Ergebnis im Code, nicht in der Anfrage. Eine Aussage, deren Quellliste leer ist, wird vor der Anzeige entfernt, und wenn zu viele Aussagen entfernt werden, wird die gesamte Antwort verworfen und der Ablehnungsweg aktiviert. Dadurch wird die Anfrage nach Begründungen von deren Gewährleistung getrennt: Eine Anfrage ist eine Hoffnung, ein Validierer ist eine Regel. Die Ausschussrate dient außerdem als kostenlose Leistungsmetrik, ein Indikator für Live-Halluzinationen, der Alarme auslösen kann, ohne dass jemand die Transkripte liest.

    Zwei weitere kleinere Entscheidungen waren wichtig. Zeigen Sie das Überarbeitungsdatum des Dokuments neben jeder Zitation, damit die Nutzer selbst die Aktualität beurteilen können. Und wenn die abgerufenen Quellen im Widerspruch zueinander stehen, zeigen Sie diesen Widerspruch anstelle einer Lösung an. „Die Leitlinien unterscheiden sich: Quelle A besagt X, Quelle B besagt Y“ ist nützlicher und ehrlicher als ein synthetisierter Durchschnitt.

    Bewertung oder Raten

    Es ist hier nicht möglich, die Versandregelungen nach Gefühl zu ändern. Die Fehlermöglichkeiten sind subtil, das Spektrum ist breit, und jede Einstellung beeinflusst alle anderen. Der funktionierende Ansatz basiert auf mehreren Schichten.

    Nur Abruf, kein LLM. Einige hundert Abfragen mit manuell beschrifteten relevanten Chunk-IDs, wobei Recall@20 und nDCG@10 gemessen werden. Die Auswertung erfolgt in Sekunden, erfasst die meisten Regressionen und trennt vor allem die Qualität des Abrufs von der Qualität der Erzeugung. Wenn sich die Antworten verschlechtern, zeigt sich sofort, ob das Problem im Korpus oder im Prompt liegt.

    Treue. Bei generierten Antworten sollte überprüft werden, ob jede Behauptung durch die zitierten Chunks gestützt wird. Die Bewertung des Modells funktioniert gut, solange der Bewertende nur eine kleine Aufgabe erhält: einen Satz, einen Absatz sowie eine Ja- oder Nein-Antwort zur Unterstützung.

    Korrektheit der Ablehnung. Eine separate Gruppe enthält Fragen, bei denen die richtige Handhabung darin besteht, eine Ablehnung oder eine Umleitung vorzunehmen, sodass Änderungen der Schwellenwerte dieses Verhalten nicht heimlich untergraben können.

    Verwandte Literatur

  • Praktische Hinweise: Sicherheit von Vektor-Datenbanken – Die neue Angriffsfläche in RAG-Systemen — Detaillierte Anleitung zu den Praktischen Hinweisen: Sicherheit von Vektor-Datenbanken: Die neue Angriffsfläche in RAG-Systemen: Verträge, Überprüfungen sowie Code-Blöcke für Teams, die dieses Muster einsetzen.
  • Docling-Pipelines: Aufbau von RAG-Datenpipelines unternehmensreifer Qualität — Wie docling-pipelines brüchige „Klebstoff-Skripte“ durch komponierbare Operatoren und JSON-Flüsse ersetzt: Eingabe, Extraktion, Qualitätskontrollen, Embeddings sowie Vektorlagern.
  • Ein Slack RAG-Assistent ohne separate Vektordatenbank erstellen — Ein Slack-Bot beantwortet Fragen der Community aus in einer StarRocks-Tabelle gespeicherten Dokumenten. Die Datenabfrage erfolgt mithilfe einfacher SQL-Anfragen, und sechs praktische Lektionen behandeln Anfragenformulierungen, Aufteilung der Daten in Blöcke sowie Kostenaspekte.
  • Ein Überblick über RAG: Von den Grundprinzipien bis zu Produktionsfehlern — Zwei Pipelines, drei Schritte sowie eine Liste benannter Fehler: Wie Retrieval-augmented Generation funktioniert, wo es versagt und welche Lösungen jedes Problem beheben.
  • RAG von Pinecone zu Postgres pgvector verschieben: Sechs Monate später — Produktionszahlen nach dem Umzug von 9 Mio. Vektoren von Pinecone: Kosten, Latenz, Recall, Zuverlässigkeit, was sich verschlechtert hat, und wann pgvector die richtige Wahl ist.