Пошук для MCP-сервера: Embeddings чи Full-text — що обрати

Оновлено:
Мова: 🇺🇦
Пошук для MCP-сервера: Embeddings чи Full-text — що обрати

⚡ Коротко

  • ✅ У MCP запит до пошуку пише модель, і результат теж читає модель. Тому помилка пошуку перетворюється не на «користувач погортав далі», а на впевнену відповідь із посиланням на нерелевантну статтю.
  • ✅ Семантичний пошук (embeddings) завжди знаходить «найближче» — навіть коли релевантного немає. На моєму MCP-сервері запит про транспорт MCP повернув статтю про HTTPS зі статусом ok.
  • ✅ Головну перевагу embeddings — синоніми й перефразування — частково бере на себе сама модель: вона переформульовує запит перед викликом tool. А переваги full-text — точні терміни, свіжість індексу, чесне «не знайдено» — для MCP стають важливішими.
  • ✅ Embeddings і full-text не конкурують: вони добре працюють на різних типах запитів. Гібрид поєднує обидва, але не завжди виправданий — я у своєму блозі його свідомо не впровадив.
  • ✅ Універсального порогу score не існує. Його треба калібрувати на власних запитах — і вміти повертати not_found.

📚 Зміст

🧭 Швидка відповідь: що обрати

Якщо немає часу читати всю статтю — ось коротка відповідь. Нижче поясню, чому саме так.

СценарійЩо обратиЧому
Блог, статті, питання «своїми словами»Embeddings + відкалібрований порігЗапити концептуальні, синоніми важливі
Технічна документація з назвами API, класів, версійFull-text + trigram або гібридТочні терміни embeddings «розмивають»
Коди помилок, номери договорів, артикулиExact або full-textПотрібен точний збіг, а не «схоже за змістом»
Корпоративна база документів, тисячі файлів, різні формулюванняГібрид (+ reranking)Типи запитів різні, кожен метод покриває свою частину
Дані змінюються щогодини, потрібні одразуFull-textІндекс оновлюється разом із записом, без перерахунку векторів
Таблиці: замовлення, статуси, клієнтиНе пошук, а окремий tool із WHEREСтруктуровані дані фільтрують, а не шукають
💡 Коли пошук узагалі НЕ потрібен. Якщо даних мало (десятки документів) або вони табличні — не будуйте пошукову систему. Для першого достатньо tool, який повертає список із короткими описами, а модель сама обере. Для другого — типізований tool на кшталт find_orders(status, from) з параметризованим SQL.

Як виглядає пошук через MCP загалом:

Користувач питання Модель пише запит до tool MCP search-tool search_articles(query) Пошук vectors / full-text фрагменти + URL + статус → читає модель Запит пише модель — і результат теж читає модель

🤖 Чому пошук для MCP — окрема задача

Більшість статей про embeddings і full-text написані для пошуку на сайті, де запит вводить людина і результат бачить людина. У MCP обидва кінці змінюються.

Пошук на сайтіПошук як MCP tool
Хто пише запитЛюдина: «як зробити сайт»Модель: переформульовує питання, часто — ключовими словами
Хто читає результатЛюдина: відсіє зайве очимаМодель: сприйме все, що повернув tool, як джерело
Ціна промахуКористувач гортає даліВпевнена відповідь із посиланням на нерелевантну статтю
Повторні спробиРідкоМодель може викликати tool 2–3 рази з різними формулюваннями
Терміни в запитіЗагальні словаЧасто точні ідентифікатори: STDIO, @McpTool, коди помилок
💡 Чому саме так. Людина, побачивши в результатах статтю про HTTPS на запит про MCP, просто її пропустить. Модель так не робить: якщо tool повернув status: ok і три статті, для неї це «знайдено три джерела». Тому для MCP важливіше не те, наскільки «розумно» пошук знаходить схоже, а те, чи вміє він чесно сказати «нічого релевантного немає».

🔍 Реальний кейс: семантичний пошук знайшов HTTPS замість MCP

У мене є публічний MCP-сервер для блогу: tool search_articles шукає по статтях через pgvector. На продакшні працює embedding-модель на 1536 вимірів (локально для розробки — nomic-embed-text на 768, і на проді пошук помітно точніший). Поріг релевантності на момент тесту — 0.38. Індексація чанків запускається раз на тиждень.

Я відправив через curl на продакшн запит «STDIO чи Streamable HTTP транспорт MCP». На блозі є окрема стаття саме про це — але вона вийшла того ж дня і ще не потрапила в індекс. Як і решта статей про MCP цього тижня. Ось що повернув пошук:

#СтаттяScoreПро те, що питали?
1HTTP vs HTTPS: чому HTTPS — must-have0.418❌ близька лише тема «протокол HTTP»
2Ollama REST API: інтеграція у свій застосунок0.410❌ близька тема «стрімінг / SSE»
3Як працює real-time чат: WebSockets vs HTTP0.401❌ «транспорт» у загальному сенсі
4Взаємодія мікросервісів: Request/Response0.392❌

Усі чотири вище порогу, тому сервер відповів status: ok. Клієнт на кшталт Claude чи OpenCode отримав би це як «знайдено 4 джерела» — і цілком міг би процитувати статтю про HTTPS у відповіді про транспорт MCP.

Може здатися, що ситуація штучна: стаття просто ще не потрапила в індекс. Але для MCP-сервера це звичайний стан. Модель питає про що завгодно, і частини відповідей у корпусі немає: тема не покрита, документ новий або його видалили. Саме в такі моменти пошук і має сказати «нічого немає».

Другий запит — «spring boot rag» — показав іншу сторону тієї ж проблеми. Правильна стаття знайшлася першою, але з мінімальним відривом:

СтаттяScoreРелевантна?
Spring AI + pgvector: 6 помилок, будуючи RAG для блогу0.423✅
Фреймворки: чому розробники не пишуть код з нуля (Spring)0.422⚠️ слабко
Spring AI 2026: що це таке і як використовувати0.416⚠️ слабко

Різниця між релевантною і слабкою статтею — 0.001. Тобто на моїх даних score сам по собі майже не відрізняє «про це» від «приблизно поруч».

👤 Висновок, який я зробив. Семантичний пошук не вміє відповідати «нічого немає» — він завжди повертає найближчих сусідів. Якщо поріг занизький, «найближче» автоматично стає «знайденим». У звичайному пошуку на сайті це дрібна незручність. У MCP — джерело галюцинацій із посиланнями.
Пошук для MCP-сервера: Embeddings чи Full-text — що обрати — 2

⚙️ Exact, full-text, trigram та embeddings: що вони шукають

Чотири механізми — чотири різні визначення «знайти».

Exact search — точний збіг значення: WHERE order_number = 'A-1042'. Без ранжування, без «схожого». Ідеальний для ідентифікаторів, кодів, статусів і всього, що має одне правильне значення. Для MCP це зазвичай не пошуковий tool, а окремий типізований інструмент із параметрами.

Full-text search — збіг слів. У PostgreSQL текст перетворюється на tsvector (набір нормалізованих слів), запит — на tsquery, а ts_rank ранжує результати за кількістю й вагою збігів. Знаходить точні терміни, працює з GIN-індексом за мілісекунди, оновлюється разом із записом. Не знаходить синоніми: «скасувати підписку» не знайде «припинити тариф».

Trigram (pg_trgm) — схожість рядків за трисимвольними фрагментами. Ловить опечатки й часткові збіги: «Kubernets» знайде «Kubernetes». Не розуміє змісту — лише написання.

Embeddings — близькість за змістом: текст і запит перетворюються на вектори, і пошук шукає найближчі. Знаходить перефразування й синоніми, працює між мовами (якщо модель мультимовна). Погано працює з точними числами, ідентифікаторами й рідкісними термінами. Детально про те, як це влаштовано і де межі, — у статті Embeddings простими словами.

⚠️ Мова має значення для full-text. PostgreSQL зводить слова до основи (stemming) через словники конкретної мови: для англійської чи німецької вони вбудовані, для низки інших мов — ні. Без стемінгу «статті» не знайде «стаття». Перед вибором full-text перевірте, чи є словник для вашої мови; якщо немає — конфігурація 'simple' плюс префіксний пошук (слово:*) і trigram частково компенсують це.
⚠️ Як зазвичай роблять → чому це погано. Найпоширеніший «пошук» у туторіалах — WHERE content LIKE '%слово%'. Він не використовує індекс (повний перебір таблиці), не ранжує результати, не знаходить інші форми слова і не має поняття релевантності. Для MCP це найгірший варіант: модель отримує або нуль, або випадковий набір збігів без порядку. Саме з LIKE я колись почав і чому пішов на вектори — описав у статті Spring AI + pgvector: 6 помилок.

📊 Порівняння: точність, свіжість, latency, ціна

ВластивістьEmbeddings (pgvector)Full-text (tsvector)Trigram (pg_trgm)
Синоніми, перефразування✅❌❌
Точні терміни (STDIO, @McpTool)⚠️ розмито✅✅
Опечатки⚠️ частково❌✅
Пошук між мовами✅ з мультимовною моделлю❌❌
Свіжість індексу❌ потрібен перерахунок векторів✅ generated column — одразу✅ одразу
Чесне «не знайдено»❌ завжди є найближчий✅ немає збігу — порожньо⚠️ залежить від порогу similarity
Ціна запитуВиклик embedding-моделі на кожен запитНуль, усе в PostgreSQLНуль
Latency~0.6 с у мене (основне — embedding запиту)МілісекундиМілісекунди
ІнфраструктураМодель, нарізка чанків, переіндексація при зміні моделіОдна міграціяРозширення + індекс
НалагодженняСкладно: чому 0.42, а не 0.6?Просто: видно, які слова збіглисяПросто
💡 Коли embeddings НЕ потрібні. Якщо запити — переважно точні терміни, коди чи назви, або документів кілька сотень і вони добре підписані, full-text дасть кращий результат дешевше. Embeddings виправдовують себе тоді, коли люди (або модель) формулюють питання словами, яких немає в тексті.

Метадані та фільтри: що не вирішує жоден із методів

Запити на кшталт «статті про MCP за останній місяць», «тільки про безпеку», «документи проєкту X» — це не про пошук за текстом, а про фільтри: дата, категорія, мова, тип документа, tenant, права доступу. Embeddings тут не допоможуть — «за останній місяць» не має змісту, який можна порівняти з вектором статті.

Чесно: у моєму search_articles фільтрів немає. Для блогу вистачає окремого tool latest_articles, який закриває найчастіший запит «що нового». Але для корпоративного пошуку фільтри — обов'язкові, а фільтр за tenant і правами — це питання безпеки, а не якості: його не можна довіряти моделі, він має братися з автентифікації запиту.

🧠 Як модель формує запит і навіщо query guidance

Ось реальний запит з логів мого сервера. Користувач OpenCode написав: «Яку модель Ollama обрати для ноутбука з 8 ГБ RAM? Дай джерело.» Модель викликала tool так:

search_articles(query="Ollama модель ноутбук 8 ГБ ОЗП", limit=5)
→ потім get_article(slug=...) для найкращого результату

Тут видно три речі:

  • Модель переписала питання ключовими словами. Ніякого «яку обрати» — лише сутності. Це те, що зазвичай вважають перевагою embeddings (розуміти «своїми словами»), а тут модель зробила це сама.
  • Модель переклала термін. «RAM» стало «ОЗП» — бо в описі tool сказано писати запит українською. Для embeddings це нормально, для full-text — ризик: якщо в статті написано «RAM», точного збігу не буде.
  • Модель використала кілька tools послідовно. Пошук → деталі статті. Тобто пошук не мусить повертати все — достатньо, щоб він привів до правильного документа.

Звідси головна думка статті: у MCP перевагу embeddings (синоніми) частково забирає модель, а переваги full-text (точність, чесне «не знайдено») стають важливішими. Це не означає, що embeddings не потрібні, — модель не знає вашої термінології, і перефразування все одно трапляються. Але аргумент «люди пишуть своїми словами» в MCP слабшає.

Обмеження теж є: модель не знає, які слова є у вашому корпусі, може «перекласти» точний термін і погіршити збіг, може повторювати майже однаковий запит, якщо перший нічого не дав. Частину цього вирішує опис tool — для моделі це інструкція.

Ось як виглядав опис мого tool спочатку:

"Semantic search over webscraft.org — a Ukrainian-language tech blog with
practical, hands-on guides on AI tools, local LLMs (Ollama, LM Studio),
Spring Boot / Spring AI, RAG and SEO. Returns up to 5 articles ...
Write the query in Ukrainian for best results.
Fragments are excerpts, not full articles: always give the user the article URL."

Він відповідав на питання «що тут є», «що повернеться» і «як використати». Але логи показали дві діри. Перша — правило «пиши українською» модель виконала буквально й переклала «RAM» на «ОЗП». Друга — опис ніде не казав, що робити, якщо нічого не знайдено. Після цього я оновив опис:

"Semantic search over webscraft.org articles — a Ukrainian-language tech blog ...
Query: short keywords in Ukrainian; keep technical terms, product names and versions
exactly as written (e.g. STDIO, Spring AI 1.1, RAM) — do not translate them.
Returns up to 5 articles ...
If status is not_found, tell the user the blog has no article on this and do not present
your own knowledge as coming from the blog.
Fragments are excerpts, not full articles: always give the user the article URL;
use get_article to see the article's sections."
💡 Що змінилось і навіщо. Два рядки закривають конкретні проблеми з логів: «не перекладай терміни» — щоб точні назви доходили до пошуку без змін, «якщо not_found — так і скажи» — щоб модель не видавала власні знання за статтю з блогу. Третій рядок — підказка про get_article: модель знає, куди йти по деталі, а не просить повну статтю в пошуку.
⚠️ Як зазвичай роблять → чому це погано. Опис на кшталт "Search articles". Модель не знає, про що корпус (і викликає tool на питання, на які тут відповіді не буде), якою мовою писати запит, що повертається і що робити з результатом. Опис має відповідати на чотири питання: що тут є, як формулювати запит, що повернеться, як це використати.

📎 Що повинен повертати MCP search-tool

Однаково важливо, як знайти і що віддати. Ось структура відповіді мого search_articles:

{
  "status": "ok",
  "message": null,
  "articles": [
    {
      "title": "Spring AI + pgvector: 6 помилок...",
      "url": "https://webscraft.org/blog/spring-ai-pgvector-...",
      "date": "2026-03-22",
      "category": "WEB_DEVELOPMENT",
      "score": 0.423,
      "fragments": ["... до 2 фрагментів по ~900 символів ..."]
    }
  ]
}
  • URL у кожному результаті — без нього модель не зможе послатися на джерело, і відповідь стане переказом без доказів. Плюс в описі tool прямо сказано: «always give the user the article URL».
  • Фрагменти, а не повні статті. Повна стаття на 20 хвилин читання з'їсть контекст моделі. 1–2 фрагменти по ~900 символів показують, чому результат знайдено, а за деталями є окремий get_article, який повертає лише план статті.
  • Явний статус: ok, not_found, unavailable. Модель розрізняє «нічого немає», «сервіс лежить» і «ось результати».
  • Ліміт — не більше 5 статей, навіть якщо модель попросить 50.
⚠️ Як зазвичай роблять → чому це погано. На «нічого не знайдено» повертають порожній масив [] або, ще гірше, помилку. Модель не розуміє, що сталося: збій? неправильний запит? Вона або повторює виклик, або відповідає з власних знань — без попередження, що в джерелі цього немає. Краще явний status: not_found із підказкою на кшталт «релевантних статей немає — скажи про це користувачу».

Фрагменти погані, якщо погана нарізка

Модель бачить не статтю, а chunk, який потрапив у результат. У моїх відповідях траплялися фрагменти, що складалися зі шматків змісту статті («📌 Що таке HTTP… 📌 Базові відмінності…») або коду з \r\n. Формально це релевантний текст — але моделі він майже нічого не дає. Якість фрагментів — це якість chunking: розмір, overlap, що робити із заголовками, змістом і кодом. Детально — у статті Chunking strategies у RAG.

🔀 Hybrid search: коли об'єднання дає результат

Гібридний пошук запускає обидва методи — векторний і full-text — і об'єднує результати. Найпростіший спосіб об'єднання — Reciprocal Rank Fusion (RRF): він складає не score (cosine і ts_rank мають різні шкали й порівнювати їх не можна), а позиції документа в кожному списку. Документ, який високо в обох списках, піднімається нагору. Формулу з розбором на прикладі, reranking і код на Qdrant я вже описував у статті Hybrid Search та Reranking.

Тут — лише те, чого там немає: як це зібрати в самому PostgreSQL, де вектори й full-text живуть у тій самій базі. Спрощено (один документ = один вектор):

WITH vec AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> CAST(:qvec AS vector)) AS r
    FROM documents
    ORDER BY embedding <=> CAST(:qvec AS vector)
    LIMIT 20
),
fts AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank(search_tsv, q) DESC) AS r
    FROM documents, websearch_to_tsquery('simple', :q) q
    WHERE search_tsv @@ q
    ORDER BY ts_rank(search_tsv, q) DESC
    LIMIT 20
)
SELECT id, SUM(1.0 / (60 + r)) AS rrf
FROM (SELECT * FROM vec UNION ALL SELECT * FROM fts) both_lists
GROUP BY id
ORDER BY rrf DESC
LIMIT 5;

Що гібрид дає для MCP: точні терміни знаходить full-text, перефразування — вектори, а документ, знайдений обома, отримує найвищий ранг. Плюс full-text працює для свіжих документів ще до того, як для них пораховані вектори.

💡 Коли гібрид НЕ потрібен — з моєї практики. Коли я готував гібридну версію пошуку для блогу, найбільше коду пішло не на саме злиття, а на правила якості. Статтю, яка збіглася з запитом лише одним словом, full-text теж додає до кандидатів, і вона починає конкурувати за місце в топі. Довелося окремо вирішувати, коли довіряти документу, знайденому лише одним методом. Тоді я подивився на свої реальні запити: вони майже завжди концептуальні — «як налаштувати», «що обрати». Для такого потоку один метод із добре налаштованим порогом дає той самий результат простіше. Гібрид я б обрав там, де типи запитів справді змішані: у корпоративній документації, де поруч стоять питання «як працює відпустка» і «договір №А-1042».

🙅 Чому я не впровадив гібрид у WebsCraft

Після кейсу з HTTPS я підготував гібридну версію пошуку: tsvector, другий SQL-запит, RRF і правила якості. У прод вона не пішла.

💡 Чому саме так.
  • Малий профіт. Запити до блогу концептуальні, з ними вектори справляються.
  • Свіжість не потрібна. Індексую раз на тиждень свідомо: статтю ще кілька днів доопрацьовую. Нові статті й так доступні через latest_articles і get_article.
  • Дорога підтримка. Два методи і три пороги, які треба калібрувати разом, — забагато для solo-проєкту.
  • Проблема в порозі, а не в методі. Поріг 0.38 пропускає сміття — це один рядок конфігурації.

Для корпоративних баз зі змішаними запитами я гібрид рекомендую. Для блогу — ні.

🎯 Поріг score і чесний not_found

Найчастіша порада в туторіалах — «поставте поріг 0.7». Вона не працює, і ось чому.

  • Score різних методів не порівнюються. Cosine similarity, ts_rank і BM25 мають різні шкали й різну природу. 0.4 у cosine і 0.4 у ts_rank — не одне й те саме.
  • Навіть cosine залежить від моделі. У моєї nomic-embed-text (768 вимірів) релевантні статті отримують близько 0.42, і це нормально. В іншої моделі або іншої розмірності діапазон буде іншим — про вплив розмірності є окрема стаття 1536 vs 3072 embeddings.
  • Поріг залежить від даних. Довжина чанків, тематична щільність корпусу, мова — усе зсуває розподіл score.

Тому поріг калібрують на власних запитах. А not_found краще визначати не за одним числом, а за кількома сигналами:

СигналЩо показуєМій випадок
Абсолютний порігНайкращий результат узагалі достатньо близький?0.38 — занизько, пропускає сміття
Розрив між 1-м і 2-мЄ явний лідер чи всі «приблизно поруч»?0.423 vs 0.422 — розриву немає
Згода методівДокумент знайдено і векторами, і словами?Лише в гібриді
Кількість кандидатів вище порогуОдин сильний чи десяток слабких?Багато слабких — тривожний знак

📏 Як перевірити якість пошуку

Загальні методики — recall@5, A/B-порівняння, RAGAS — описані в статтях про hybrid search і chunking. Для MCP-пошуку я додаю до них дві речі, які в класичних методиках зазвичай пропускають.

  1. Беріть запити, які пише модель, а не ви. Логуйте кожен виклик search-tool (у мене це рядок [mcp-search] q="..."). Справжні запити від Claude чи OpenCode відрізняються від того, що ви б придумали самі: коротші, ключовими словами, іноді з перекладеними термінами.
  2. Складіть набір із 15–20 запитів і для кожного запишіть очікувану статтю. Перевіряйте, чи вона потрапляє в Top-1 і Top-3.
  3. Додайте 5 запитів, на які відповіді немає. Наприклад, «рецепт борщу з грибами» для технічного блогу. Правильна відповідь — not_found. Саме цю перевірку найчастіше пропускають, і саме вона ловить проблему з HTTPS.
  4. Повторюйте набір після кожної зміни — моделі embeddings, порогу, chunking, опису tool.

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

Чи потрібні embeddings для MCP-сервера?

Ні, MCP не вимагає жодної конкретної технології пошуку. Search-tool може працювати через full-text, SQL, зовнішній API або embeddings. Embeddings виправдані, коли запити формулюються словами, яких немає в тексті документів. Для точних термінів, кодів і структурованих даних full-text або SQL працюють краще й дешевше.

pgvector чи tsvector: що обрати?

pgvector — для пошуку за змістом (синоніми, перефразування, різні мови). tsvector — для пошуку за словами (точні терміни, свіжі дані, чесне «не знайдено»). Обидва живуть у PostgreSQL, тому їх можна поєднати в гібриді без окремої пошукової системи.

Чи працює full-text пошук для моєї мови?

Залежить від наявності словника в PostgreSQL. Для англійської, німецької, іспанської та ряду інших мов стемінг вбудований. Для мов без словника використовуйте конфігурацію 'simple' з префіксним пошуком (слово:*) і pg_trgm — це частково компенсує відсутність стемінгу.

Чи може MCP-сервер робити vector search?

Так. MCP-сервер — це звичайний застосунок, який надає tools. Усередині tool можна виконувати будь-який пошук, зокрема векторний через pgvector, Qdrant чи інший vector store. Модель бачить лише назву, опис і параметри tool та результат, який він повернув.

Що таке RRF?

Reciprocal Rank Fusion — спосіб об'єднати кілька списків результатів за позиціями, а не за score: кожен документ отримує суму 1/(60 + позиція) з кожного списку. Так можна поєднати векторний і full-text пошук, хоча їхні score мають різні шкали.

✅ Висновки

Пошук для MCP — це не про те, який метод «розумніший». Це про те, щоб пошук не вводив модель в оману: знаходив правильне, коли воно є, і чесно казав «немає», коли його немає.

Ось що я рекомендую:

  • Блог або база статей з концептуальними питаннями — embeddings, але з порогом, відкаліброваним на ваших запитах. Саме так працює мій сервер.
  • Точні терміни, назви API, коди помилок — full-text + trigram. Embeddings тут розмивають результат.
  • Структуровані дані — не пошук, а типізовані tools з параметризованим SQL.
  • Корпоративна документація зі змішаними запитами — гібрид, а за потреби ще й reranking.
  • Завжди — явний статус not_found, URL джерела в кожному результаті і опис tool, який пояснює моделі, як формулювати запит.
  • Перевіряйте на запитах, які пише модель, і обов'язково — на запитах, на які відповіді немає.