Чистий 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 Search | BM25 | Hybrid |
| "консультація юриста 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():
- Векторний пошук — той самий
vectorStore.similaritySearch() через pgvector, cosine similarity
- BM25 пошук — SQL запит з оператором
@@ по колонці content_tsv
- 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 — по сенсу "звільнення"):
- Чанк про процедуру звільнення (rank 1)
- Чанк про трудовий договір (rank 2)
- Чанк "Наказ №142" (rank 5)
BM25 повертає (за точним збігом слів "Наказ №142"):
- Чанк "Наказ №142" (rank 1)
- Чанк "Наказ №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,
нічого не ламаючи.
📖 Джерела
📚 Пов'язані статті