Я додав BM25 до свого RAG-сервісу — і vector search перестав губити точні запити

Оновлено:
Я додав BM25 до свого RAG-сервісу — і vector search перестав губити точні запити

Чистий vector search втрачає точні терміни, ціни і номери документів. Я це виправив за один день — без зміни LLM, без GPU, без нових залежностей.

Мій RAG-сервіс працював. Vector search знаходив релевантні чанки, LLM генерувала відповіді українською. Але коли клієнт запитав "консультація юриста 500 грн" — vector search повернув чанки про юридичні послуги загалом, проігнорувавши точну ціну. А запит "Наказ №142" знайшов усе про накази, окрім самого документа №142.

Проблема була не в LLM і не в embedding-моделі. Чистий vector search шукає сенс — але іноді потрібен текст. Я додав BM25 поруч з vector search, об'єднав результати через RRF — і якість retrieval помітно зросла. У цій статті — як саме я це зробив у production на Spring Boot + pgvector, які помилки допустив, і що врахувати перед впровадженням.

⚡ Коротко

  • Проблема: vector search "розмиває" точні терміни, ціни, коди, номери документів
  • Рішення: hybrid search — BM25 (ключові слова) + vector (семантика) + RRF (злиття)
  • Стек: Java 21, Spring Boot, PostgreSQL + pgvector, tsvector для BM25
  • Конфігурація: переключення vector/hybrid через проперті, без перекомпіляції

📚 Зміст статті

🎯 Навіщо hybrid search: де vector search не справляється

Я будую комерційний RAG-сервіс — бізнес-клієнти завантажують документи компанії (PDF, DOCX, CSV, FAQ), а їхні користувачі задають питання природною мовою і отримують відповіді від LLM, засновані на завантаженому контенті. Стек: Java 21, Spring Boot + Spring AI, PostgreSQL з pgvector (IVFFlat індекс), Ollama локально (nomic-embed-text для ембедингів, mistral-nemo для чату).

До hybrid search мій пошук працював так: запит користувача перетворюється на вектор (768 вимірів через nomic-embed-text), pgvector знаходить найближчі чанки по cosine similarity. Це добре ловить сенс — запит "як захистити дані компанії" знаходив чанки про "безпеку інформації" і "захист персональних даних", навіть якщо слова не співпадали.

Але я помітив три типи запитів, де vector search стабільно промахувався:

  • Точні ціни і числа: "500 грн" — embedding-модель перетворює це на вектор, який описує загальний "сенс" ціни, але різниця між 500 і 550 у векторному просторі мінімальна
  • Коди і номери документів: "Наказ №142" — вектор "наказ" схожий на вектор будь-якого іншого наказу, номер губиться
  • Специфічні терміни: "амортизація" — vector search повертав семантично схоже ("зношування основних засобів"), але не завжди чанк з точним терміном

Це відома проблема vector search, яку я детально описував у статті про Hybrid Search та Reranking. Рішення — додати keyword search (BM25), який шукає точні збіги слів, і об'єднати результати з vector search.

📌 Що таке BM25 і чому алгоритм 1994 року досі працює

BM25 (Best Matching 25) — це алгоритм ранжування текстового пошуку, який був формалізований Robertson та Walker у 1994 році. За 30 років він не помер — і ось чому.

BM25 оцінює релевантність документа за трьома факторами:

  • TF (Term Frequency) — як часто слово зустрічається в конкретному чанку. Чим частіше — тим релевантніше
  • IDF (Inverse Document Frequency) — наскільки слово рідкісне у всій колекції. Слово "документ" зустрічається скрізь — воно менш цінне. Слово "амортизація" рідкісне — воно важливіше
  • Довжина документа — нормалізація, щоб короткі і довгі чанки були на рівних

Для мого кейсу BM25 критичний, тому що бізнес-документи містять точні терміни, ціни, номери — те, що vector search "розмиває". BM25 знаходить чанк з "500 грн" за мілісекунди, без нейронних мереж, без GPU.

Обмеження BM25: він не розуміє синоніми. "Автомобіль" ≠ "машина". Якщо користувач написав "скасувати підписку", а в документі — "відмовитись від тарифу", BM25 не знайде нічого. Саме тому потрібна комбінація — vector search ловить сенс, BM25 ловить точні слова.

Таблиця: коли що працює

Тип запитуVector SearchBM25Hybrid
"консультація юриста 500 грн"⚠️ знайде юридичне, ціну проігнорує✅ точний збіг✅✅
"як захистити дані компанії"✅ семантика❌ немає точних збігів
"Наказ №142 про звільнення"⚠️ знайде накази загалом✅ "Наказ №142"✅✅
"повернення коштів"✅ семантика✅ точний збіг✅✅ обидва сигнали

Детальне порівняння BM25 vs Dense Vector Search з бенчмарками — у моїй статті про Hybrid Search, Розділ 1.

📌 Підготовка бази: міграція, tsvector, GIN індекс

Перш ніж писати Java-код, я підготував PostgreSQL. У моїй таблиці vector_store вже були вектори (embeddings) для cosine similarity пошуку. Для BM25 потрібна додаткова структура — tsvector. Це вбудований тип PostgreSQL, де текст розбитий на токени (лексеми) з позиціями. Без нього повнотекстовий пошук через оператор @@ не працює.

Аналогія: вектор (embedding) — це "розуміння сенсу" тексту. А tsvector — це алфавітний покажчик у книзі. Для різних типів пошуку потрібні різні структури даних.

Міграція — дві команди

-- 1. Додаємо колонку для повнотекстового пошуку
ALTER TABLE vector_store ADD COLUMN content_tsv tsvector;

-- 2. Створюємо GIN індекс для швидкого пошуку по ключових словах
CREATE INDEX idx_vector_store_content_tsv ON vector_store USING GIN (content_tsv);

Перша команда додає колонку content_tsv. Після міграції вона буде NULL для всіх існуючих чанків — це нормально, заповнимо пізніше.

Друга команда створює GIN (Generalized Inverted Index) — тип індексу, оптимізований для повнотекстового пошуку. Без нього BM25 запити з оператором @@ скануватимуть усі рядки. З GIN — пошук швидкий. Це як IVFFlat індекс для векторів, тільки GIN — для тексту.

Заповнення tsvector для існуючих чанків

UPDATE vector_store
SET content_tsv = to_tsvector('simple', content)
WHERE content_tsv IS NULL;

⚠️ Підводний камінь: вибір text search конфігурації

Коли PostgreSQL конвертує текст у tsvector, йому потрібно знати мову — щоб прибрати стоп-слова ("і", "та", "на") і привести слова до базової форми (стемінг: "працівників" → "працівник").

Для української мови PostgreSQL не має вбудованого словника. Я мав два варіанти:

  • simple — просто розбиває текст на слова, переводить у нижній регістр. Без стемінгу, без стоп-слів. Надійно — не буде ситуацій, коли PostgreSQL неправильно стемить українське слово
  • russian — найближчий з вбудованих. Стемінг частково працює для української (мови схожі), але може неправильно стемити деякі слова

Я обрав simple — менш "розумно", але надійно. BM25 з simple конфігом все одно знаходить точні збіги ключових слів, а для "розуміння сенсу" у мене є vector search.

📌 Реалізація HybridSearchService: два пошуки + RRF

Мій HybridSearchService робить три речі в методі search():

  1. Векторний пошук — той самий vectorStore.similaritySearch() через pgvector, cosine similarity
  2. BM25 пошук — SQL запит з оператором @@ по колонці content_tsv
  3. RRF злиття — об'єднання обох списків результатів за формулою 1/(k + rank)

BM25 пошук: SQL запит

Для BM25 я використовую plainto_tsquery() — вона автоматично розбиває запит користувача на слова і шукає їх через AND. Результати ранжуються через ts_rank() — вбудовану функцію PostgreSQL, яка рахує BM25-подібний score.

SELECT id, content, metadata FROM vector_store
WHERE content_tsv @@ plainto_tsquery(CAST(:tsconfig AS regconfig), :question)
ORDER BY ts_rank(content_tsv, plainto_tsquery(CAST(:tsconfig AS regconfig), :question)) DESC
LIMIT :topK

⚠️ Підводний камінь: CAST до regconfig

Моя перша спроба без CAST видала BadSqlGrammarException. PostgreSQL не може автоматично привести строковий параметр ? (який приходить як String через JdbcClient) до типу regconfig. Потрібен явний каст: CAST(:tsconfig AS regconfig). Ця ж помилка виникла і при заповненні content_tsv під час індексації — довелося фіксити в двох місцях.

RRF (Reciprocal Rank Fusion): як працює злиття

RRF був запропонований Cormack, Clarke та Buettcher у 2009 році (SIGIR '09) і з тих пір став стандартом для hybrid search. Формула:

score(d) = Σ 1 / (k + rank)

де rank — позиція документа у кожному окремому рейтингу, а k — константа згладжування (я використовую стандартне значення 60).

Що робить k: він контролює, наскільки сильно "перше місце" відрізняється від "п'ятого місця".

  • k=60 (стандарт): 1-ше місце = 1/61 = 0.0164, 5-те = 1/65 = 0.0154. Різниця маленька — усі результати "майже рівні"
  • k=1 (маленький): 1-ше = 1/2 = 0.5, 5-те = 1/6 = 0.167. Різниця тройна — топові результати домінують
  • k=200 (великий): різниці майже немає — важливо лише чи потрапив чанк у результати

Чому саме 60? Це значення з оригінальної наукової статті. Його використовують Elasticsearch, Qdrant, Weaviate.

Приклад з реальними даними

Запит: "Наказ №142 про звільнення"

Vector search повертає (за cosine similarity — по сенсу "звільнення"):

  1. Чанк про процедуру звільнення (rank 1)
  2. Чанк про трудовий договір (rank 2)
  3. Чанк "Наказ №142" (rank 5)

BM25 повертає (за точним збігом слів "Наказ №142"):

  1. Чанк "Наказ №142" (rank 1)
  2. Чанк "Наказ №155" (rank 2)

RRF score для чанка "Наказ №142":

vector rank=5: 1/(60+5) = 0.0154
BM25 rank=1:   1/(60+1) = 0.0164
total:                     0.0318 ← найвищий серед усіх

Чанк "Наказ №142" виграє — він високо в обох рейтингах. Без hybrid search він був би на 5-й позиції і міг не потрапити у контекст LLM.

Заповнення tsvector при індексації нових документів

Нові документи проходять через PgVectorIndexingService. Після vectorStore.add(documents) (який зберігає embeddings) я додав UPDATE, що заповнює content_tsv:

private void updateTsVector(Long docId) {
    jdbcClient.sql(
        "UPDATE vector_store SET content_tsv = to_tsvector(CAST(:tsconfig AS regconfig), content) " +
        "WHERE metadata->>'doc_id' = :docId AND content_tsv IS NULL"
    )
    .param("tsconfig", tsConfig)  // @Value("${app.search.tsconfig:simple}")
    .param("docId", String.valueOf(docId))
    .update();
}

Чому без тригера: я розглядав варіант з PostgreSQL тригером, але тригер — це SQL, він не знає про Spring @Value. Якщо клієнт змінить мову (наприклад, з simple на german для німецького клієнта) — довелося б пересоздавати тригер через міграцію. З Java-кодом усе управляється з application.properties.

📌 Конфігурація: vector vs hybrid через проперті

Я не видалив старий PgVectorSearchService. Замість цього зробив переключення через @ConditionalOnProperty:

# application.properties
app.search.mode=hybrid       # або "vector" для чистого vector search
app.search.tsconfig=simple   # мова для tsvector: simple, russian, german, english...
app.search.rrf-k=60          # константа згладжування RRF
@ConditionalOnProperty(name = "app.search.mode", havingValue = "vector", matchIfMissing = true)
public class PgVectorSearchService implements SearchService { ... }

@ConditionalOnProperty(name = "app.search.mode", havingValue = "hybrid")
public class HybridSearchService implements SearchService { ... }

Навіщо два режими

Мій сервіс обслуговує різних бізнес-клієнтів, і не для всіх hybrid search оптимальний:

  • Hybrid search дає більше навантаження на базу — два запити замість одного (vector + BM25), плюс додатковий GIN індекс споживає RAM. Для деяких клієнтів з невеликою базою документів і простими запитами це надлишково
  • Fallback — якщо BM25 частина зламається або tsvector не заповнений для якихось чанків, можна миттєво повернутися на чистий vector search через одну проперті
  • A/B тестування — можна порівнювати якість відповідей між режимами на одних і тих самих запитах

По дефолту matchIfMissing = true на PgVectorSearchService — якщо проперті не задана, працює як раніше. Нічого не ламається.

Конфігурація під клієнта

Для українського клієнта:

app.search.mode=hybrid
app.search.tsconfig=simple

Для німецького клієнта:

app.search.mode=hybrid
app.search.tsconfig=german

Для клієнта з маленькою базою, де hybrid надлишковий:

app.search.mode=vector

Мова для tsvector і tsquery повинна збігатися — інакше пошук не працюватиме коректно. PostgreSQL з коробки підтримує: simple, english, german, french, spanish, russian, italian, dutch, turkish та інші.

⚠️ Підводні камені, які я зустрів

1. BadSqlGrammarException при plainto_tsquery

Проблема: перший запуск BM25 пошуку видав bad SQL grammar. PostgreSQL не міг привести строковий параметр ? до типу regconfig.

Рішення: явний каст CAST(:tsconfig AS regconfig) у двох місцях — у WHERE та ORDER BY частинах SQL.

2. Та ж помилка при індексації нових документів

Проблема: виправив HybridSearchService, але при завантаженні нового документа — та ж BadSqlGrammarException у PgVectorIndexingService.updateTsVector().

Рішення: додати CAST і там. Урок — якщо to_tsvector() використовується з параметром через JdbcClient, CAST потрібен завжди.

3. @RequiredArgsConstructor не працює з @Value

Проблема: Lombok @RequiredArgsConstructor генерує конструктор тільки для final полів. Поле з @Value — не final, тому не потрапляє в конструктор.

Рішення: замінив @RequiredArgsConstructor на явний конструктор у класах, де є і final залежності, і @Value конфігурація.

4. content_tsv = NULL для існуючих документів

Проблема: після міграції нова колонка content_tsv була NULL для всіх існуючих чанків. BM25 повертав 0 результатів.

Рішення: одноразовий UPDATE: UPDATE vector_store SET content_tsv = to_tsvector('simple', content) WHERE content_tsv IS NULL;

5. Similarity threshold для українського тексту

Дефолтний threshold cosine similarity для vector search занадто високий для українського тексту з nomic-embed-text. Я знизив його до 0.1 — інакше vector search повертав мало результатів. Детально про вибір embedding-моделі та threshold — у статті про embedding-моделі.

📊 Результати: vector=5, bm25=2, merged=5

Логи з мого сервісу після впровадження hybrid search:

Stream query: 'Скільки часу займає повернення коштів?', sessionId=3
HybridSearchService: Hybrid search results: 5 chunks (vector=5, bm25=1, merged=5)

Stream query: 'Скільки часу займає розробка лендінгу?', sessionId=null
HybridSearchService: Hybrid search results: 5 chunks (vector=5, bm25=2, merged=5)

Stream query: 'напиши про Локальний деплой Як це працює?', sessionId=3
HybridSearchService: Hybrid search results: 5 chunks (vector=5, bm25=0, merged=5)

Що ми бачимо:

  • "повернення коштів" — BM25 знайшов 1 чанк з точним збігом слів, vector знайшов 5 по сенсу. Чанк, що потрапив в обидва рейтинги, отримав найвищий RRF score і опинився першим
  • "розробка лендінгу" — BM25 знайшов 2 чанки. Hybrid дав 5 результатів — чанки з обох рейтингів, відсортовані по RRF score
  • "Локальний деплой" — BM25 знайшов 0. Це нормально: запит "напиши про Локальний деплой Як це працює" — семантичний, без точних термінів з документів. Vector search впорався сам

Головний висновок: hybrid search не погіршує результати, коли BM25 не знаходить нічого — він просто повертає vector results. Але коли BM25 знаходить — якість фінального рейтингу зростає, бо RRF підсилює чанки, які з'являються в обох пошуках.

Про чанкінг документів — як я розбиваю PDF, DOCX і CSV на чанки для індексації, включаючи семантичний FAQ-чанкінг — читайте у статті про Chunking Strategies. А про вибір Ollama-моделей, які працюють локально на 8 ГБ RAM — у статті про Ollama на 8 ГБ.

❓ Часті питання (FAQ)

Чи потрібен hybrid search, якщо база документів маленька (< 100 документів)?

Не обов'язково. На маленькій базі vector search зазвичай достатній — є менше "шуму" і релевантні чанки потрапляють у топ. Hybrid виправданий, коли документи містять точні терміни, коди або ціни, які vector search "розмиває". Тому я і зробив переключення через app.search.mode — для маленьких клієнтів залишаю vector.

Чому tsvector, а не Elasticsearch для BM25?

PostgreSQL у мене вже є — він зберігає і документи, і вектори (pgvector). Додавати Elasticsearch як окремий сервіс — це DevOps overhead, моніторинг, синхронізація даних. Вбудований tsvector з GIN індексом вирішує задачу BM25 пошуку без додаткової інфраструктури. Для масштабу 10K+ документів з високим QPS варто розглянути Elasticsearch або Qdrant з нативним hybrid.

Як hybrid search впливає на latency?

Мінімально. BM25 і vector search виконуються послідовно (поки не паралельно), але BM25 по GIN індексу — мілісекунди. RRF злиття — мікросекунди (обрахунок рангів). Основний час — embedding запиту через nomic-embed-text і vector search через pgvector. Hybrid додає ~5-15ms до загального часу пошуку.

Чи можна використовувати конфіг russian замість simple для української?

Можна — стемінг частково працює (мови схожі). Але є ризик неправильного стемінгу для деяких українських слів. simple надійніше: він просто токенізує без стемінгу. Для "розуміння" слів у мене є vector search — BM25 потрібен тільки для точних збігів.

Що робити, якщо BM25 завжди повертає 0 результатів?

Перевірте три речі: (1) чи заповнена колонка content_tsv — виконайте SELECT count(*) FROM vector_store WHERE content_tsv IS NOT NULL; (2) чи збігається tsconfig у to_tsvector() і plainto_tsquery(); (3) чи є точні збіги слів запиту в тексті чанків. Якщо запити переважно семантичні — BM25 повертає 0, і це нормальна поведінка.

✅ Висновки

  • 🔹 Vector search сам по собі не достатній: він "розмиває" точні терміни, ціни, номери документів. BM25 закриває ці пробіли
  • 🔹 Hybrid search (BM25 + vector + RRF): два паралельних пошуки, злиття через формулу 1/(k + rank). Чанк, що високо в обох рейтингах, виграє
  • 🔹 pgvector + tsvector: не потрібен Elasticsearch — PostgreSQL з GIN індексом достатній для BM25 поруч з vector search
  • 🔹 Конфігурація під клієнта: app.search.mode=hybrid/vector, app.search.tsconfig=simple/german/english — усе через проперті, без перекомпіляції
  • 🔹 Підводні камені: CAST(:tsconfig AS regconfig) обов'язковий для JdbcClient, content_tsv потрібно заповнити для існуючих чанків, similarity threshold для українського тексту — 0.1
  • 🔹 Hybrid не погіршує: коли BM25 не знаходить нічого — результати = vector search. Коли знаходить — якість зростає

Моя головна думка: hybrid search — це найпростіший і найефективніший крок для підвищення якості RAG-системи після базового vector search. Якщо ваші документи містять терміни, ціни, коди — ефект помітний одразу. А якщо ні — hybrid просто працює як vector search, нічого не ламаючи.

📖 Джерела

📚 Пов'язані статті

Останні статті

Читайте більше цікавих матеріалів

Я додав BM25 до свого RAG-сервісу — і vector search перестав губити точні запити

Я додав BM25 до свого RAG-сервісу — і vector search перестав губити точні запити

Чистий vector search втрачає точні терміни, ціни і номери документів. Я це виправив за один день — без зміни LLM, без GPU, без нових залежностей. Мій RAG-сервіс працював. Vector search знаходив релевантні чанки, LLM генерувала відповіді українською. Але коли клієнт запитав "консультація...

Hybrid Search та Reranking: як підняти якість RAG на 15–40% без зміни моделі

Hybrid Search та Reranking: як підняти якість RAG на 15–40% без зміни моделі

Ваш RAG-пайплайн працює. Відповіді генеруються, retrieval повертає результати. Але користувач шукає get_user_v2 — і замість документації отримує статтю про user management. Або питає про "стаття 42 ЗУ про захист персональних даних" — і vector search повертає три чанки про...

Embeddings простими словами: як AI розуміє сенс, а не просто слова

Embeddings простими словами: як AI розуміє сенс, а не просто слова

Ви коли-небудь дивувались, чому ChatGPT знаходить зв'язок між "автомобілем" і "машиною" — хоча це різні слова? Або чому RAG-система знаходить потрібний документ навіть якщо у запиті немає жодного слова з тексту? Спойлер: за цим стоїть одна технологія — embedding. Це спосіб...

Як виміряти якість RAG: метрики, інструменти та перший evaluation pipeline — гайд 2026

Як виміряти якість RAG: метрики, інструменти та перший evaluation pipeline — гайд 2026

Ви побудували RAG-систему, відповіді генеруються, retrieval працює. Але як дізнатися, чи працює він на 90% запитів чи на 55%? Eyeball evaluation не скейлиться: variance між ревьюерами, нульове покриття edge cases, неможливість відловити регресії. Спойлер: п'ять метрик + 50...

ChromaDB, Qdrant або pgvector: як обрати Vector DB під свій проєкт

ChromaDB, Qdrant або pgvector: як обрати Vector DB під свій проєкт

ChromaDB, Qdrant або pgvector: як обрати Vector DB Проблема: Ви запустили перший RAG на ChromaDB — все працює: ~50 000 документів, відповіді стабільні. Але з’являється нова вимога: масштабування. Менеджер очікує мільйон документів, DevOps ставить під сумнів окрему vector DB, якщо...

Vector Search для початківців: як RAG знаходить потрібну інформацію

Vector Search для початківців: як RAG знаходить потрібну інформацію

Ви додали документи у свій RAG-пайплайн, написали запит — і система знаходить відповідь. Але як саме? Чому вона обирає цей фрагмент, а не сусідній? І чому іноді повертає повну нісенітницю? Спойлер: за кожним RAG-пошуком стоїть математика кутів у просторі тисячі вимірів — і її можна...