Головна / Статті

Гібридний пошук у 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 вимірів, тоді як звичайний vector — лише до 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 вимірах вектор типу bit має розмір близько 200 байт, тоді як для halfvec цей розмір становить приблизно 3 КБ, що є зниженням обсягу сирих даних у 16 разів. Цифра 32 рази, яка часто наводиться, стосується векторів повної точності, які не використовуються в цій схемі.

    Індекс не скорочується у 16 разів, і причина цього є показовою. Кожна тупла HNSW містить фіксовані додаткові дані об’ємом у кілька сотень байтів — посилання між елементами графа та заголовок туплі, незалежно від вмісту. Для даних у форматі halfvec об’ємом 3 КБ ці додаткові дані є незначними, але для бітового вектора об’ємом 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. Кілька сотень запитів із вручну позначеними ідентифікаторами відповідних фрагментів для оцінки показників recall@20 та nDCG@10. Процес виконується за секунди, виявляє більшість регресій та, що найважливіше, розділяє якість отримання інформації від якості її генерації. Коли відповіді погіршуються, система одразу показує, чи причиною є корпус даних чи запит.

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

    Правильність відмови. Окремий набір містить запитання, для правильної обробки яких потрібна відмова або перенаправлення, щоб зміни порогів не могли тихо підривати цю поведінку.

    Пов’язана література

  • Практичні нотатки: Безпека векторних баз даних: нова поверхня атак у системах RAG — Детальний огляд Практичних нотаток: Безпека векторних баз даних: нова поверхня атак у системах RAG: контракти, перевірки та готові фрагменти коду для команд, які впроваджують цю модель.
  • Docling Pipelines: Створення потоків даних RAG корпоративного рівня — Як docling-pipelines замінює крихкі скрипти на композитні оператори та JSON-потоки: завантаження даних, вилучення інформації, перевірка якості, створення ембеддингів та зберігання векторів.
  • Створення асистента Slack RAG без окремої векторної бази даних — бот Slack відповідає на запитання спільноти з документів, збережених у одній таблиці StarRocks. Отримання даних здійснюється за допомогою звичайного SQL, а шість практичних порад стосуються формулювання запитів, розділення тексту на частини та витрат.
  • Повний план розвитку RAG: від основ до проблем у продакшені — два потоки обробки даних, три кроки та список конкретних проблем: як працює технологія отримання даних для покращення генерації тексту, де виникають збої та які рішення усувають кожну проблему.
  • Перенесення RAG з Pinecone до Postgres pgvector: через шість місяців — показники у режимі продакшн після міграції 9 мільйонів векторів з Pinecone: витрати, затримка, точність відтворення, надійність, що погіршилося, та коли pgvector є правильним вибором.