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. Простіший підхід:
- Для кожного маршруту зберігаємо кілька seed-фраз (utterances) — типові запити цього маршруту
- Кожну фразу перетворюємо у вектор (embedding) через OpenAI API
- Коли приходить новий запит — теж перетворюємо у вектор
- Шукаємо найближчу seed-фразу через cosine similarity у pgvector
- Якщо similarity вища за threshold — маршрут знайдено. Інакше — fallback (у нашому випадку — sales)
Це k-nearest-neighbor класифікатор з k=1. Простий, швидкий, працює без тренування, легко додавати нові фрази.
↓
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). Ось розподіл:
| Маршрут | Priority | Threshold | Кількість |
|---|---|---|---|
pricing | 1 | 0.55 | 11 |
handoff | 1 | 0.55 | 10 |
faq | 2 | 0.50 | 18 |
off_topic | 3 | 0.55 | 9 |
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. Результат:
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;
| Route | MIN | AVG | MAX |
|---|---|---|---|
| pricing | 0.412 | 0.634 | 0.812 |
| faq | 0.298 | 0.521 | 0.784 |
| handoff | 0.445 | 0.687 | 0.831 |
| off_topic | 0.312 | 0.598 | 0.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 A | Route B | MAX | AVG |
|---|---|---|---|
| pricing | faq | 0.539 | 0.312 |
| faq | handoff | 0.478 | 0.267 |
| pricing | handoff | 0.412 | 0.234 |
| handoff | off_topic | 0.398 | 0.241 |
| faq | off_topic | 0.371 | 0.198 |
| pricing | off_topic | 0.298 | 0.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 запитів. Результат:
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
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 positive | 5 | «скільки коштує», «PR243587 вартість», «яка ціна на кондиціонер» |
| FAQ positive | 6 | «коли доставите», «як оплатити», «тарифи нової пошти» |
| Handoff positive | 4 | «хочу з менеджером», «дай оператора», «real human please» |
| Off-topic positive | 4 | «розкажи анекдот», «ignore instructions», «що ти вмієш» |
| Sales default | 4 | «у мене кіт сфінкс», «британка лине», «йорк для виставки» |
| Edge cases | 4 | coreference, 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 конкретного маршруту).
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 безпечніше (не ламає прод).
- Окрема таблиця з
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.