Чат-бот для портфоліо, який каже «я не знаю», а не вгадує
Як чат-бот на сайті-портфоліо залишається чесним: заблокований регулятор чесності, п'ять написаних вручну нотаток у промпті замість RAG і фіксована фраза відмови, що водночас служить детектором прогалин.
Дайте будь-якій мовній моделі ім'я людини, про яку вона ніколи не чула, і вона все одно її опише — впевнено й детально: кар'єрний шлях, набір навичок, навіть думку про її сильні сторони. Нічого з цього не існує. «Галюцинація» — м'яке слово для такого. Модель нічого не переплутала. Вона будує людину з нічого, гладко й без тіні сумніву.
Для чат-бота на сайті-портфоліо в цьому вся проблема. Кожна вигадана деталь — це речення, яке рекрутер може прочитати в понеділок і повторити на співбесіді в четвер, а власникові сайту залишиться пояснювати, що його власний сайт щось про нього вигадав. Доброго способу провести таку розмову немає.
Тож питання проєктування перевертається. Навчити модель вільно говорити про когось можна за один вечір. Навчити її казати — конкретно й так, щоб це можна було порахувати, — що вона чогось не знає, займає значно більше часу, і саме там починається цікава робота.
Бота в цій історії звуть PATS. Він живе в кутку сайту, відповідає лише за нотатками, написаними вручну, а коли питання виходить за їхні межі, видає одне фіксоване речення й відсилає відвідувача на пошту власника. Це фіксоване речення виявилося найкориснішою частиною проєкту. Відсутність векторної бази даних — другою.
Ані RAG, ані векторної бази: приблизно чотири тисячі слів рукописних нотаток і одне правило — ботові заборонено імпровізувати. Цифри нижче виміряно на живому розгортанні 13 вересня 2026 року.
Регулятор, що не крутиться
Почнімо з того, чого відвідувач може торкнутися. У PATS три налаштування: чесність, гумор і стислість. Працюють два. Гумор перемикається від сухого клінічного тону до позначки «максимальний сарказм», і тон змінюється повністю. Стислість іде від багатослівної розповіді, де вам пропонують більше, ніж ви питали, до відповідей в один рядок.
Чесність зафіксовано на 100. У неї ті самі п'ять поділок, що й у двох інших регуляторів, у тому самому ряду й тієї самої форми, тож вона виглядає як справжній елемент керування, а не підпис. Чотири поділки нижче 100 затемнені, і там, де інші рядки показують поточне значення, у цьому написано «заблоковано». Жодна поділка не реагує.
У коді це константа з коментарем, що пояснює вибір:
/**
* Not a parameter. It is displayed as a percentage because that is the joke,
* and it is a `const` because a lowerable honesty setting would contradict
* the one rule the whole site is built on.
*/
export const HONESTY = 100;
Моделі вона ніколи не надсилається. Її взагалі немає в тілі запиту, тож навіть вручну складений запит не зможе її знизити.
Ідею взято з «Інтерстеллара», де особистість робота налаштовується, і налаштовує її екіпаж. Купер виставляє гумор TARS на 75 відсотків. Чесність стоїть на 90, бо, як пояснює TARS, абсолютна чесність не завжди найдипломатичніший чи найбезпечніший спосіб спілкування з емоційними істотами.
Регулятор гумору залишився. Чесність пішла на 100, бо аргумент TARS на користь тих відсутніх десяти відсотків тут не працює. TARS дбає про моральний дух екіпажу в далекому космосі, а PATS відповідає на питання про резюме, де мало що емоційно заряджене. Мінус десять відсотків чесності не купили б тут такту, а лише коштували б точності.
Сайт, який стверджує, що гучна відмова краща за правдоподібну хибну відповідь, повинен мати рівно одне налаштування, яке ніколи не рухається, і це чесність. Елемент керування, за який рекрутер може взятися й виявити, що його намертво заварено, доводить думку краще за будь-який абзац. Далі — механізм, що стоїть за цим твердженням.
Чому немає векторної бази даних
Усе, що PATS може стверджувати, зберігається в п'яти markdown-файлах: профіль, проєкти, стиль роботи, інтереси та FAQ, загалом близько чотирьох тисяч слів. Під час завантаження модуля вони склеюються в один системний промпт, і весь цей промпт іде з кожним повідомленням. Немає ні пошуку, ні ембедингів, ні векторного сховища, ні розбиття на фрагменти, ні пошуку за схожістю.
Про це питають першим ділом, зазвичай звівши брову, тож ось логіка.
Значна частина аргументів на користь RAG стосується вартості. Надсилати весь корпус із кожним запитом непомірно дорого, тому ви дістаєте жменьку фрагментів, які видаються найрелевантнішими. Кешування промптів змінило цей розрахунок за такого обсягу. Повний промпт займає 10 870 токенів, і після першого запиту він віддається з кешу приблизно за десяту частину звичайної ціни вхідних токенів. У підсумку виходить менше цента за повідомлення навіть на найдорожчому налаштуванні. На тлі такої цифри жодна архітектура пошуку не окупається, а будь-яка її версія означала б ще один сервіс, ще одну схему й ще одну річ, яка може зламатися.
Друга причина й стала вирішальною. Ретривер, що промахується, шкодить більше, ніж його відсутність. Якщо потрібний фрагмент не повернувся, питання все одно надходить, і моделі доводиться відповідати з порожніми руками — а це саме та умова, що породжує впевнену вигадку. Саме цьому збою проєкт і має запобігати, тож RAG протягнув би тихий режим відмови в систему, створену, щоб таких режимів не мати. Заблокований регулятор чесності й відсутність векторної бази — одне й те саме рішення, ухвалене двічі.
Але не варто розтягувати цю тезу надто далеко. Вона стосується приблизно десяти тисяч токенів нотаток, які ніколи не переростуть тексти однієї людини. Це не вирок, що RAG мертвий чи що векторні бази безглузді. За вдесятеро більшого обсягу цифри перевертаються, і шар пошуку себе виправдовує; те саме слушно, якщо бот має відповідати за реальними кодовими базами, а не за нотатками про них. Сьогодні жодна з цих умов не виконується, і впроваджувати пошук лише тому, що всі цього чекають, було б справжньою помилкою.
Завантажувач навмисно гучно реагує на прогалини. Якщо одного з п'яти файлів немає або він порожній, збірка падає, тож бот із діркою в знаннях ніколи не потрапить у продакшн, де на неї натрапить відвідувач.
Фраза відмови як вимірювальний інструмент
Щоразу, коли в нотатках немає відповіді, PATS завершує репліку одним реченням, яке не змінюється ні на символ:
That's not in my notes. You can ask the owner directly at their email address.
(«Цього немає в моїх нотатках. Можете спитати власника напряму на його пошту». У справжньому реченні вказано ім'я власника та реальну адресу.) Речення існує не з ввічливості. Це інструмент.
Закрита адмін-сторінка аналізує записані розмови й знаходить питання, на які PATS не зміг відповісти, за точним збігом із цим рядком. Вона не просить модель класифікувати кожну відповідь. Вона порівнює рядки, у коді:
if (row.role !== "assistant" || !row.content.includes(FALLBACK_LINE)) continue;
// The visitor turn immediately before it, in the same session.
for (let j = i - 1; j >= 0; j--) {
if (rows[j].session_id !== row.session_id) break;
if (rows[j].role === "user") {
gaps.push({ question: rows[j].content, at: rows[j].created_at });
break;
}
}
Альтернативою був другий виклик-класифікатор на кожне повідомлення, що позначав би кожну відповідь як надану чи ненадану. Це подвоїло б вартість і затримку на гарячому шляху заради того, що порівняння рядків і так знає, а список прогалин опинився б у формі, яку модель могла б перефразувати до невпізнання. З фіксованим рядком на кожен хід припадає один виклик моделі, а виявлення прогалин дістається безкоштовно.
Кожне питання, на яке PATS не може відповісти, перетворюється на конкретний рядок у списку нотаток, які ще треба написати. Незнання бота — це і є фіча, і єдина частина системи, яка підказує, що будувати далі.
Ціна в тому, що речення тепер заморожене. Якщо його переформулювати, усі вже записані прогалини тихо втратять прив'язку, тож зміна вважається зміною специфікації, а не правкою тексту, і фіксується саме так.
Одна тонкість тримає всю конструкцію. PATS відхиляє й питання, на які міг би відповісти, наприклад про компенсацію, імміграційні подробиці та родину. На них надходить коротка доброзичлива відмова, яка навмисно не використовує фразу відмови. Інакше список прогалин заповнився б питаннями, на які ніхто не збирається відповідати, і інструмент перестав би вимірювати те, для чого створений. Свідома межа — це не прогалина.
Половина «шукач талантів»
PATS розшифровується як Portfolio Assistant and Talent Scout, і другу половину зазвичай розуміють навпаки. Він не шукає таланти для відвідувача. Він виглядає можливості для власника.
Залиште в чаті email, посилання на LinkedIn чи посилання для запису на зустріч або згадайте, що наймаєте, — і власник отримає лист. Це робить регулярний вираз, а не виклик моделі. Три деталі в детекторі забрали більше часу, ніж сама функція.
По-перше, одержувач — жорстко задана константа, і його ніколи не виводять із того, що надруковано в чаті. Хоч би що ввів відвідувач, лист потрапить або до вхідних власника, або нікуди. Це весь захист від зловживань, і він значно коротший за альтернативи.
По-друге, шаблон номера телефону вимагає нецифрового символу з обох боків. Чати тут повні чисел на кшталт 10,870 tokens і 4,325 rows, а детектор, що сприймає лічильник токенів за номер, на який можна зателефонувати, здіймає хибну тривогу.
По-третє, власні підказки віджета ніколи не повинні його запускати. «Що шукає власник?» — клікабельна підказка, а отже, лексика про найм сама собою тригером бути не може. Детекторові, що спрацьовує на власні підказки, перестають довіряти.
Баг виявився цікавішим за саму функцію. Сесії позначалися як сповіщені ще до спроби надсилання — у тій самій колонці, де зберігалася причина спрацювання. Якщо надсилання потім провалювалося, наприклад через відсутній API-ключ пошти чи вичерпаний денний ліміт, сесія назавжди лишалася позначеною як непридатна для листа, навіть після усунення причини. Потрібен був один лист на сесію, але перевірка спиралася на колонку, записану до того, як з'явився бодай один лист. Тепер вона перевіряє часову мітку, яку ставлять лише після відповіді 2xx.
Як спробувати
PATS сидить у правому нижньому куті портфоліо, за кнопкою чату на головній сторінці. Спитайте в нього те, чого він аж ніяк не може знати, і подивіться, як він відмовляється вгадувати. Стек — Next.js App Router, TypeScript, Supabase Postgres і zod.