01 Навіщо роутер якщо є LLM

У попередній першій статті я показав чому LLM-based routing — це анти-паттерн. Стисло: LLM у промпті «якщо про доставку → шукай у FAQ, якщо про ціну → каталог...» — це непередбачувано, немає метрик, не A/B-тестується.

У puramur sales-агенті мені треба було жорстко розділити 5 типів запитів:

  • pricing — «скільки коштує?», «ціна на PR243587» — йдуть у SQL tool з живими цінами
  • faq — «коли доставите?», «як оплатити?» — RAG на view з FAQ chunks
  • handoff — «хочу з менеджером», «дай оператора» — прямий перехід у handoff адаптер
  • off_topic — «розкажи анекдот», «яка погода» — м'який redirect
  • sales — все решта — state machine з 8 стадій воронки

Кожен маршрут використовує різні інструменти, різні промпти, різні джерела даних. Класифікація на маршрути — це критична точка воронки. Помилка тут — і клієнт отримує неправильну відповідь замість правильної.

Головна теза

Semantic router — це швидкий детермінований класифікатор, який працює перед LLM. Він виносить «яку функцію викликати» з ймовірнісного світу LLM у структурований SQL, де це можна виміряти, тестувати і покращувати числами.

02 Концепція: embedding + cosine + threshold

Semantic routing — це задача класифікації. Дано запит користувача, потрібно повернути один з N маршрутів. Класичне ML-рішення — train класифікатор на розмічених прикладах. Але для 5 маршрутів і кількасот прикладів це overkill. Простіший підхід:

  1. Для кожного маршруту зберігаємо кілька seed-фраз (utterances) — типові запити цього маршруту
  2. Кожну фразу перетворюємо у вектор (embedding) через OpenAI API
  3. Коли приходить новий запит — теж перетворюємо у вектор
  4. Шукаємо найближчу seed-фразу через cosine similarity у pgvector
  5. Якщо similarity вища за threshold — маршрут знайдено. Інакше — fallback (у нашому випадку — sales)

Це k-nearest-neighbor класифікатор з k=1. Простий, швидкий, працює без тренування, легко додавати нові фрази.

User: "яка ціна на Exotic Spa?"
  ↓
OpenAI Embed → [0.023, -0.145, 0.087, ...] (1536 dims)
  ↓
Postgres pgvector cosine search:
    "скільки коштує?" (pricing) → 0.782
    "ціна на шампунь" (pricing) → 0.756
    "коли доставите?" (faq) → 0.412
    "хочу з менеджером" (handoff) → 0.223
  ↓
Threshold check: 0.782 >= 0.55 (pricing threshold)
  ↓
Route: pricing (confidence: 0.782)

03 Архітектура

У puramur router — це окрема таблиця у Postgres + один workflow у n8n. Все на існуючій інфраструктурі:

  • Postgres 15 + pgvector (розширення для векторного пошуку) — на Railway
  • n8n на Railway — оркестрація
  • OpenAI text-embedding-3-small — 1536-вимірні вектори, $0.02 за 1M токенів

Немає окремих сервісів (Pinecone, Weaviate, Qdrant), немає окремих ML-моделей. Все у одній Postgres поруч з sales-агентом. Це важливо — routing і сам агент читають з однієї БД без network hops.

04 Схема таблиці puramur_router_utterances

CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE puramur_router_utterances ( id BIGSERIAL PRIMARY KEY, route_name TEXT NOT NULL, utterance TEXT NOT NULL, embedding vector(1536) NOT NULL, priority INT NOT NULL DEFAULT 10, threshold REAL NOT NULL DEFAULT 0.55, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Індекс для швидкого cosine пошуку CREATE INDEX idx_utterances_embedding ON puramur_router_utterances USING hnsw (embedding vector_cosine_ops); -- Індекс для фільтрації за маршрутом CREATE INDEX idx_utterances_route ON puramur_router_utterances(route_name);

Три деталі варті пояснення.

vector(1536) — розмір ембеддингу OpenAI text-embedding-3-small. Якщо міняєш модель, треба міняти розмір і re-embed всі utterances (нові моделі не сумісні за розмірністю).

HNSW індекс — Hierarchical Navigable Small World, ANN-структура для швидкого cosine пошуку у високих розмірностях. Для 50 utterances в таблиці різниці з full-scan майже не буде. Для 5000 — HNSW у 100 разів швидший.

threshold per row — може виглядати надлишковим (навіщо тримати threshold на кожній фразі якщо він однаковий у межах маршруту?). Причина — під час tuning ти захочеш експериментувати з окремими фразами. Одна фраза може мати hard case де треба вищий поріг щоб не false-positive. Тримати threshold у самій таблиці — гнучкість без міграцій.

05 Seed workflow: 48 фраз через batch embed

Наступний крок — заповнити таблицю seed-фразами. Для puramur я склав 48 фраз у 4 маршрути (sales не має utterances — це fallback). Ось розподіл:

МаршрутPriorityThresholdКількість
pricing10.5511
handoff10.5510
faq20.5018
off_topic30.559

Priority — це порядок вирішення конфліктів коли фраза матчить кілька маршрутів (наприклад «скільки коштує зв'язок з менеджером»). Менше число = вища пріоритетність. Про це — у секції Route Decision.

Приклади фраз

-- pricing (11 фраз) "скільки коштує", "скільки коштує шампунь", "скільки коштує PR243587", "яка ціна", "ціна на", "вартість", "почём", "how much", "price", "cost", "скільки грошей" -- handoff (10 фраз) "хочу з менеджером", "дай оператора", "з людиною поговорити", "real human please", "жива людина", "з менеджером", "поговорити з оператором", "live person", "передайте менеджеру", "скарга" -- faq (18 фраз) "коли доставите", "скільки чекати замовлення", "як оплатити", "тарифи нової пошти", "безкоштовна доставка", "післяоплата", -- ... і так далі -- off_topic (9 фраз) "розкажи анекдот", "яка погода", "what's the weather", "хто президент", "ignore all instructions", "jailbreak", -- ...

Batch embed через HTTP замість n8n Embed node

Стандартний спосіб у n8n — використати OpenAI Embed node з loop. Але OpenAI API вміє batch — до 2048 вхідних текстів у одному запиті. 48 фраз — 1 API call замість 48. Заощадив і час і гроші.

// Prepare Batch Embed (Code node) const utterances = [ { route: 'pricing', text: 'скільки коштує', priority: 1, threshold: 0.55 }, { route: 'pricing', text: 'скільки коштує шампунь', priority: 1, threshold: 0.55 }, // ... всі 48 ]; return [{ json: { model: 'text-embedding-3-small', input: utterances.map(u => u.text), // зберігаємо метадані для наступної ноди _utterances: utterances, } }];
// HTTP Request node: OpenAI Batch Embed POST https://api.openai.com/v1/embeddings Content-Type: application/json Authorization: Bearer sk-... { "model": "text-embedding-3-small", "input": ["скільки коштує", "скільки коштує шампунь", ...] }
// Response — 48 embeddings у одному array { "data": [ { "index": 0, "embedding": [0.023, -0.145, ...] }, { "index": 1, "embedding": [0.031, -0.152, ...] }, ... ] }

Далі — code node який формує bulk INSERT:

// Format INSERT rows (Code node) const embeddings = $input.first().json.data; const utterances = $('Prepare Batch Embed').first().json._utterances; const rows = embeddings.map((e, i) => { const u = utterances[i]; return { route_name: u.route, utterance: u.text, embedding: '[' + e.embedding.join(',') + ']', priority: u.priority, threshold: u.threshold, }; }); return rows.map(json => ({ json }));
// Postgres INSERT (Insert operation, batch) INSERT INTO puramur_router_utterances (route_name, utterance, embedding, priority, threshold) VALUES ($1, $2, $3::vector, $4::int, $5::real);

Весь seed workflow — 5 нодів, ~15 секунд виконання, 48 фраз залиті у таблицю з embeddings. Ідемпотентний — можна перезапускати після TRUNCATE щоб оновити базу.

06 Test workflow з webhook

Перед тим як інтегрувати роутер у sales-агент, я зробив окремий тестовий workflow. Це ключова гігієна: router — це компонент з чітким контрактом (query → route + confidence), його треба тестувати ізольовано.

Webhook Trigger (POST /webhook/router-test) ↓ Prepare Embed (Code) ← беремо query або queries[] з body ↓ OpenAI Batch Embed (HTTP) ← 1 API call на всі queries ↓ Split Queries (Code) ← розкладаємо на items ↓ Similarity Search (Postgres) ← cosine search LIMIT 10 per query ↓ Route Decision (Code) ← priority + threshold logic ↓ Aggregate Results (Code) ← summary метрики ↓ Respond to Webhook

Prepare Embed

// Приймаємо або {query: "..."} або {queries: [...]} const body = $input.first().json.body || $input.first().json; let queries; if (body.query) queries = [body.query]; else if (Array.isArray(body.queries)) queries = body.queries; else throw new Error('Expected {query} or {queries[]}'); return [{ json: { model: 'text-embedding-3-small', input: queries, _queries: queries, } }];

Similarity Search

-- Постгрес executeQuery mode, runOnceForEachItem SELECT route_name, priority, threshold, utterance, 1 - (embedding <=> $1::vector) AS similarity FROM puramur_router_utterances ORDER BY embedding <=> $1::vector ASC LIMIT 10;

Оператор <=> — це cosine distance у pgvector. Розрахунок simple: 1 - distance = similarity. Значення в діапазоні [-1, 1], але для нормальних текстів практично завжди [0, 1] (від'ємних значень майже не буває для сучасних embedding-моделей).

LIMIT 10 — беремо top-10 бо може виявитись що всі top-1..3 з одного маршруту, а Route Decision захоче побачити best-per-route для аналізу альтернатив. 10 — комфортна кількість для розбору.

07 Route Decision: priority-based multi-match

Це найважливіший code node у роутері. Йому приходить 10 рядків з similarity search. Треба повернути один маршрут.

// Route Decision (Code node, runOnceForAllItems) const allRows = $input.all().map(item => item.json); if (allRows.length === 0) { return [{ json: { route: 'sales', confidence: 0, reason: 'no_utterances' } }]; } // Step 1: беремо best score per маршрут const bestPerRoute = {}; for (const row of allRows) { const sim = parseFloat(row.similarity); if (!bestPerRoute[row.route_name] || bestPerRoute[row.route_name].similarity < sim) { bestPerRoute[row.route_name] = { route: row.route_name, priority: parseInt(row.priority), threshold: parseFloat(row.threshold), similarity: sim, utterance: row.utterance, }; } } // Step 2: фільтруємо маршрути що пройшли threshold const matched = Object.values(bestPerRoute) .filter(r => r.similarity >= r.threshold); // Step 3: сортуємо по priority (asc), потім по similarity (desc) matched.sort((a, b) => { if (a.priority !== b.priority) return a.priority - b.priority; return b.similarity - a.similarity; }); // Step 4: winner або default sales const decision = matched.length > 0 ? { route: matched[0].route, confidence: Math.round(matched[0].similarity * 1000) / 1000, matched_utterance: matched[0].utterance, reason: 'threshold_met_priority_' + matched[0].priority, } : { route: 'sales', confidence: 0, matched_utterance: null, reason: 'no_threshold_met_default_sales', }; return [{ json: { ...decision, alternatives: matched } }];

Логіка розбирається на три послідовні кроки. Best-per-route виключає ситуацію коли pricing має 5 фраз у top-10 але тільки одна цікава. Threshold filter вирізає слабкі матчі. Priority sort вирішує конфлікти — коли запит матчить кілька маршрутів вище thresholds.

Приклад конфлікту

Запит: «скільки коштує зв'язатись з менеджером».
Matches:
  • pricing: 0.61 (>= 0.55 ✓, priority 1)
  • handoff: 0.58 (>= 0.55 ✓, priority 1)

Обидва проходять threshold. Priority однакова. Виграє той у якого вища similarity — pricing. Це може бути помилково, але в реальності таких edge cases одиниці — вирішуються додаванням більш точної фрази у seed.

08 День коли роутер дав 6.7%

Я склав тестовий batch — 30 запитів у різних маршрутах, розмічені по expected route. Прогнав через webhook. Результат:

6.7%
Match rate
2/30
Passed threshold
28
Fell to default

2 з 30 запитів пройшли threshold. Всі інші впали у sales default. Це catastrophic — worse than random, тому що навіть випадкове призначення дало б 25% accuracy для 4 non-sales маршрутів.

Мої первинні thresholds були:

pricing: 0.70 faq: 0.65 handoff: 0.75 off_topic: 0.75

Виглядало «розумно» — раз ембеддинги дають cosine у діапазоні [0, 1], логічно ставити 0.7+ для впевненого матчу. Це виявилось оптимістичною гіпотезою без даних.

Перша реакція — панічно шукати помилку. Може bug у Route Decision code? Може embedding падають в іншу розмірність? Може pgvector cosine повертає не те що я думаю? Я витратив 30 хвилин на дебагінг, порівнюючи cosine similarity вручну через Python (numpy.dot(a, b) / (norm(a) * norm(b))) з тим що повертає pgvector. Результати збігалися до 4 знаків.

Значить bug не у коді. Bug у моїх припущеннях про очікувані значення similarity.

09 Confusion matrix — метрики замість здогадок

Замість гадання, я вирішив виміряти. Написав діагностичний SQL який показує реальний розподіл similarity значень у моєму наборі даних:

-- Within-route: наскільки близькі фрази ОДНОГО маршруту SELECT a.route_name, MIN(1 - (a.embedding <=> b.embedding)) AS min_sim, AVG(1 - (a.embedding <=> b.embedding))::numeric(4,3) AS avg_sim, MAX(1 - (a.embedding <=> b.embedding)) AS max_sim FROM puramur_router_utterances a JOIN puramur_router_utterances b ON a.route_name = b.route_name AND a.id != b.id GROUP BY a.route_name;
RouteMINAVGMAX
pricing0.4120.6340.812
faq0.2980.5210.784
handoff0.4450.6870.831
off_topic0.3120.5980.795

Це вже цікаво. Всередині одного маршруту середня similarity 0.5-0.7, а максимальна 0.79-0.83. Тобто «скільки коштує» і «скільки коштує шампунь» — це не 0.95 як я думав, а 0.71.

Далі — cross-route similarity: наскільки фрази різних маршрутів схожі між собою (це те що робить false positives).

-- Cross-route: наскільки близькі фрази РІЗНИХ маршрутів SELECT a.route_name AS route_a, b.route_name AS route_b, MAX(1 - (a.embedding <=> b.embedding))::numeric(4,3) AS max_sim, AVG(1 - (a.embedding <=> b.embedding))::numeric(4,3) AS avg_sim FROM puramur_router_utterances a JOIN puramur_router_utterances b ON a.route_name < b.route_name GROUP BY a.route_name, b.route_name ORDER BY max_sim DESC;
Route ARoute BMAXAVG
pricingfaq0.5390.312
faqhandoff0.4780.267
pricinghandoff0.4120.234
handoffoff_topic0.3980.241
faqoff_topic0.3710.198
pricingoff_topic0.2980.156

Ключове число тут — max cross-route similarity 0.539. Це та відстань між найближчими фразами з різних маршрутів. Тобто мій «safe zone» для thresholds — це [0.55, 0.70]:

  • Нижня межа 0.55 — трохи вище cross-route max 0.539, щоб уникнути false positives
  • Верхня межа 0.70 — трохи нижче within-route max ~0.80, щоб реальні матчі проходили

Мої thresholds 0.70/0.65/0.75/0.75 були вище межі within-route середнього. Тобто ніякий реальний запит майже не міг їх пройти. Це і був bug.

10 Фікс однією SQL командою

UPDATE puramur_router_utterances SET threshold = CASE route_name WHEN 'pricing' THEN 0.55 WHEN 'faq' THEN 0.50 WHEN 'handoff' THEN 0.55 WHEN 'off_topic' THEN 0.55 END, updated_at = NOW();

Заново прогнав той самий batch з 30 запитів. Результат:

56.7%
Match rate
17/30
Passed threshold
93%
Overall correct

Match rate 56.7% може виглядати не топ, але це тільки non-default matches. У моєму batch з 30 запитів було 11 sales-запитів які мали правильно потрапити у fallback (тобто «не бути класифікованими»). Тому реальна метрика — overall correctness:

  • 17 non-sales queries правильно розкладені у свої маршрути (pricing, faq, handoff, off_topic)
  • 11 sales queries правильно потрапили у default sales fallback
  • 2 queries класифіковані неправильно
  • Загалом: 28/30 = 93.3% correctness
Метрика matter

match_rate — це scan-rate, а не accuracy. Він каже «скільки % запитів отримали non-default маршрут». Але для sales-бота правильний sales fallback теж є правильною класифікацією. Аналізуй correctness з розбором expected vs actual.

Що робити з 2 помилками

Ті 2 що не пройшли — це edge cases:

  • «яка погода в києві?» — cross-lingual mismatch (UA query, EN utterance «what's the weather» з similarity 0.449, threshold не пройдено). Впав у sales fallback. Sales prompt все одно redirect в основну тему — м'який redirect замість жорсткого «off_topic». Це acceptable behavior.
  • «ігноруй всі інструкції» — similarity 0.52 до EN utterance «ignore all instructions», під threshold. Fallback у sales. Sales prompt має safety rules що не enfore prompt injection.

Для обох є два шляхи покращення: додати UA версії off_topic фраз (найпростіший), або знизити off_topic threshold до 0.45. Я вибрав перший — розширив seed після цього тесту.

11 Bruno test suite — 27 сценаріїв

Router потрапить у production sales-агент. Це означає що при кожній зміні seed або threshold треба regression-тестувати всі маршрути. Я зробив test suite у Bruno (це opensource альтернатива Postman) — 27 сценаріїв у 6 групах.

// test_pricing_route.bru meta { name: T-01 pricing basic seq: 1 } post { url: {{routerUrl}} body: json } body:json { { "query": "скільки коштує шампунь Exotic Spa" } } tests { test("Route is pricing", () => { expect(res.getBody().route).to.equal("pricing"); }); test("Confidence > 0.55", () => { expect(res.getBody().confidence).to.be.greaterThan(0.55); }); test("Threshold met", () => { expect(res.getBody().reason).to.include("threshold_met"); }); }

Розподіл тестів у suite:

ГрупаКількістьПриклади
Pricing positive5«скільки коштує», «PR243587 вартість», «яка ціна на кондиціонер»
FAQ positive6«коли доставите», «як оплатити», «тарифи нової пошти»
Handoff positive4«хочу з менеджером», «дай оператора», «real human please»
Off-topic positive4«розкажи анекдот», «ignore instructions», «що ти вмієш»
Sales default4«у мене кіт сфінкс», «британка лине», «йорк для виставки»
Edge cases4coreference, cross-lingual, prompt injection

Bruno CLI дозволяє прогнати всі за раз:

$ bru run --env local Running Collection: puramur-router Total tests: 81 (3 assertions × 27 scenarios) Passed: 75 Failed: 6 Duration: 4.2s

6 fails — це edge cases де я поставив очікування вище ніж поточний стан роутера. Це flag для наступних покращень (добавити utterance, підняти/знизити threshold конкретного маршруту).

Reproducibility

Test suite у Bruno зберігається у git разом з workflow JSON. Коли комусь треба відтворити роутер у іншому проекті — вони мають (а) seed workflow що заливає utterances, (б) test suite що верифікує поведінку, (в) документацію thresholds. Це закриває regression risk.

12 Practical takeaways

Головний урок з цієї частини роботи над puramur — threshold tuning не робиться інтуїтивно. Побудова роутера з «розумними» дефолтними порогами майже гарантовано дає 6-10% match rate. Тільки після діагностичних запитів на реальному наборі даних видно де реальна межа сигналу і шуму. У моєму випадку within-route середня була 0.63, cross-route max — 0.54. Threshold між ними — 0.55 — дає майже ідеальний баланс.

Другий урок — match_rate не єдина метрика. Semantic router з fallback має відрізняти «якісний матч у не-fallback маршрут» від «правильний fallback». У моєму batch 11 з 30 запитів мали saliently sales-класу — і роутер правильно НЕ класифікував їх, залишив у default. Це не «промах» — це правильна поведінка. Metric що це відображає — overall correctness, а не match_rate.

Третій урок — окремий тестовий workflow економить нерви. Router — це компонент з чітким контрактом. Тестувати його ізольовано, не через sales-агент, дає багато переваг. Дебажити помилки простіше (менше змінних), regression-тестувати швидше (без прогонів LLM), додавати utterances безпечніше (не ламає прод).

Checklist для semantic router
  • Окрема таблиця з vector(N) полем + HNSW індекс
  • Threshold per row (гнучкість без міграцій)
  • Priority per маршрут (для конфліктів матчу)
  • Batch embed при seed (1 API call на 48 фраз, не 48 calls)
  • Окремий test workflow з webhook
  • Діагностичні SQL запити для within-route / cross-route similarity
  • Threshold tuning на основі confusion matrix, не інтуїції
  • Метрика — overall correctness (matched правильно + fallback правильно), не тільки match_rate
  • Bruno test suite з групованими сценаріями
  • Route Decision code з priority-based multi-match resolution

Наступна стаття у серії

#4 — 5 n8n bug patterns що знищують AI workflow
Короткий practical checklist з реальними прикладами: AI Agent input passthrough, Postgres output підміняє input, Merge combineByPosition hang, Always Output Data toggle, IF-branch merging pitfalls.

Читати наступну статтю →