Главная / Статьи

Гибридный поиск в Postgres для помощника в сфере здравоохранения: схема данных, разбиение на части, алгоритм RRF и методы оценки.

Гибридный поиск в Postgres для ассистента по здоровью: разбиение на части с учетом структуры, pgvector с RRF, двоичная квантизация, пороги оценки и корректная оценка.

Издатель: REACTAPP.TOP19 мин чтения4283 слов

Источники: RAG in Postgres: What I Learned Building a Health Assistant Where Being Wrong Is Expensive

Демонстрации «ИИ в области здравоохранения» обычно выглядят одинаково. Публичный набор данных загружается в векторный хранилище, задаётся запрос о том, что модель является полезным медицинским помощником, и на первые три вопроса приходят отличные ответы. Затем поступает четвёртый вопрос.

В нём содержится отрицание. Или правильный ответ зависит от документа, отредактированного восемнадцать месяцев назад и заменившего документ 2011 года. Или два авторитетных публичных источника противоречат друг другу, и правильно в этом случае указать это и объяснить, почему это важно, а не выбрать один из вариантов. Или честный ответ — «Я не знаю, спросите у врача», — но модель, обученная быть полезной, всё равно пишет уверенный абзац.

Именно в этом четвертом вопросе и находится суть продукта, причем почти вся работа по его ответу не выполняется в языковой модели. Она осуществляется на этапе поиска информации, в данном случае с использованием Postgres. Далее описывается этот слой: схема данных, разбиение на фрагменты, гибридный запрос к поиску, квантизация и механизм оценки, предотвращающий появление ошибок в результатах. Он независим от какого-либо конкретного источника данных, и эти принципы применимы к любому корпусу, где неверный ответ имеет серьезные последствия.

Четыре способа, как медицинский текст превосходит примитивные подходы RAG

Открытые медицинские корпуса обладают четырьмя особенностями, которые незаметно разрушают стандартную процедуру обработки: разбиение на фрагменты, генерация векторных представлений, использование коэффициента косинусного сходства и включение полученных данных в запрос.

  1. Значение не имеет формы абзаца. В монографии препарата есть отдельные разделы, посвящённые противопоказаниям, взаимодействию и дозировке. Если разделить её на окна размером 512 токенов, некоторые фрагменты будут содержать по половине информации из раздела противопоказаний и по половине из таблицы дозировок. Такие фрагменты отлично находятся с помощью алгоритмов поиска, показывая высокое значение косинусного сходства, но при этом имея низкую точность. Это самая опасная проблема в такой системе, поскольку показатели поиска всё равно кажутся нормальными.
  • Лексика имеет двумодальную структуру. Пользователи описывают жжение в желудке после еды, тогда как в корпусе данных указано «боль в эпигастрии, постпрандиальная». Семантический поиск хорошо сглаживает эту разницу. Однако пользователи также вводят точные идентификаторы, такие как название лекарства, код или химическое соединение, и в таких случаях эмбеддинги работают хуже, чем индекс по ключевым словам. Модель, которая размещает метопролол и метопролол сукцинат в соседних точках, может помочь кому-то совершить ошибку. Необходимы оба способа поиска, объединённые вместе, а не выбор между ними.
  • Недавность — это часть семантики. В других областях старый документ считается немного худшим. В медицине же это может быть активно вредно, поскольку рекомендации корректируются именно из-за ошибок в более ранних версиях. Недавность не может быть фактором, принимаемым в последнюю очередь; она должна быть первостепенным параметром, который учитывают как слой поиска, так и слой формирования ответа.
  • Ответы имеют определенную юридическую форму. Некоторые вопросы требуют не тщательного ответа, а определенного порядка действий: симптомы чрезвычайных ситуаций, решения относительно дозировки для конкретного человека и всё, что может считаться диагнозом. Для таких случаев необходим строгий, основанный на правилах алгоритм, который не может изменяться в зависимости от настроения модели.
  • Каждый из этих случаев представляет собой проблему уровня поиска информации, что приводит к скучному, но имеющему наибольшее значение решению.

    Почему Postgres, а не векторная база данных

    Этот компромисс заслуживает четкого обоснования, поскольку тезис о том, что «векторная база данных не нужна», стал слишком упрощенным, и существуют реальные ситуации, когда она действительно необходима.

    Специализированная база данных векторов обеспечивает лучшую точность восстановления данных за миллисекунду даже при очень высоких объемах данных, поддерживает более разнообразные типы индексов, предусматривает фильтрованный поиск, рассчитанный на фильтрацию с самого начала, а также позволяет осуществлять горизонтальное масштабирование без дополнительных усилий. Наличие сотен миллионов векторов при интенсивных запросах является реальным преимуществом, которое следует использовать.

    Postgres предоставляет лишь один границу транзакции. Это может показаться незначительным, но на деле это серьезная проблема. В корпусе данных, который постоянно меняется, архитектура с двумя системами влечет за собой значительные издержки:

    • При изменении исходного документа необходимо атомарно удалить его старые фрагменты, вставить новые, пересчитать векторные представления и обновить метаданные. При использовании отдельного хранилища векторов атомарность отсутствует. В худшем случае индекс ключевых слов и векторный индекс могут расходиться во мнениях относительно того, что существует, в результате чего ассистент может сослаться на документ, который уже не содержит информации, по которой он был цитирован.
    • Фильтрация по лицензии, юрисдикции, источнику, дате ревизии и языку с последующей классификацией по векторному расстоянию представляет собой оператор WHERE в Postgres. В двух системах это означает либо синхронизированную копию метаданных, либо алгоритм загрузки большого объема данных с последующей фильтрацией, который тихо возвращает меньше результатов, чем было запрошено.
    • Определение причины того, почему определенный ответ был получен шесть недель назад, осуществляется с помощью операции объединения.

    Предел масштабируемости действительно существует, но он выше, чем предполагают многие: с текущей реализацией pgvector один хорошо настроенный инстанс может обрабатывать десятки миллионов элементов. Разумное решение — начинать с Postgres и рассматривать последующую миграцию как этап успеха, а не как цель проектирования. Проект, который никогда не достигает этого предела, избегает целого ряда проблем, связанных с распределенными системами. Проект, достигший этого предела, имеет готовый продукт, реальные журналы запросов и четкое представление о своих потребностях.

    Версии имеют большое значение. В pgvector 0.7.0 появились функции halfvec, sparsevec, индексация типа bit и функция binary_quantize. Версия 0.8.0 добавила возможность итеративного сканирования индексов, что облегчает решение проблемы фильтрации, описанной ниже; кроме того, версии серии 0.8.x получили поддержку Postgres 18. В версиях 0.6.x большинство описанных ниже функций не будут работать так, как предусмотрено.

    Схема

    Дизайн реализован в четырех таблицах. Соблюдается принцип, согласно которому информация о происхождении данных имеет структурированную форму и никогда не представляет собой JSON-блок: всё, что необходимо учитывать, фильтровать или анализировать на уровне ответов, получает собственную колонку.

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

    Четыре параметра являются несущими.

    Эмбеддинги привязаны к блокам и моделям. Модели эмбеддингов могут меняться. Когда это происходит, обе версии должны быть активны одновременно, чтобы их можно было тестировать в режиме A/B на реальном трафике и заменять без необходимости периода технического обслуживания. С самого начала используйте модель в качестве первичного ключа и создавайте частичный индекс для каждой модели, чтобы они оставались компактными:

    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;
    

    Язык и текущий флаг намеренно копируются как в чанк, так и в эмбеддинг. Именно здесь нормализация была нарушена специально, и причину этого пришлось долго и с неловкостью выяснять. Предикат частичного индекса в Postgres может упоминать только столбцы таблицы, на которой создан индекс. Индекс на эмбеддинге не может содержать условие WHERE document.is_current; Postgres категорически отклоняет такие запросы, включая подзапросы. Чтобы встроить критически важные фильтры в векторный индекс, что необходимо по причинам, описанным ниже в разделе об опасностях фильтрации, эти столбцы должны физически находиться в строке эмбеддинга.

    Обеспечение точности копий — сложная задача: при первой попытке были допущены две ошибки. Флаг не устанавливается во время самого ввода документа; он меняется, когда какой-то последующий документ заменяет этот, поэтому обновление выполняется в рамках транзакции заменяющего документа и должно затронуть каждый фрагмент и строку встраивания, принадлежащие устаревшему документу. Необходимо объединить всю эту логику в одну функцию с одним местом вызова и написать тест, проверяющий, что денормализованные значения всегда совпадают с значениями родительского документа. Когда две системы поиска расходятся во мнении относительно того, какие строки являются актуальными, процесс слияния смешивает данные из двух разных корпусов, в результате чего качество результатов постепенно ухудшается без возникновения явных ошибок.

    Это тоже не бесплатно: изменение флага приводит к удалению этих строк из обоих частичных индексов HNSW, а индексы HNSW освобождают пространство только при полной очистке, поэтому частые изменения данных приводят к увеличению размера индексов.

    Столбец вектора — это halfvec, а не vector. Хранение 16-битных чисел с плавающей точкой сокращает объем данных с 6,152 байт до 3,076 при 1,536 измерениях, а индекс HNSW сокращается еще больше, поскольку небольшие туплы упаковываются более плотно на страницах. Для поиска здесь достаточно передать примерно 50 кандидатов ранжировщику, и в ходе тестирования разница в точности воспроизведения оставалась в пределах шума. Плюс: halfvec можно индексировать до 4,000 измерений, тогда как обычные векторы ограничиваются 2,000, что полезно для крупных моделей эмбеддингов.

    Информация о замене хранится в виде указателя на ту же таблицу, а не в виде булевого значения. Простой маркер устаревания сообщает ассистенту слишком мало информации. Знание того, какой документ заменил этот, позволяет ему объяснить, что руководство изменилось, и предоставить ссылку на текущую версию. Это возможно только при наличии связи, зафиксированной при вводе данных.

    Именно в разбиении на чанки кроется наибольшая польза

    Если здесь есть какой-то урок, то он заключается в том, что инструмент разбиения текста важнее, чем модель эмбеддингов. Лучшая модель приносит небольшое улучшение в показателях воспроизведения информации. Инструмент разбиения, учитывающий структуру текста, позволяет отличить правильный ответ ассистента от ошибочного, даже если ассистент уверен в своем ответе. Действуют три правила.

    Сначала структура, затем размер. Разбейте каждый документ на дерево заголовков и сохраняйте каждый фрагмент внутри одного верхнего уровня структуры. Если раздел слишком мал для заданного размера, оставьте его в том виде, а не соединяйте с соседним разделом: ограничение в 60 токенов более полезно, чем фрагмент из 500 токенов, в котором информация теряется. Ограничения по количеству токенов являются вариантом на случай, когда структура не позволяет справиться с задачей, но никогда не должны быть основным правилом.

    Никогда не делите таблицу. Таблицы с информацией о здоровье обычно представляют собой сетки дозировок, матрицы взаимодействий или критерии пригодности — именно в таких случаях половина строк бесполезна. Если объем данных превышает установленный лимит, разделите таблицу на группы строк, повторите заголовок для каждой группы и преобразуйте полученный формат в предложения перед вставкой. Фраза вроде «Возраст от 6 до 11 лет: 5 мг один раз в день» вставляется гораздо лучше, чем таблица, разделенная тире.

    Перед вставкой укажите контекст заранее. Вставляемый текст не должен представлять собой сырой фрагмент, а должен включать всю необходимую информацию: строку заголовка с указанием источника, названия документа и даты ревизии, затем пути к разделу, например «Противопоказания», далее «Поражение почек», и только после этого — основной текст. Вставляйте именно такой формат, а сырой текст сохраняйте для отображения и цитирования. Фрагмент вроде «Не использовать у пациентов с сниженной клиренс-способностью» сам по себе почти ничего не говорит, поскольку в нем не указано ни лекарство, ни состояние, ни контекст. Его можно найти только тогда, когда заголовок сообщит модели, о чём речь. Это единственное изменение улучшило точность поиска больше, чем любые попытки замены модели, причём оно стоит лишь нескольких токенов во время обработки.

    Ниже приведены два замечания по реализации. Рассчитайте хеш содержимого для контекстуализированного текста, чтобы повторная загрузка, изменяющая только заголовок, корректно запускала процесс повторной инкорпорации. Кроме того, выполняйте всю замену документа на фрагменты, а затем на эмбеддинги в рамках одной транзакции, чтобы корпус никогда не оставался частично обновленным:

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

    Именно эта транзакция является основной причиной хранения данных в Postgres.

    Гибкий поиск с слиянием рангов

    Каждый запрос выполняет семантический поиск и поиск по ключевым словам отдельно, затем сливает результаты по рангу, а не по первоначальным оценкам. Слияние оценок неэффективно: косинусное расстояние и ts_rank_cd находятся на несвязанных шкалах, несопоставимы между собой в разных запросах, и любые веса, применяемые к одному набору запросов, приведут к чрезмерной адаптации к нему. Слияние рангов полностью обходит эту проблему, учитывая только позиции в результатах поиска.

    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;
    

    Элемент, занявший третье место по смысловому содержанию и седьмое по количеству ключевых слов, превосходит тот, в котором только один показатель оказался на первом месте. Так и задумано, поскольку согласование двух независимых сигналов имеет большее значение, чем действие одного сигнала. websearch_to_tsquery является наиболее подходящим парсером, так как он способен обрабатывать реальные данные пользователей, фразы в кавычках, а также указания на исключения с помощью знака минус, не терпя при этом ошибок при обработке некорректного текста. Здесь ts_rank_cd превосходит обычный ts_rank, поскольку плотность покрытия благоприятствует терминам, находящимся рядом друг с другом, что подходит для вопросов, объединяющих несколько концепций.

    Два аспекта этого запроса кажутся стилистическими выборами, но на самом деле это не так.

    Первая ловушка: вектор запроса должен быть скалярным подзапросом

    В семантической части вектор запроса поступает в виде скалярного подзапроса, а не как столбец из объединенной таблицы параметров. В этом и заключается разница между миллисекундой и минутой.

    Интуитивная версия выбирает данные из таблицы вкладок и из таблицы связей параметров одновременно, затем сортирует их по расстоянию до столбца параметров. Она работает хорошо, корректна и возвращает правильные строки, и именно поэтому проходит проверку кода. Однако планировщик может использовать индекс ближайших соседей только тогда, когда оператор ORDER BY сравнивает индексируемый столбец с чем-то фиксированным для всего сканирования. Значения, полученные в результате объединения таблиц, не подходят, поэтому отсутствует какой-либо упорядоченный путь: Postgres читает каждую строку и сортирует их.

    При пятидесяти тысячах блоков это занимает несколько сотен миллисекунд — достаточно много, чтобы это было заметно, и достаточно неоднозначно, чтобы винить в этом сеть. При пяти миллионах блоков возникает тайм-аут.

    Проверяйте, а не доверяйте. Объясните запрос на получение данных в CI и убедитесь, что план включает сканирование по индексу HNSW с указанием параметра Order By. Последовательное сканирование с последующей сортировкой означает, что векторный индекс является лишь декоративным. Тест, который не пройдёт при такой настройке, имеет смысл, поскольку добросовестная рефакторинг может снова привести к проблеме, не давая ни одного неверного результата, а лишь медленные ответы в производственной среде.

    Вторая ловушка: фильтрация после LIMIT

    Фильтры — текущий статус, язык и отсутствие замены — должны находиться внутри подзапроса каждой группы данных, до оператора LIMIT. В первой версии их поместили во внешний запрос, в очевидное, но неверное место.

    HNSW возвращает приблизительных ближайших соседей. Берутся 50 элементов по расстоянию, затем производится фильтрация, и, возможно, остается всего 12. Набор из 50 элементов незаметно сокращается до 12; теперь объединение рангов происходит над набором, загрязнённым строками, предназначенными для удаления, и окончательный запрос LIMIT 20 незаметно возвращает 9 результатов. Происходит коллапс результатов, но в логах об этом не указано. Каждый отдельный результат по-прежнему кажется правдоподобным, поэтому никто не сообщает о баге. Ассистент просто незаметно показывает худшие результаты при вопросах, где корпус фильтруется неравномерно, что в случае корпуса здоровой литературы означает вопросы о недавно пересмотренном материале.

    Три решения в порядке предпочтения:

    1. Включите фильтр в частичный индекс. Именно поэтому язык и текущий флаг копируются в строку эмбеддинга. Как только предикат индекса совпадает с условием WHERE запроса, в индексе остаются только отфильтрованные строки, и проблема исчезает. Этот подход подходит для измерений с низкой кардинальностью и медленными изменениями, таких как язык, валюта или уровень источника, но не подходит для данных с большим количеством значений, поскольку это потребовало бы поддержки сотни индексов.
    2. Итеративный поиск в индексе (pgvector 0.8.0 и новее) позволяет обрабатывать фильтры, слишком динамичные для прямого включения в структуру индекса:
    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
    

    При использовании этого метода индекс продолжает искать дополнительные кандидаты до тех пор, пока достаточное количество из них не пройдет фильтрацию. Действуют два ограничения: фильтр должен находиться на том же уровне запроса, что и сканирование индекса, иначе ничего не изменится, а сканирование имеет предел, поэтому оно может вернуть меньше строк, чем запрашивалось. Это смягчает проблему, но не устраняет её полностью. Использование параметра relaxed_order вместо strict_order улучшает точность поиска, но за счёт того, что результаты не всегда сортированы строго по расстоянию, что приемлемо, если после этого применяется дополнительный алгоритм сортировки.

    Ускорение за счёт бинарной квантизации

    В масштабных системах самым экономичным решением является двухэтапный поиск: сначала извлекаются кандидаты из бинарного индекса по показателю Хэмминга, а затем эта короткий список снова оценивается с учётом всех факторов.

    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;
    

    На первом этапе следует произвести значительно больше вычислений — примерно в восемь раз больше, чем потребуется в итоговом результате. Точная пересчётка на втором этапе восстанавливает правильный порядок, поэтому на этапе работы с бинарными данными достаточно лишь наличия кандидатов, а не их правильного ранжирования.

    Важна точность оценки экономии ресурсов, поскольку в Интернете существует много неточных данных по этому вопросу. При 1 536 измерениях длина битового вектора составляет примерно 200 байт, тогда как у полувектора она составляет около 3 КБ, что даёт сокращение объёма необработанных данных в 16 раз. Указанное везде значение в 32 раза основано на сравнении с вектором полной точности, который не используется в данной схеме.

    Индекс не сжимается в 16 раз, и причина этого весьма показательна. Каждая тупла HNSW включает фиксированный надводный уровень в несколько сотен байт — ссылки на узлы графа и заголовок туплы — независимо от содержимого. При данных объемом в 3 КБ для halfvec этот надводный уровень является незначительным, но при векторе длиной 200 байт он превышает само содержимое. Учитывая еще и потери от пакетирования страниц, фактическое сжатие остается значительно ниже первоначального соотношения. На этих данных бинарный индекс оказался примерно в 4–5 раз меньше по размеру, чем индекс на основе halfvec. Это все еще определяет, вместится ли индекс в оперативную память, что и является главной целью, но те, кто ожидает сжатие в 32 раза, будут крайне разочарованы. Перед планированием объема памяти измеряйте размер с помощью pg_relation_size и используйте реалистичные векторы: один тест занял целый день из-за того, что синтетические векторы были положительными, функция binary_quantize приводила к результату, состоящему исключительно из единиц, а Postgres сжимал все до нуля.

    Остальное — это рутинные действия, хорошо описанные в документации. Увеличьте значение maintenance_work_mem перед созданием индексов HNSW, иначе процесс записи на диск займет часы вместо минут. Для каждого запроса выбирайте значение hnsw.ef_search: от 100 до 200, когда ошибки имеют серьезные последствия, и около 40 для автодополнения. Создавайте индексы для производства ОДНОВРЕМЕННО. Тестируйте их на собственных данных, поскольку опубликованные показатели точности редко переносятся на реальные условия.

    Переранжирование и решение о возврате ничего

    Метод Fusion возвращает приемлемый список из 20 результатов, а не из 5, при этом в запросе есть место только для 5 элементов. Использование кросс-кодера для переранжирования, который обрабатывает запрос и кандидатов как пару вместо сравнения сохраненных векторов, обеспечивает наибольшее улучшение качества после гибридного поиска. Однако этот метод медленный, поэтому он рассматривает лишь 20 кандидатов, а не миллион.

    Что часто упускают из виду: следует использовать оценки системы переранжирования в качестве абсолютного порога, а не просто для сортировки. Если лучший вариант оказывается ниже этого порога, правильным ответом не будет просто лучший из плохих вариантов — это будет признанием того, что в корпусе данных отсутствует достоверная информация по данной теме.

    Именно здесь большинство помощников в сфере здравоохранения допускают ошибки, и решением является правильный выбор продукта, что на практике реализуется через код. Система, созданная для ответов на любые вопросы, будет выдумывать ответы на редкие вопросы. Если переранжирование используется только для сортировки, модель всегда получает какой-то ответ, а приемлемая модель всегда формирует ответ в ответ. Установление порога оценок превращает отсутствие подходящего ответа в прямой отказ, а не в выдумку. Установите этот порог с использованием маркированных вопросов, ответы на которые известно отсутствуют в корпусе данных, и сохраняйте его, когда пользователи жалуются на чрезмерное количество отказов.

    Сначала необходимо понять запрос

    Перед процессом поиска существует ещё один этап, добавляющий задержку: компактная, быстрая модель, которая переформулирует вопрос до того, как он попадёт к индексу.

    • Расширение и нормализация. Переводить простой язык на словарный запас корпуса, не удаляя при этом оригинал. Оба варианта входят в гибридный запрос: оригинал — в лексическую часть, а расширенная версия — в семантическую.
    • Разделение составных вопросов. Вопрос о том, можно ли сочетать два лекарства во время беременности, на самом деле представляет собой три отдельных поиска: по каждому лекарству и по аспекту беременности. Их выполняют отдельно, объединяют рейтинги, затем переранжируют полученный набор.
    • Учёт контекста. Дополнительный вопрос, например о более высокой дозе, сам по себе бессмыслен. Необходимо учесть предыдущий вопрос и сформулировать его заново как отдельный вопрос перед вставкой в модель, поскольку модели не сохраняют память.
  • Классификация и маршрутизация. Знаки чрезвычайной ситуации, запросы на индивидуальную дозировку и любые элементы, похожие на диагнозы, игнорируют процедуру поиска и следуют предопределённому пути ответа. Классификатор должен активно реагировать на входные данные. Ложноположительный результат приводит к незначительно раздражающему сообщению «пожалуйста, обратитесь к врачу», тогда как ложноотрицательный результат ведёт к сбою, прерывающему работу продукта.
  • Обоснование с использованием структурированных ссылок

    Этап генерации является кратким. Большая часть усилий прилагается к формату вывода, а не к формулировке запроса. Каждый найденный фрагмент в запросе следует помечать стабильным тегом, а также требовать структурированного вывода, в котором каждое утверждение указывает теги, на которые оно опирается:

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

    Проверяйте результат в коде, а не в запросе. Утверждения, у которых список источников пуст, удаляются перед отображением, а если таких утверждений слишком много, вся ответная информация отклоняется, и вступает в действие процедура отказа. Это разделяет процесс запроса обоснований и процесс его проверки: запрос — это надежда, а инструмент проверки — это правило. Коэффициент отклонений также служит бесплатным показателем качества работы системы, индикатором возникновения галлюцинаций в реальном времени, способным запускать сигналы тревоги даже без просмотра текстовых записей.

    Важны были ещё два небольших фактора. Необходимо отображать дату редактирования документа рядом с каждым цитируемым источником, чтобы пользователи могли сами оценить его актуальность. Кроме того, при противоречиях в полученных источниках следует показывать эти противоречия, а не устранять их. Фраза «Рекомендации различаются: источник А говорит X, источник B говорит Y» более полезна и честна, чем синтезированное среднее значение.

    Оценка или угадывание

    Изменение параметров доставки на основе интуиции здесь недопустимо. Механизмы сбоев сложны, возможные варианты множественны, и каждая настройка влияет на все остальные. Система, которая справляется с задачей, имеет несколько уровней защиты.

    Только извлечение информации, без использования LLM. Несколько сотен запросов с вручную проставленными идентификаторами соответствующих фрагментов текста; оценивается точность восстановления информации @20 и показатель nDCG@10. Процесс выполняется за секунды, позволяет выявить большинство регрессий, а также, что критически важно, отделяет качество извлечения информации от качества генерации ответов. Когда качество ответов ухудшается, сразу становится ясно, виноват ли корпус данных или формулировка запроса.

    Верность исходному тексту. Для генерируемых ответов необходимо проверять, что каждое утверждение подтверждается цитируемыми фрагментами текста. Оценка качества модели работает довольно хорошо, если оценщику предоставляется небольшая задача: одно предложение, один абзац и ответ «да» или «нет» на вопрос о наличии подтверждения.

    Правильность отказа. Отдельный набор включает вопросы, для которых правильным решением является отказ или перенаправление, чтобы изменения порогов не могли тайно нарушить это поведение.

    Связанная литература

  • Практические заметки: Безопасность векторных баз данных: новая поверхность атак в системах RAG — Подробное руководство по применению практических заметок: Безопасность векторных баз данных: новая поверхность атак в системах RAG, включающее шаблоны контрактов, проверок и готовые фрагменты кода для команд, разрабатывающих подобные решения.
  • Пайплайны Docling: создание пайплайнов данных уровня корпораций для RAG — Как пайплайны Docling заменяют нестабильные скрипты на составные операторы и JSON-структуры для обработки данных: загрузка, извлечение информации, проверка качества, генерация векторных представлений и хранение векторов.
  • Создание помощника Slack RAG без отдельной векторной базы данных — бот Slack отвечает на вопросы сообщества на основе документов, хранящихся в одной таблице StarRocks. Получение данных осуществляется с помощью обычного SQL, а шесть практических рекомендаций касаются формулировки запросов, разбиения текста на части и учета затрат.
  • Полная дорожная карта RAG: от основ до проблем в производстве — два потока обработки данных, три этапа и список известных сбоев: как работает генерация с усилением с помощью данных из внешних источников, где возникают сбои и какие решения устраняют каждую проблему.
  • Перенос RAG с Pinecone в Postgres pgvector: через шесть месяцев — Реальные показатели работы после миграции 9 миллионов векторов с Pinecone: стоимость, задержка, точность воспроизведения, надежность, что ухудшилось, и когда pgvector является правильным выбором.