Grounding в AI агентах: що робити коли tool call повернув не те

Aktualisiert:
Grounding в AI агентах: що робити коли tool call повернув не те

Уявіть: ваш AI агент отримав запит «яка ціна на Enterprise план?». Він викликав tool. Tool відповів. Агент сформулював відповідь — впевнено, зв'язно, з конкретною цифрою. Клієнт отримав відповідь і пішов задоволений.

Проблема в тому що tool повернув порожній результат — документ не знайшовся. А агент замість того щоб сказати «я не знайшов» — вигадав ціну з власних тренувальних даних. Впевнено. Без жодного попередження.

Це не баг. Це відсутність grounding — і саме про це стаття.

Ця стаття — частина серії про AI агентів на Spring Boot. Якщо ви ще не читали як модель вирішує коли викликати tool — почніть з Як LLM вирішує коли викликати tool.

Зміст

Що таке grounding і чому його немає в туторіалах

Більшість туторіалів про AI агентів виглядають так:

1. Отримай запит від користувача
2. Виклич tool
3. Передай результат моделі
4. Отримай відповідь
5. ???
6. Profit

Крок 5 — це і є grounding. І туторіали його пропускають бо він «нецікавий». Немає ефектного демо. Немає wow-моменту. Просто нудна перевірка якості перед тим як відповідати.

Grounding — це процес де агент перевіряє чи результат який повернув tool дійсно відповідає на запит, чи він достатньо якісний щоб будувати на ньому відповідь, і чи може він послатись на конкретне джерело а не вигадати відповідь.

Чому це критично — аналогія з реального життя

Уявіть що ви найняли асистента і попросили: «Знайди мені умови нашого договору з клієнтом Альфа».

Асистент пішов в архів. Повернувся через хвилину. І розповів вам умови договору — впевнено і детально.

Але насправді він нічого не знайшов в архіві. Просто згадав схожий договір з іншим клієнтом і вирішив що «приблизно так само».

Ви підписали нову угоду на основі цих умов. І тільки через тиждень виявили що умови були зовсім інші.

Саме так поводиться AI агент без grounding. І саме тому це не академічна концепція — це питання довіри до вашого продукту.

Реальний кейс з AskYourDocs: на початку розробки агент іноді відповідав на запити про конкретні документи навіть коли документ не був завантажений у базу знань. Модель «згадувала» схожий контент з тренувальних даних і подавала його як результат пошуку по документах клієнта. Після впровадження grounding перевірок — ця проблема зникла.

Три сценарії поганого tool result — з реальними прикладами

Не всі погані результати однакові. Розберемо три принципово різні сценарії — кожен вимагає окремої стратегії обробки. І кожен з них може призвести до різного типу помилки якщо не обробляти явно.

Сценарій 1: Порожній результат

Tool виконався успішно але нічого не знайшов. Здається — найпростіший випадок. Насправді — найчастіший привід для галюцинацій.

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

// Tool повернув порожній список
{
  "results": [],
  "total": 0
}

// Що модель робить БЕЗ grounding:
// User: "Яка ціна на Enterprise план?"
// Agent: "Згідно з корпоративними стандартами, 
//         ціна Enterprise плану складає $500/місяць..."
// (вигадала з тренувальних даних — реальна ціна $850)

// Що модель робить З grounding:
// Agent: "Я не знайшов інформації про ціни в базі знань. 
//         Рекомендую звернутись до відділу продажів."

Як обробляти в Java:

@Tool(description = "Шукає інформацію в базі знань")
public String searchKnowledgeBase(String query) {
    List<SearchResult> results = vectorStore.search(query);

    // Явно обробляємо порожній результат
    if (results == null || results.isEmpty()) {
        log.warn("Empty result for query: '{}'", query);
        // Не повертаємо порожній рядок — повертаємо явну інструкцію
        return String.format("""
            РЕЗУЛЬТАТ: нічого не знайдено для запиту "%s"
            ІНСТРУКЦІЯ ДЛЯ МОДЕЛІ: не відповідай з власних знань.
            Повідом користувача що інформація відсутня в базі знань.
            """, query);
    }

    return formatResults(results);
}

Реальний приклад з Agent Chat: Wikipedia tool іноді повертає порожній результат для вузькоспеціалізованих запитів — наприклад «vibe coding productivity statistics 2025». Без grounding агент одразу починав наводити статистику яку сам вигадав — і вона потрапляла в діалог як «реальний факт». Після додавання явної інструкції в порожній результат — агент коректно перемикається на Tavily або визнає що не знайшов.

Сценарій 2: Нерелевантний результат

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

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

// Запит: "умови розірвання договору з клієнтом Альфа"
// Tool повернув найближчий документ:
{
  "results": [
    {
      "title": "Загальні умови розірвання договорів",
      "content": "Розірвання договору можливе за 30 днів...",
      "score": 0.61  // низька релевантність — загальний шаблон, не договір Альфа
    }
  ]
}

// БЕЗ grounding модель відповість:
// "Договір з клієнтом Альфа можна розірвати за 30 днів попередження"
// (насправді в їхньому договорі — 60 днів і штрафні санкції)

// З grounding — перевіряємо score перед передачею в модель

Як обробляти в Java — перевірка порогу релевантності:

@Tool(description = "Шукає інформацію в базі знань")
public String searchKnowledgeBase(String query) {
    List<SearchResult> results = vectorStore.search(query);

    if (results == null || results.isEmpty()) {
        return buildNotFoundMessage(query);
    }

    SearchResult best = results.get(0);
    double score = best.getScore();

    if (score >= 0.75) {
        // Висока релевантність — передаємо впевнено
        return String.format("""
            РЕЗУЛЬТАТ (релевантність: висока):
            %s
            ДЖЕРЕЛО: %s
            """, best.getContent(), best.getDocumentTitle());

    } else if (score >= 0.55) {
        // Середня релевантність — попереджаємо модель
        return String.format("""
            РЕЗУЛЬТАТ (релевантність: середня, score: %.2f):
            %s
            УВАГА ДЛЯ МОДЕЛІ: результат може не точно відповідати на запит.
            Повідом користувача що відповідь може бути неточною.
            ДЖЕРЕЛО: %s
            """, score, best.getContent(), best.getDocumentTitle());

    } else {
        // Низька релевантність — краще визнати що не знайшли
        log.warn("Low relevance score {:.2f} for query: '{}'", score, query);
        return buildNotFoundMessage(query);
    }
}

private String buildNotFoundMessage(String query) {
    return String.format("""
        РЕЗУЛЬТАТ: інформація не знайдена для запиту "%s"
        ІНСТРУКЦІЯ: не відповідай з власних знань.
        Повідом що інформація відсутня або запропонуй уточнити запит.
        """, query);
}

Реальний приклад з AskYourDocs: клієнт запитував про умови конкретного договору. Документ не був завантажений в базу — але векторний пошук знаходив загальний шаблон договору зі score 0.63. Без grounding агент відповідав умовами шаблону як якщо б це були умови конкретного клієнта. Після додавання порогу 0.75 — агент коректно відповідає що конкретний договір не знайдено.

Сценарій 3: Помилка виконання

Tool впав з exception. Зовнішній API недоступний. Timeout. Rate limit вичерпано. Це технічна помилка — і вона найлегша для обробки якщо зробити це правильно.

Чому це небезпечно без обробки: модель отримує повідомлення про помилку і робить одне з двох — або ігнорує і відповідає з власних знань, або переказує технічні деталі помилки прямо користувачу. Обидва варіанти погані.

// Tool повернув помилку
{
  "error": "Connection timeout after 5000ms to https://api.tavily.com",
  "results": null
}

// БЕЗ grounding — варіант 1: модель ігнорує помилку
// "За даними останніх досліджень, vibe coding збільшує продуктивність на 40%..."
// (вигадала бо не знала що робити з помилкою)

// БЕЗ grounding — варіант 2: модель переказує помилку
// "Виникла помилка підключення до https://api.tavily.com після 5000ms timeout..."
// (технічні деталі які не мають бачити користувачі)

Як обробляти в Java — try-catch з явною grounding інструкцією:

@Tool(description = """
    Шукає актуальну інформацію в інтернеті через Tavily.
    Використовуй для свіжих новин і статистики.
    """)
public String searchWeb(String query) {
    log.info("Tavily search: '{}'", query);

    if (apiKey == null || apiKey.isBlank()) {
        // API ключ не налаштований — явна grounding інструкція
        return """
            ПОМИЛКА КОНФІГУРАЦІЇ: пошук недоступний.
            ІНСТРУКЦІЯ: повідом користувача що онлайн пошук 
            наразі не налаштований. Не відповідай з власних знань.
            """;
    }

    try {
        TavilyResponse response = callTavilyApi(query);

        if (response == null || response.results().isEmpty()) {
            return buildNotFoundMessage(query);
        }

        return formatResults(response.results());

    } catch (ResourceAccessException e) {
        // Timeout або недоступність сервісу
        log.warn("Tavily timeout for '{}': {}", query, e.getMessage());
        return """
            ТИМЧАСОВА ПОМИЛКА: сервіс пошуку недоступний.
            ІНСТРУКЦІЯ: повідом користувача що пошук тимчасово 
            недоступний і запропонуй спробувати пізніше.
            НЕ відповідай з власних знань замість пошуку.
            """;

    } catch (HttpClientErrorException e) {
        // Rate limit або помилка авторизації
        log.error("Tavily API error for '{}': {} {}", 
            query, e.getStatusCode(), e.getMessage());
        return """
            ПОМИЛКА API: ліміт запитів вичерпано.
            ІНСТРУКЦІЯ: повідом користувача що пошук 
            тимчасово обмежений. Спробуй відповісти 
            тільки якщо маєш точні знання з теми.
            """;

    } catch (Exception e) {
        log.error("Unexpected Tavily error for '{}': {}", query, e.getMessage());
        return """
            НЕВІДОМА ПОМИЛКА пошуку.
            ІНСТРУКЦІЯ: повідом користувача що виникла 
            технічна проблема. Не відповідай з власних знань.
            """;
    }
}
Зверніть увагу на патерн: кожен catch блок повертає різну grounding інструкцію залежно від типу помилки. Rate limit — можна спробувати відповісти якщо є точні знання. Timeout — краще не відповідати взагалі. Це тонка але важлива різниця яка впливає на якість відповідей агента.
Сценарій Що повертає tool Ризик без grounding Стратегія обробки Пріоритет
Порожній результат results: [] Галюцинація з тренувальних даних Явна інструкція в контенті 🔴 Критичний
Нерелевантний результат results з score < 0.55 Відповідь на неправильне питання Перевірка порогу + попередження 🔴 Критичний
Помилка виконання exception / timeout Технічні деталі у відповіді try-catch з окремими інструкціями 🟠 Високий
Підводний камінь: не використовуйте порожній рядок як повернення при помилці. Модель інтерпретує порожній рядок по-різному залежно від контексту — іноді як «нічого не знайдено», іноді як «можна відповідати самостійно». Завжди повертайте явну текстову інструкцію що саме сталось і що модель має робити далі.

Що модель робить з поганим результатом

Коли модель отримує поганий tool result — вона не зупиняється і не питає «що робити далі». Вона продовжує генерувати відповідь. Завжди. Автоматично. І робить це одним з трьох способів — кожен з яких має різний рівень небезпеки і різний спосіб діагностики.

Варіант A: Ігнорує і відповідає з пам'яті

Найпоширеніший і найнебезпечніший варіант. Модель бачить порожній або нерелевантний результат — і «вирішує» що краще відповісти з власних тренувальних знань ніж визнати незнання. Відповідь звучить впевнено і зв'язно. Жодного попередження.

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

// Ситуація: клієнт запитує про ціну, документ не знайдено
// Tool повернув: results: []

// Внутрішній процес моделі (спрощено):
// "Результат пошуку порожній...
//  Але я навчався на тисячах SaaS прайсингових сторінок.
//  Знаю що Enterprise плани зазвичай коштують $300-800/місяць.
//  Відповім на основі загальних знань. Звучатиме переконливо."

// Що отримує користувач:
// "Enterprise план включає необмежену кількість користувачів
//  і коштує $500 на місяць при річній оплаті."
// stop_reason: "end_turn" — жодного tool_use в логах після порожнього результату

// Реальна ціна: $850/місяць після підвищення 2 місяці тому

Як виявити в логах: це єдиний варіант де діагностика потребує активних зусиль. Відповідь виглядає нормально — проблема видна тільки якщо логувати весь цикл tool calling.

// Що шукати в логах щоб виявити Варіант A:
// 1. Tool виклик є
// 2. Tool result є але порожній або з низьким score
// 3. stop_reason == "end_turn" — модель відповіла без повторного tool call
// 4. Відповідь містить конкретні цифри або факти яких немає в tool result

// Додайте в AgentConversationRunner:
log.info("Tool result length: {} chars, stop_reason: {}", 
    toolResult.length(), 
    response.getStopReason());

if (toolResult.isBlank() && response.getStopReason().equals("end_turn")) {
    log.warn("GROUNDING RISK: empty tool result but model answered directly. " +
             "Query: '{}'", userQuery);
}

Приклад з Agent Chat: в одному з тестових діалогів агент наводив статистику «за даними дослідження Stanford» — але Wikipedia tool повернув порожній результат для цього запиту. Модель просто «згадала» схожу статистику з тренувальних даних і подала її як реальний факт. В логах це виглядало як нормальний успішний запит.

Варіант B: Будує відповідь на нерелевантному результаті

Модель бачить що щось знайдено — і приймає це як достатню основу. Семантична схожість між запитом і результатом «обманює» модель. Вона не перевіряє чи документ дійсно відповідає на питання — вона просто бачить «є текст → можна відповідати».

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

// Запит: "які штрафні санкції в договорі з клієнтом Альфа?"
// Tool знайшов найближчий документ зі score 0.63:
{
  "title": "Типовий договір про надання послуг v2.1",
  "content": "У разі порушення умов договору — штраф 0.1% на день...",
  "score": 0.63
}

// Внутрішній процес моделі:
// "Є результат про договори і штрафи. Відповім на його основі."

// Що отримує користувач:
// "Згідно з договором, штрафні санкції складають 0.1% на день..."

// Реальні умови договору з Альфа: 
// штраф 0.5% на день + право розірвання після 5 днів прострочення
// (вони підписали нестандартні умови 6 місяців тому)

Як виявити в логах: цей варіант видно через score метрику — але тільки якщо ви її логуєте.

// Логування score для кожного tool result
@Tool(description = "Шукає в базі знань")
public String search(String query) {
    List<SearchResult> results = vectorStore.search(query);
    
    if (!results.isEmpty()) {
        double score = results.get(0).getScore();
        log.info("Search score for '{}': {:.3f} — {}", 
            query, score, results.get(0).getTitle());
            
        // Алерт якщо score підозріло низький але результат є
        if (score < 0.65) {
            log.warn("LOW RELEVANCE SCORE {:.3f} for query: '{}'. " +
                     "Document: '{}'", score, query, results.get(0).getTitle());
        }
    }
    
    // ... обробка результату
}

Варіант C: Визнає проблему (рідко і недетерміновано)

Frontier моделі — Claude Sonnet, GPT-4o, Gemini Pro — іноді самостійно розпізнають що результат не відповідає на запит і чесно про це кажуть. Це найкращий варіант поведінки. Але є критична проблема: це відбувається непередбачувано.

Та сама модель на той самий запит може:

  • Раз — визнати що не знайшла відповідь
  • Інший раз — впевнено відповісти з вигаданими даними

Залежить від температури, від формулювання запиту, від того що ще є в контексті. В production це неприйнятно.

// Той самий порожній результат — два різних запуски:

// Запуск 1 (модель "вирішила" визнати):
// "На жаль, я не знайшов конкретної інформації про ціни 
//  в доступних документах. Рекомендую звернутись до менеджера."

// Запуск 2 (та сама модель, той самий запит):  
// "Enterprise план зазвичай включає необмежений доступ
//  і коштує від $400 до $800 на місяць залежно від кількості користувачів."

// Різниця між запусками: лише temperature=0.7 замість temperature=0.3
// Grounding зробив би обидва запуски однаковими і передбачуваними

Що робити замість того щоб покладатись на Варіант C:

// НЕ робіть так — сподіватись що модель сама розбереться:
String systemPrompt = "Якщо не знаєш відповіді — скажи що не знаєш";
// Це працює іноді. Не завжди. Не в production.

// РОБІТЬ так — явна grounding інструкція в tool result:
return String.format("""
    СТАТУС ПОШУКУ: результат не знайдено для запиту "%s"
    
    ОБОВ'ЯЗКОВА ІНСТРУКЦІЯ: 
    - НЕ відповідай з власних тренувальних знань
    - НЕ вигадуй цифри або факти
    - Повідом користувача що інформація відсутня в базі
    - Запропонуй альтернативу: уточнити запит або звернутись до підтримки
    """, query);
// Це працює завжди. Детерміновано. В production.
Ключовий висновок: модель не знає що її tool result «поганий» якщо ви їй це не сказали явно. Вона бачить лише рядок тексту. Ваш код знає контекст — score релевантності, статус помилки, чи результат порожній. Передавайте цю інформацію явно через структурований контент tool result — не через загальні інструкції в системному промпті. Різниця між «скажи що не знаєш» в системному промпті і «СТАТУС: не знайдено» прямо в tool result — це різниця між «іноді працює» і «завжди працює».

Таблиця діагностики: як визначити який варіант відбувся

Варіант Що видно в логах Що видно у відповіді Як виявити
A: Відповідає з пам'яті tool result порожній, stop_reason = end_turn Впевнена відповідь з конкретними даними 🔴 Складно — потрібне логування циклу
B: Нерелевантний результат tool result є, score < 0.65 Відповідь схожа але не точна 🟠 Середньо — потрібне логування score
C: Визнає проблему tool result порожній, stop_reason = end_turn «Не знайшов», «рекомендую уточнити» 🟢 Легко — видно з відповіді

is_error: true vs порожній контент — різниця яка має значення

Spring AI і більшість LLM API підтримують поле is_error в tool result. Більшість розробників ігнорують його — передають або порожній рядок або текст помилки без прапорця. І це одна з найпоширеніших причин чому агент поводиться непередбачувано при помилках.

Як модель технічно читає tool result

Щоб зрозуміти різницю — потрібно знати як tool result потрапляє в контекст моделі. Це не просто рядок тексту. Це структурований блок з метаданими:

// Що LLM бачить всередині контексту (спрощено):
{
  "role": "tool",
  "tool_use_id": "toolu_01ABC",
  "content": "...",      // текст результату
  "is_error": false      // або true
}

// Модель навчена розрізняти ці два стани:
// is_error: false → "це нормальний результат, використовуй для відповіді"
// is_error: true  → "щось пішло не так, не будуй відповідь на цьому"

Саме тому is_error: true — це не просто семантика. Це інструкція яку модель отримала під час fine-tuning: результат з цим прапорцем означає що пошук не вдався і відповідати з власних знань не варто.

Порожній контент — модель вирішує сама

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

// ❌ Так робити не варто — модель вирішує сама
ToolResponseMessage emptyResult = new ToolResponseMessage(
    toolCallId,
    ""  // порожній рядок без is_error
);

// Модель може інтерпретувати це як:
// - "нічого не знайдено — скажу що не знаю" (правильно, але рідко)
// - "результат ще завантажується — почекаю" (невірно)
// - "можна відповідати з власних знань" (небезпечно — найчастіший варіант)
// - "tool зламався — поскаржусь технічно" (поганий UX)

// Та сама ситуація може давати різні результати при різних запусках

is_error: true — явна детермінована інструкція

// ✅ Так правильно — явний сигнал для моделі
ToolResponseMessage errorResult = new ToolResponseMessage(
    toolCallId,
    "Документ не знайдено в базі знань для запиту: умови договору Альфа",
    true  // is_error — модель знає що робити
);

// Модель отримує чіткий сигнал:
// - це явна помилка, не відсутній результат
// - не будуй відповідь на цій основі
// - повідом користувача про проблему
// - не намагайся «заповнити» відповідь з власних знань

Повний GroundedToolResultBuilder — чотири стани

На практиці у tool result є чотири різних стани — кожен вимагає окремої обробки:

@Service
@Slf4j
public class GroundedToolResultBuilder {

    /**
     * Успішний результат з високою релевантністю (score >= 0.75)
     * is_error: false — модель може будувати відповідь
     */
    public ToolResponseMessage success(String toolCallId,
                                       String content,
                                       String documentTitle,
                                       String sourceRef) {
        String groundedContent = String.format("""
            СТАТУС: РЕЗУЛЬТАТ ЗНАЙДЕНО
            
            ЗМІСТ:
            %s
            
            ДЖЕРЕЛО: %s
            ДОКУМЕНТ: %s
            ІНСТРУКЦІЯ: використовуй цей результат для відповіді.
            Обов'язково вкажи джерело у форматі "Згідно з [документ]..."
            """, content, sourceRef, documentTitle);

        log.info("Tool result: SUCCESS, doc='{}'", documentTitle);
        return new ToolResponseMessage(toolCallId, groundedContent);
        // is_error за замовчуванням false
    }

    /**
     * Результат знайдено але релевантність середня (score 0.55-0.75)
     * is_error: false — але з попередженням для моделі
     */
    public ToolResponseMessage lowRelevance(String toolCallId,
                                             String content,
                                             String documentTitle,
                                             double score) {
        String groundedContent = String.format("""
            СТАТУС: РЕЗУЛЬТАТ З НИЗЬКОЮ РЕЛЕВАНТНІСТЮ (score: %.2f)
            
            ЗМІСТ:
            %s
            
            ДОКУМЕНТ: %s
            УВАГА: результат може не точно відповідати на запит користувача.
            Використовуй з обережністю. Повідом користувача що відповідь 
            може бути неточною і рекомендуй уточнити запит.
            """, score, content, documentTitle);

        log.warn("Tool result: LOW_RELEVANCE, score={:.2f}, doc='{}'",
            score, documentTitle);
        return new ToolResponseMessage(toolCallId, groundedContent);
    }

    /**
     * Нічого не знайдено в базі знань
     * is_error: true — критично щоб модель не відповідала з пам'яті
     */
    public ToolResponseMessage notFound(String toolCallId, String query) {
        String message = String.format("""
            СТАТУС: РЕЗУЛЬТАТ НЕ ЗНАЙДЕНО
            
            Запит: "%s"
            
            ОБОВ'ЯЗКОВА ІНСТРУКЦІЯ:
            - НЕ відповідай з власних тренувальних знань
            - НЕ вигадуй факти або цифри
            - Повідом користувача що інформація відсутня в базі знань
            - Запропонуй уточнити запит або звернутись до підтримки
            """, query);

        log.warn("Tool result: NOT_FOUND, query='{}'", query);
        return new ToolResponseMessage(toolCallId, message, true); // is_error: true
    }

    /**
     * Технічна помилка — API недоступний, timeout тощо
     * is_error: true — модель має повідомити про технічну проблему
     */
    public ToolResponseMessage technicalError(String toolCallId,
                                               String errorMessage) {
        String message = String.format("""
            СТАТУС: ТЕХНІЧНА ПОМИЛКА ПОШУКУ
            
            Деталі (тільки для логів, не показуй користувачу): %s
            
            ІНСТРУКЦІЯ:
            - Повідом користувача що пошук тимчасово недоступний
            - НЕ передавай технічні деталі помилки користувачу
            - НЕ відповідай з власних знань замість пошуку
            - Запропонуй спробувати пізніше
            """, errorMessage);

        log.error("Tool result: TECHNICAL_ERROR — {}", errorMessage);
        return new ToolResponseMessage(toolCallId, message, true); // is_error: true
    }
}

Порівняльна таблиця: який стан коли використовувати

Стан is_error Коли використовувати Що робить модель
success() false score >= 0.75, документ знайдено Будує відповідь, вказує джерело
lowRelevance() false score 0.55–0.75, щось знайшлось Відповідає з застереженням
notFound() true results порожній або score < 0.55 Повідомляє що не знайшла
technicalError() true Exception, timeout, API помилка Повідомляє про технічну проблему

Як перевірити що is_error реально працює

Додайте простий тест щоб переконатись що модель справді поводиться по-різному залежно від is_error:

@SpringBootTest
class IsErrorBehaviorTest {

    @Autowired
    private ChatModel chatModel;

    @Test
    void modelShouldNotHallucinateWhenIsErrorTrue() {
        // Симулюємо порожній результат з is_error: true
        List<Message> messages = List.of(
            new SystemMessage("Відповідай тільки на основі результатів пошуку."),
            new UserMessage("Яка ціна на Enterprise план?"),
            new AssistantMessage(""),  // placeholder
            new ToolResponseMessage(
                "test-id",
                "СТАТУС: РЕЗУЛЬТАТ НЕ ЗНАЙДЕНО\n" +
                "ІНСТРУКЦІЯ: не відповідай з власних знань.",
                true  // is_error: true
            )
        );

        String response = chatModel.call(new Prompt(messages))
            .getResult().getOutput().getText();

        // Модель НЕ має вигадувати ціну
        assertThat(response)
            .doesNotContain("$")
            .doesNotContain("500")
            .doesNotContain("місяць")
            .containsAnyOf("не знайшов", "відсутня", "не знайдено");
    }

    @Test
    void modelShouldAnswerWhenIsErrorFalse() {
        // Симулюємо успішний результат
        List<Message> messages = List.of(
            new SystemMessage("Відповідай тільки на основі результатів пошуку."),
            new UserMessage("Яка ціна на Enterprise план?"),
            new AssistantMessage(""),
            new ToolResponseMessage(
                "test-id",
                "СТАТУС: РЕЗУЛЬТАТ ЗНАЙДЕНО\n" +
                "ЗМІСТ: Enterprise план — $850/місяць при річній оплаті.\n" +
                "ДЖЕРЕЛО: Прайслист v2.3",
                false  // is_error: false
            )
        );

        String response = chatModel.call(new Prompt(messages))
            .getResult().getOutput().getText();

        // Модель МАЄ використати цифру з результату
        assertThat(response)
            .contains("850")
            .containsAnyOf("Згідно з", "прайслист", "документ");
    }
}
Практична порада для Spring AI: в Spring AI 2.0.x конструктор ToolResponseMessage(id, content, isError) може відрізнятись залежно від версії — перевірте сигнатуру в вашому M3/M5. Якщо конструктор з трьома параметрами недоступний — використовуйте builder або передавайте is_error через контент з явною ІНСТРУКЦІЄЮ для моделі. Явний текст в контенті ("НЕ відповідай з власних знань") працює майже так само надійно як прапорець — і є запасним варіантом для будь-якої версії Spring AI.

Confidence scoring — як попросити модель оцінити якість

Confidence scoring — це техніка де ми просимо модель явно оцінити наскільки знайдений результат відповідає на запит, перш ніж будувати фінальну відповідь. Це додатковий крок — але він закриває сліпу зону яку не закриває векторний score.

Навіщо це потрібно — і чому векторного score недостатньо

Ваш код знає технічні метрики — score векторного пошуку, кількість знайдених документів. Але він не знає семантичної відповідності.

Ось конкретний приклад де векторний score брехав:

// Запит: "штрафні санкції за прострочення оплати в договорі з Бета Corp"
// Векторний пошук повернув:
{
  "title": "Договір з Бета Corp — основні умови",
  "score": 0.82,  // висока релевантність!
  "content": "Сторони домовились про наступні умови співпраці: 
               терміни виконання, порядок оплати, відповідальність сторін..."
}

// Документ правильний — але розділ про санкції в іншому місці
// Векторний score 0.82 — бо документ дійсно про цей договір
// Але ВІДПОВІДІ на питання про санкції тут немає

// Confidence scoring виявить це:
// { "confidence": "LOW", "reason": "документ знайдено але санкції не описані", 
//   "can_answer": false }

Модель краще розуміє семантику ніж числовий score — попросіть її перевірити перед тим як відповідати.

Коли використовувати confidence scoring

Тип запиту Використовувати? Причина
Ціни, тарифи ✅ Так Помилка = фінансові наслідки
Умови договорів ✅ Так Помилка = юридичні наслідки
Конкретні дати, дедлайни ✅ Так Точність критична
Загальні FAQ питання ⚠️ Опціонально Достатньо векторного score
Простий пошук по темі ❌ Ні Додає latency без суттєвої користі

Реалізація на Spring AI

@Service
@RequiredArgsConstructor
@Slf4j
public class ConfidenceScoringService {

    private final ChatModel chatModel;

    private static final String CONFIDENCE_PROMPT = """
        Оціни наскільки знайдений результат відповідає на запит користувача.
        
        ЗАПИТ КОРИСТУВАЧА: %s
        
        ЗНАЙДЕНИЙ РЕЗУЛЬТАТ:
        %s
        
        Відповідай ТІЛЬКИ у форматі JSON без пояснень, без markdown, без ```json:
        {
          "confidence": "HIGH" | "MEDIUM" | "LOW" | "NOT_RELEVANT",
          "reason": "одне речення чому",
          "can_answer": true | false
        }
        
        Критерії оцінки:
        - HIGH: результат прямо і повно відповідає на запит — відповідай впевнено
        - MEDIUM: результат частково відповідає — відповідай з застереженням
        - LOW: результат пов'язаний з темою але не відповідає конкретно — краще визнати
        - NOT_RELEVANT: результат не стосується запиту — не відповідай
        """;

    public ConfidenceResult evaluate(String userQuery, String toolResult) {
        
        // Не оцінюємо якщо результат явно порожній
        if (toolResult == null || toolResult.isBlank()) {
            return ConfidenceResult.notFound();
        }

        String prompt = String.format(CONFIDENCE_PROMPT, userQuery, toolResult);

        try {
            String response = chatModel.call(prompt).trim();
            
            // Прибираємо можливі markdown backticks які деякі моделі додають
            // незважаючи на інструкцію (deepseek, llama схильні до цього)
            String cleanJson = response
                .replaceAll("```json", "")
                .replaceAll("```", "")
                .trim();

            ObjectMapper mapper = new ObjectMapper();
            JsonNode json = mapper.readTree(cleanJson);

            ConfidenceLevel level = ConfidenceLevel.valueOf(
                json.get("confidence").asText());
            String reason = json.get("reason").asText();
            boolean canAnswer = json.get("can_answer").asBoolean();

            log.info("Confidence evaluated: level={}, canAnswer={}, reason='{}'",
                level, canAnswer, reason);

            return ConfidenceResult.builder()
                .level(level)
                .reason(reason)
                .canAnswer(canAnswer)
                .build();

        } catch (JsonProcessingException e) {
            // Модель повернула не-JSON — логуємо і використовуємо безпечний дефолт
            log.warn("Failed to parse confidence JSON response. " +
                     "Raw response: '{}'. Defaulting to LOW.", 
                     chatModel.call(prompt));
            return ConfidenceResult.safe(); // LOW + canAnswer: false
            
        } catch (IllegalArgumentException e) {
            // Невідомий ConfidenceLevel у відповіді
            log.warn("Unknown confidence level in response. Defaulting to LOW.");
            return ConfidenceResult.safe();
        }
    }
}

@Value
@Builder
public class ConfidenceResult {
    ConfidenceLevel level;
    String reason;
    boolean canAnswer;

    // Фабричні методи для типових станів
    public static ConfidenceResult notFound() {
        return ConfidenceResult.builder()
            .level(ConfidenceLevel.NOT_RELEVANT)
            .reason("Результат порожній")
            .canAnswer(false)
            .build();
    }

    public static ConfidenceResult safe() {
        return ConfidenceResult.builder()
            .level(ConfidenceLevel.LOW)
            .reason("Не вдалось оцінити релевантність")
            .canAnswer(false)
            .build();
    }
}

public enum ConfidenceLevel {
    HIGH, MEDIUM, LOW, NOT_RELEVANT
}

Як виглядає реальний результат confidence scoring

Ось три реальні приклади з AskYourDocs — що повертає confidence scoring на різних запитах:

// Приклад 1: HIGH confidence — відповідь є прямо в документі
Запит: "яка ціна на Basic план?"
Результат пошуку: "Basic план — $49/місяць, до 5 користувачів..."
Confidence: {
  "confidence": "HIGH",
  "reason": "документ містить пряму відповідь на питання про ціну",
  "can_answer": true
}
→ Агент відповідає впевнено з цитатою

// Приклад 2: LOW confidence — документ є але відповіді немає
Запит: "штрафи за прострочення оплати"
Результат пошуку: "Договір про надання послуг. Розділ 3: Порядок оплати..."
Confidence: {
  "confidence": "LOW", 
  "reason": "документ про оплату але штрафні санкції не описані в цьому розділі",
  "can_answer": false
}
→ Агент повідомляє що точної інформації не знайшов

// Приклад 3: NOT_RELEVANT — пошук знайшов не той документ
Запит: "умови договору з клієнтом Гамма"
Результат пошуку: "Загальні умови надання послуг v1.0..."
Confidence: {
  "confidence": "NOT_RELEVANT",
  "reason": "знайдено загальний шаблон, а не договір конкретного клієнта",
  "can_answer": false
}
→ Агент повідомляє що договір Гамма не знайдено в базі

Як використовувати в агентному pipeline

@Service
@RequiredArgsConstructor
@Slf4j
public class GroundedAgentService {

    private final ConfidenceScoringService confidenceScoring;
    private final GroundedToolResultBuilder resultBuilder;
    private final ChatModel chatModel;

    public String answerWithGrounding(String userQuery,
                                       String toolResult,
                                       String toolCallId,
                                       String documentTitle) {

        // 1. Оцінюємо релевантність через LLM
        ConfidenceResult confidence = confidenceScoring
            .evaluate(userQuery, toolResult);

        log.info("Grounding decision for '{}': {} (canAnswer={})",
            userQuery, confidence.getLevel(), confidence.isCanAnswer());

        // 2. Вибираємо стратегію на основі confidence
        ToolResponseMessage groundedResult = switch (confidence.getLevel()) {
            case HIGH ->
                resultBuilder.success(toolCallId, toolResult, documentTitle, "knowledge_base");
            case MEDIUM ->
                resultBuilder.lowRelevance(toolCallId, toolResult, documentTitle, 0.65);
            case LOW, NOT_RELEVANT ->
                resultBuilder.notFound(toolCallId, userQuery);
        };

        // 3. Передаємо в модель з grounding інструкціями
        List<Message> messages = List.of(
            new SystemMessage("""
                Відповідай ТІЛЬКИ на основі наданих результатів пошуку.
                Якщо результат позначений як 'не знайдено' — повідом про це.
                Завжди вказуй джерело інформації.
                """),
            new UserMessage(userQuery),
            new AssistantMessage(""),
            groundedResult
        );

        return chatModel.call(new Prompt(messages))
            .getResult()
            .getOutput()
            .getText();
    }
}
Вартість і latency confidence scoring: це додатковий LLM запит — ~150-300 токенів на вхід і ~50 токенів на вихід. З deepseek-chat через OpenRouter це приблизно $0.0001-0.0003 на один запит. Latency — 200-500ms залежно від моделі і провайдера. Для критичних запитів (ціни, договори) — ця ціна виправдана. Для простих інформаційних запитів де помилка не має серйозних наслідків — достатньо векторного score >= 0.75 і можна пропустити confidence scoring повністю.

Re-query патерн — коли спробувати ще раз

Якщо перший пошук не дав хорошого результату — чи варто спробувати ще раз з іншим запитом? Відповідь: так, але тільки в конкретних ситуаціях і з жорстким лімітом спроб.

Re-query — це не «пошукай ще раз те саме». Це переформулювання запиту на основі того що перший пошук не знайшов. Різниця принципова.

Flow re-query циклу

Запит користувача
      ↓
  Пошук (спроба 1)
      ↓
  Є результат?
  ├── НІ → Переформулюй запит → Пошук (спроба 2)
  │                                    ↓
  │                               Є результат?
  │                               ├── НІ → notFound()
  │                               └── ТАК → Confidence scoring
  │                                              ↓
  │                                    HIGH/MEDIUM → found()
  │                                    LOW → notFound()  
  │                                    NOT_RELEVANT → notFound()
  └── ТАК → Confidence scoring
                  ↓
        HIGH/MEDIUM → found()
        LOW → Переформулюй → Пошук (спроба 2)
        NOT_RELEVANT → notFound() (без re-query — документа немає)

Коли re-query виправданий

  • Перший запит занадто специфічний — «штрафні санкції договір Альфа Corp пункт 5.2» → краще «умови відповідальності Альфа Corp»
  • Запит містить технічний жаргон — «SLA uptime guarantee» → «гарантія доступності сервісу»
  • Confidence LOW але не NOT_RELEVANT — документ пов'язаний з темою але не той розділ. Є шанс знайти потрібний розділ з іншим запитом

Коли re-query не допоможе

  • Confidence NOT_RELEVANT — пошук знайшов документ на зовсім іншу тему. Документа якого шукають просто немає в базі. Переформулювання не допоможе
  • Tool повернув технічну помилку — проблема в інфраструктурі а не в запиті. Re-query тільки збільшить кількість помилкових запитів
  • Вже спробували MAX_ATTEMPTS варіантів — зупиняємось і визнаємо що не знайшли

Реальні приклади переформулювання

Ось як LLM переформульовує запити на практиці — з реальних логів AskYourDocs:

// Приклад 1: занадто специфічний запит
Оригінал:  "пункт 8.3.2 договору послуги хмарного зберігання Бета Corp"
Спроба 2:  "умови зберігання даних Бета Corp"
Результат: знайдено потрібний розділ зі score 0.81 ✅

// Приклад 2: технічний жаргон
Оригінал:  "RTO і RPO SLA enterprise tier"  
Спроба 2:  "гарантії відновлення після збоїв enterprise план"
Результат: знайдено документ про SLA зі score 0.79 ✅

// Приклад 3: документа немає — re-query не допомагає
Оригінал:  "договір з клієнтом Гамма LLC"
Confidence: NOT_RELEVANT (знайшовся договір з іншим клієнтом)
Рішення:   одразу notFound() без re-query — 
           немає сенсу переформульовувати якщо документа не існує ✅

// Приклад 4: re-query теж не знайшов
Оригінал:  "знижки для освітніх установ"
Спроба 2:  "пільгові умови для університетів і шкіл"
Результат: обидва порожні → notFound()
           (прайсингова політика для edu не була завантажена в базу) ✅

Реалізація з лімітом спроб

@Service
@RequiredArgsConstructor
@Slf4j
public class ReQueryService {

    private static final int MAX_ATTEMPTS = 2;

    private final KnowledgeBaseSearchTool searchTool;
    private final ConfidenceScoringService confidenceScoring;
    private final ChatModel chatModel;

    public SearchResult searchWithRequery(String originalQuery) {

        String currentQuery = originalQuery;

        for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
            log.info("Search attempt {}/{}: '{}'",
                attempt, MAX_ATTEMPTS, currentQuery);

            String result = searchTool.search(currentQuery);

            // Порожній результат — одразу re-query якщо є спроби
            if (result == null || result.isBlank()) {
                if (attempt < MAX_ATTEMPTS) {
                    currentQuery = reformulateQuery(originalQuery, attempt);
                    continue;
                }
                log.warn("All {} attempts exhausted, query: '{}'", 
                    MAX_ATTEMPTS, originalQuery);
                return SearchResult.notFound(originalQuery);
            }

            // Оцінюємо семантичну релевантність
            ConfidenceResult confidence = confidenceScoring
                .evaluate(originalQuery, result);

            log.info("Attempt {} confidence: {} — {}", 
                attempt, confidence.getLevel(), confidence.getReason());

            switch (confidence.getLevel()) {
                case HIGH, MEDIUM -> {
                    // Знайшли — повертаємо результат
                    return SearchResult.found(result, confidence, currentQuery);
                }
                case NOT_RELEVANT -> {
                    // Документа немає — re-query не допоможе
                    log.info("NOT_RELEVANT result, skipping re-query for: '{}'", 
                        originalQuery);
                    return SearchResult.notFound(originalQuery);
                }
                case LOW -> {
                    // Є шанс знайти краще — спробуємо ще раз
                    if (attempt < MAX_ATTEMPTS) {
                        currentQuery = reformulateQuery(originalQuery, attempt);
                    }
                }
            }
        }

        log.warn("Re-query exhausted for: '{}'", originalQuery);
        return SearchResult.notFound(originalQuery);
    }

    private String reformulateQuery(String originalQuery, int attempt) {
        String prompt = String.format("""
            Пошуковий запит не знайшов релевантного результату: "%s"
            
            Переформулюй запит — простіше, ширше, без жаргону.
            Це спроба %d переформулювання.
            
            Відповідай ТІЛЬКИ новим запитом без пояснень і лапок.
            """, originalQuery, attempt);

        String reformulated = chatModel.call(prompt).trim()
            .replaceAll("\"", ""); // прибираємо лапки якщо модель їх додала
            
        log.info("Reformulated: '{}' → '{}'", originalQuery, reformulated);
        return reformulated;
    }
}

SearchResult — клас результату

@Value
@Builder
public class SearchResult {
    boolean found;
    String content;
    ConfidenceResult confidence;
    String usedQuery;      // який саме запит знайшов результат
    int attemptsUsed;      // скільки спроб знадобилось

    public static SearchResult found(String content, 
                                      ConfidenceResult confidence,
                                      String usedQuery,
                                      int attempts) {
        return SearchResult.builder()
            .found(true)
            .content(content)
            .confidence(confidence)
            .usedQuery(usedQuery)
            .attemptsUsed(attempts)
            .build();
    }

    public static SearchResult notFound(String originalQuery) {
        return SearchResult.builder()
            .found(false)
            .content("")
            .usedQuery(originalQuery)
            .attemptsUsed(0)
            .build();
    }
}
Ліміт спроб і вартість — з мого досвіду: я зупинився на MAX_ATTEMPTS = 2 після тестування. Спробував 3 — третя спроба давала кращий результат менш ніж у 5% випадків. Здебільшого якщо за дві спроби нічого не знайшлось — документа просто немає в базі, і ніяке переформулювання не допоможе. Кожна спроба це два додаткових LLM запити — reformulate + confidence scoring. З deepseek-chat через OpenRouter це ~$0.0002-0.0005 на повний re-query цикл — але при 1000 запитів на день це вже відчутна сума. Два — достатньо. Більше — витрати без результату.

Citation і traceability — агент має знати звідки відповідь

Citation — це не просто «додати посилання». Це архітектурний принцип: агент повинен знати звідки кожна частина відповіді — і бути здатним це показати. Без citation у вас немає способу перевірити чи агент відповів на основі документів чи вигадав.

Чому citation важлива — дві причини

Причина 1: Бізнес і довіра. Уявіть юридичну фірму що використовує AskYourDocs. Клієнт запитує про умови договору. Агент відповідає. Адвокат потім питає: «На якій сторінці якого документа це написано?»

Без citation — відповісти неможливо. І клієнт перестає довіряти системі. З citation — агент відразу показує: «Стаття 5.2 Договору №123 від 15.03.2025».

Причина 2: Дебаг і моніторинг. Коли агент дає неправильну відповідь — як ви зрозумієте чому? Без citation ви бачите тільки «агент відповів неправильно». З citation ви бачите конкретно: «агент відповів на основі документу v1.2 який був замінений v2.0 три місяці тому». Це різниця між «щось зламалось» і «ось де саме».

// Без citation — ви бачите тільки результат:
User:  "Який штраф за прострочення оплати?"
Agent: "Штраф складає 0.1% на день від суми заборгованості."
// Правильно? Неправильно? Звідки ця цифра? Невідомо.

// З citation — ви бачите повний ланцюжок:
User:  "Який штраф за прострочення оплати?"
Agent: "Згідно з Договором про надання послуг v1.2 (розділ 7.3, 
        індексовано 12.01.2025), штраф складає 0.1% на день."
// Одразу видно: v1.2 — але зараз актуальна v2.0 де штраф 0.5%
// Проблема знайдена за секунди

Структура CitedSearchResult

@Value
@Builder
public class CitedSearchResult {
    String content;              // текст знайденого фрагменту
    String documentTitle;        // назва документу
    String documentId;           // ID в базі даних
    String documentVersion;      // версія документу якщо є
    String pageOrSection;        // сторінка або розділ
    String sourceUrl;            // посилання якщо є
    double relevanceScore;       // score векторного пошуку
    LocalDateTime indexedAt;     // коли документ був індексований в базу
    LocalDateTime documentDate;  // дата самого документу (може відрізнятись)
}

Як зберігати citation метадані в PostgreSQL

Citation працює тільки якщо метадані зберігаються разом з векторними embeddings. Ось як це виглядає в схемі:

-- Таблиця документів з метаданими для citation
CREATE TABLE knowledge_documents (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title       VARCHAR(500) NOT NULL,
    version     VARCHAR(50),
    section     VARCHAR(200),
    source_url  VARCHAR(1000),
    document_date   TIMESTAMP,
    indexed_at      TIMESTAMP DEFAULT NOW(),
    is_active   BOOLEAN DEFAULT TRUE  -- важливо для застарілих документів
);

-- Таблиця чанків з посиланням на документ
CREATE TABLE document_chunks (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    document_id UUID REFERENCES knowledge_documents(id),
    content     TEXT NOT NULL,
    embedding   vector(1536),          -- pgvector
    chunk_index INTEGER,               -- номер чанку в документі
    page_number INTEGER                -- сторінка якщо є
);

-- Індекс для векторного пошуку
CREATE INDEX ON document_chunks 
USING ivfflat (embedding vector_cosine_ops);
// Repository для пошуку з метаданими
@Repository
public interface DocumentChunkRepository extends JpaRepository<DocumentChunk, UUID> {

    @Query(value = """
        SELECT dc.*, kd.title, kd.version, kd.section, 
               kd.source_url, kd.document_date, kd.indexed_at,
               1 - (dc.embedding <=> :embedding) as relevance_score
        FROM document_chunks dc
        JOIN knowledge_documents kd ON dc.document_id = kd.id
        WHERE kd.is_active = TRUE
        ORDER BY dc.embedding <=> :embedding
        LIMIT :limit
        """, nativeQuery = true)
    List<ChunkWithMetadata> findWithCitation(
        @Param("embedding") float[] embedding,
        @Param("limit") int limit
    );
}

Tool з повним citation

@Tool(description = """
    Шукає інформацію в корпоративній базі знань.
    Повертає результат з точним посиланням на джерело — 
    назву документу, розділ і дату актуальності.
    """)
public String searchWithCitation(String query) {
    
    // Генеруємо embedding для запиту
    float[] queryEmbedding = embeddingModel.embed(query);
    
    List<ChunkWithMetadata> results = chunkRepository
        .findWithCitation(queryEmbedding, 5);

    if (results.isEmpty()) {
        return """
            РЕЗУЛЬТАТ: нічого не знайдено
            ДЖЕРЕЛО: відсутнє
            ІНСТРУКЦІЯ: повідом що інформація не знайдена в базі знань
            """;
    }

    ChunkWithMetadata best = results.get(0);

    // Перевіряємо актуальність документу
    boolean isRecent = best.getIndexedAt()
        .isAfter(LocalDateTime.now().minusMonths(6));
    
    String freshnessWarning = isRecent ? "" : 
        "\n⚠️ УВАГА: документ індексовано більше 6 місяців тому — " +
        "рекомендуй користувачу підтвердити актуальність.";

    return String.format("""
        РЕЗУЛЬТАТ:
        %s
        
        ДЖЕРЕЛО (обов'язково вкажи у відповіді):
        - Документ: %s%s
        - Розділ: %s
        - Сторінка: %s
        - Посилання: %s
        - Актуальність документу: %s
        - Індексовано: %s
        - Релевантність: %.0f%%
        %s
        
        ІНСТРУКЦІЯ: при відповіді обов'язково вкажи джерело у форматі
        "Згідно з [назва документу], [розділ/сторінка]..."
        """,
        best.getContent(),
        best.getTitle(),
        best.getVersion() != null ? " (" + best.getVersion() + ")" : "",
        best.getSection() != null ? best.getSection() : "не вказано",
        best.getPageNumber() != null ? best.getPageNumber().toString() : "не вказано",
        best.getSourceUrl() != null ? best.getSourceUrl() : "внутрішній документ",
        best.getDocumentDate() != null 
            ? best.getDocumentDate().toLocalDate().toString() 
            : "не вказано",
        best.getIndexedAt().toLocalDate(),
        best.getRelevanceScore() * 100,
        freshnessWarning
    );
}

Як виглядає відповідь з citation — три рівні деталізації

// Рівень 1 — без citation (так не треба робити):
"Договір можна розірвати за 30 днів попередження."

// Рівень 2 — базова citation:
"Згідно з Договором №123 (розділ 5.2), 
розірвання можливе за 30 календарних днів письмового попередження."

// Рівень 3 — повна citation з датою (для критичних запитів):
"Згідно з Договором про надання послуг №123 v2.1 (розділ 5.2, 
сторінка 8), розірвання можливе за 30 календарних днів письмового 
попередження. [Документ актуальний на 15.03.2025, 
індексовано 16.03.2025]"

Citation для Agent Chat — Wikipedia і Tavily

В Agent Chat агенти використовують зовнішні джерела — Wikipedia, Tavily, NewsAPI. Citation тут особливо важлива бо читач хоче перевірити факт самостійно.

// Погано — агент каже "за даними досліджень":
"За даними досліджень, GitHub Copilot підвищує продуктивність на 55%."
// Яких досліджень? Коли? Де перевірити?

// Добре — агент цитує конкретне джерело:
"Згідно з дослідженням GitHub (2023, опубліковано на github.blog), 
розробники з Copilot виконують завдання на 55% швидше."
// Читач може перейти і перевірити
// Як додати citation в WikipediaSearchTool
@Tool(description = "Шукає факти у Wikipedia")
public String searchWikipedia(String query) {
    WikiSearchResponse response = callWikipediaApi(query);
    
    if (response.getResults().isEmpty()) {
        return "Wikipedia: нічого не знайдено для запиту: " + query;
    }
    
    WikiResult result = response.getResults().get(0);
    
    // Повертаємо результат з явним citation
    return String.format("""
        WIKIPEDIA РЕЗУЛЬТАТ:
        %s
        
        ДЖЕРЕЛО: Wikipedia, стаття "%s"
        ПОСИЛАННЯ: https://uk.wikipedia.org/wiki/%s
        ІНСТРУКЦІЯ: при відповіді вкажи джерело як 
        "За даними Wikipedia (стаття '%s')..."
        """,
        result.getSnippet(),
        result.getTitle(),
        result.getTitle().replace(" ", "_"),
        result.getTitle()
    );
}
Підводний камінь — застарілі документи: citation показує коли документ був індексований — але не коли він був оновлений в реальності. Документ індексований місяць тому може містити інформацію дворічної давнини. Додайте в схему поле document_date окремо від indexed_at і показуйте обидва. Якщо document_date більше 6 місяців тому — попереджайте модель щоб вона повідомила користувача про можливу застарілість.

Java + Spring AI реалізація — повний pipeline

Зберемо всі концепції в єдиний grounding pipeline який можна використати в реальному проекті.

Структура компонентів

src/main/java/com/example/
├── grounding/
│   ├── GroundingPipeline.java          // головний компонент
│   ├── ConfidenceScoringService.java   // оцінка релевантності
│   ├── ReQueryService.java             // повторний пошук
│   ├── CitationBuilder.java            // формування citation
│   └── GroundedToolResultBuilder.java  // формування tool result
├── model/
│   ├── ConfidenceResult.java
│   ├── SearchResult.java
│   └── CitedSearchResult.java

GroundingPipeline — головний компонент

@Service
@RequiredArgsConstructor
@Slf4j
public class GroundingPipeline {

    private final ReQueryService reQueryService;
    private final GroundedToolResultBuilder resultBuilder;
    private final ChatModel chatModel;
    
    private static final double HIGH_CONFIDENCE_THRESHOLD = 0.75;
    private static final double LOW_CONFIDENCE_THRESHOLD = 0.50;

    /**
     * Основний метод — обробляє запит з повним grounding циклом
     */
    public String processWithGrounding(String userQuery, String toolCallId) {
        
        // 1. Шукаємо з можливим re-query
        SearchResult searchResult = reQueryService
            .searchWithRequery(userQuery);
        
        log.info("Search result for '{}': found={}, confidence={}", 
            userQuery, 
            searchResult.isFound(), 
            searchResult.getConfidence());
        
        // 2. Формуємо grounded tool result
        ToolResponseMessage toolResponse = buildGroundedResponse(
            toolCallId, userQuery, searchResult);
        
        // 3. Передаємо в модель з системними інструкціями
        List messages = buildMessagesWithGrounding(
            userQuery, toolResponse);
        
        // 4. Отримуємо фінальну відповідь
        return chatModel.call(new Prompt(messages))
            .getResult()
            .getOutput()
            .getText();
    }

    private ToolResponseMessage buildGroundedResponse(
            String toolCallId, 
            String query, 
            SearchResult result) {
        
        if (!result.isFound()) {
            return resultBuilder.notFound(toolCallId, query);
        }
        
        double score = result.getConfidence().getScore();
        
        if (score >= HIGH_CONFIDENCE_THRESHOLD) {
            return resultBuilder.success(
                toolCallId, 
                result.getContent(), 
                result.getSourceReference()
            );
        } else if (score >= LOW_CONFIDENCE_THRESHOLD) {
            return resultBuilder.lowRelevance(
                toolCallId, 
                result.getContent(), 
                score
            );
        } else {
            return resultBuilder.notFound(toolCallId, query);
        }
    }

    private List buildMessagesWithGrounding(
            String userQuery, 
            ToolResponseMessage toolResponse) {
        
        return List.of(
            new SystemMessage("""
                Ти корпоративний асистент що відповідає на основі документів компанії.
                
                ПРАВИЛА GROUNDING:
                1. Відповідай ТІЛЬКИ на основі результатів пошуку
                2. Якщо результат позначений як "не знайдено" — скажи що не знайшов
                3. Завжди вказуй джерело у форматі: "Згідно з [документ]..."
                4. Якщо впевненість LOW — попереджай: "Знайдена інформація може бути неточною"
                5. НІКОЛИ не вигадуй інформацію якої немає в результатах пошуку
                """),
            new UserMessage(userQuery),
            toolResponse
        );
    }
}

Інтеграція в AgentConversationRunner (Agent Chat)

// В AgentConversationRunner.ask() — додаємо grounding
private String ask(String systemPrompt, List history,
                   String lastMessage, AgentSender currentSender) {

    List messages = new ArrayList<>();
    messages.add(new SystemMessage(systemPrompt));
    
    // ... history mapping як раніше ...
    
    messages.add(new UserMessage(lastMessage));

    ToolCallback[] tools = ToolCallbacks.from(
        wikipediaSearchTool,
        tavilySearchTool,
        alphaVantageTool,
        arxivSearchTool,
        newsApiSearchTool
    );

    try {
        ChatResponse response = agentChatModel.call(
            new Prompt(messages,
                ToolCallingChatOptions.builder()
                    .toolCallbacks(tools)
                    .build()));
        
        String result = response.getResult().getOutput().getText();
        
        // Видаляємо  блоки якщо є (для qwen3)
        return removeThinkingBlock(result);
        
    } catch (IllegalStateException e) {
        // Grounding fallback: tool call не вдався — відповідаємо без tools
        log.warn("Tool call failed for agent {}: {}", currentSender, e.getMessage());
        return agentChatModel.call(new Prompt(messages))
            .getResult().getOutput().getText();
    }
}

private String removeThinkingBlock(String text) {
    if (text == null) return "";
    // Видаляємо ... блоки які qwen3 додає
    return text.replaceAll("(?s).*?", "").trim();
}

Тест grounding pipeline

@SpringBootTest
class GroundingPipelineTest {

    @Autowired
    private GroundingPipeline pipeline;

    @Test
    void shouldReturnNotFoundWhenDocumentMissing() {
        // Запит про документ якого немає
        String result = pipeline.processWithGrounding(
            "умови договору з клієнтом XYZ-99999",
            "test-tool-call-id"
        );
        
        // Відповідь має визнати що не знайшло — не вигадувати
        assertThat(result)
            .containsAnyOf("не знайшов", "не знайдено", "відсутня інформація")
            .doesNotContain("$")          // не вигадав ціну
            .doesNotContain("30 днів");   // не вигадав умови
    }

    @Test  
    void shouldIncludeCitationInResponse() {
        String result = pipeline.processWithGrounding(
            "яка ціна на базовий план",
            "test-tool-call-id"
        );
        
        // Відповідь має містити посилання на джерело
        assertThat(result)
            .containsAnyOf("Згідно з", "документ", "розділ");
    }
}

Висновки

Grounding — це різниця між агентом якому можна довіряти і агентом який виглядає розумним але може вигадати будь-що. Більшість туторіалів показують як викликати tool. Ніхто не показує що робити після того як tool відповів. Саме тут ховаються 90% проблем production агентів.

Особливо критично для систем де відповіді мають реальні наслідки — юридичні документи, ціни, умови договорів, медичні дані. Там одна невірна відповідь коштує дорожче ніж весь час витрачений на впровадження grounding.

П'ять правил grounding — і як перевірити що вони працюють

1. Порожній результат ≠ «можна відповідати з пам'яті»

Передавайте is_error: true і явну текстову інструкцію в контент tool result. Як перевірити: видаліть всі документи з бази — агент має відповідати «не знайшов» на будь-який запит, а не вигадувати.

2. Векторний score недостатньо

Score 0.85 означає «найближчий документ» — не «відповідь на питання». Confidence scoring дає семантичну перевірку яку score не дає. Як перевірити: зробіть запит про конкретний клієнт якого немає в базі — агент не має відповідати умовами іншого клієнта.

3. Re-query з жорстким лімітом

Максимум 2 спроби. NOT_RELEVANT — зупиняємось одразу без re-query. Як перевірити: в логах після кожного пошуку має бути attempt X/2 і причина зупинки.

4. Citation обов'язкова

Агент має знати звідки кожна відповідь і вміти це показати. Як перевірити: запитайте агента «звідки ця інформація?» — він має назвати конкретний документ і розділ, не «з бази знань».

5. Явні інструкції в tool result — не тільки в системному промпті

«Не відповідай з власних знань» в системному промпті — це рекомендація. Та сама фраза в контенті tool result — це інструкція яку модель бачить прямо перед відповіддю. Як перевірити: тест з is_error: true — відповідь не має містити жодних конкретних цифр або фактів.

Чеклист перед деплоєм агента в production

// Grounding checklist — перевірте кожен пункт перед деплоєм

□ Tool повертає явну інструкцію при порожньому результаті
  (не порожній рядок, а "РЕЗУЛЬТАТ НЕ ЗНАЙДЕНО + ІНСТРУКЦІЯ")

□ Tool використовує is_error: true для notFound і technicalError

□ Є перевірка score з порогами:
  score >= 0.75 → success()
  score 0.55-0.75 → lowRelevance()
  score < 0.55 → notFound()

□ Confidence scoring увімкнено для критичних типів запитів
  (ціни, договори, дедлайни)

□ Re-query має MAX_ATTEMPTS = 2 і зупиняється на NOT_RELEVANT

□ Tool result містить citation: назву документу, розділ, дату

□ Логується stop_reason для кожної відповіді агента

□ Є тест: порожня база → агент відповідає "не знайшов"
  (не вигадує відповідь)

□ Є тест: is_error: true → відповідь без конкретних цифр і фактів

Три типові помилки які знищують grounding

// ❌ Помилка 1: порожній рядок замість явної інструкції
return "";  
// Модель інтерпретує по-різному — іноді відповідає з пам'яті

// ✅ Правильно:
return "РЕЗУЛЬТАТ НЕ ЗНАЙДЕНО. НЕ відповідай з власних знань.";


// ❌ Помилка 2: покладатись на системний промпт для grounding
new SystemMessage("Якщо не знаєш — скажи що не знаєш");
// Працює іноді. Недетерміновано. Не в production.

// ✅ Правильно:
// Явна інструкція в кожному tool result де потрібно


// ❌ Помилка 3: re-query без ліміту
while (!found) {
    currentQuery = reformulate(currentQuery);
    result = search(currentQuery);
}
// Нескінченний цикл якщо документа немає в базі

// ✅ Правильно:
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { ... }
Наступний крок у серії: grounding вирішує проблему якості результатів. Але є інша проблема — коли у агента стає 10-15+ tools, передавати їх всі в кожен запит стає неефективно і якість вибору tool падає. Як будувати динамічний реєстр інструментів і підключати тільки потрібні tools до конкретного запиту — Tool RAG — що робити коли у агента 100 інструментів.

Читайте також у серії про AI агентів:

Tool Use vs Function Calling — базова механіка перш ніж будувати grounding.

Як LLM вирішує коли викликати tool — як модель приймає рішення до того як отримати результат.

Agent Chat — живий приклад де grounding критичний для Wikipedia і Tavily tools.

Джерела: Spring AI Documentation, Anthropic Tool Use Docs, WildToolBench (2026)

Останні статті

Читайте більше цікавих матеріалів

Grounding в AI агентах: що робити коли tool call повернув не те

Grounding в AI агентах: що робити коли tool call повернув не те

Уявіть: ваш AI агент отримав запит «яка ціна на Enterprise план?». Він викликав tool. Tool відповів. Агент сформулював відповідь — впевнено, зв'язно, з конкретною цифрою. Клієнт отримав відповідь і пішов задоволений. Проблема в тому що tool повернув порожній результат — документ не...

Я змусив два AI посперечатись про vibe coding — ось що вийшло

Я змусив два AI посперечатись про vibe coding — ось що вийшло

Я очікував що AI здасться через 3 раунди. Він не здався через 8. І це змінило моє розуміння того як працюють мовні моделі. Як виникла ідея Класична проблема AI-агентів — вони занадто ввічливі. Попроси ChatGPT посперечатись — він погодиться через два повідомлення. Мене це дратувало. Я...

Agent Chat: два AI агенти що сперечаються — Spring Boot 4 + Spring AI + Ollama / OpenRouter

Agent Chat: два AI агенти що сперечаються — Spring Boot 4 + Spring AI + Ollama / OpenRouter

Що буде якщо дати двом AI протилежні переконання і змусити їх сперечатись на задану тему? Саме це питання стало відправною точкою для Agent Chat — експерименту де два агенти з різними характерами ведуть діалог в реальному часі, підкріплюючи аргументи реальними фактами з Wikipedia, Tavily,...

GPT-Realtime-2 vs Gemini Live API: що обрати для голосового агента у 2026 році

GPT-Realtime-2 vs Gemini Live API: що обрати для голосового агента у 2026 році

Два флагмани real-time голосового AI вийшли практично одночасно. OpenAI випустила GPT-Realtime-2 7 травня 2026 року. Google запустила Gemini 3.1 Flash Live 26 березня 2026 року. Обидві — speech-to-speech моделі з reasoning всередині. Обидві — для голосових агентів у продакшн. Але під капотом...

GPT-5.5 в Codex: що змінилось для розробників у 2026

GPT-5.5 в Codex: що змінилось для розробників у 2026

23 квітня 2026 OpenAI випустила GPT-5.5 — і одразу зробила її дефолтною моделлю в Codex. Але не кожен апдейт насправді щось змінює у щоденній роботі. Цей — змінює. Три речі, які важливі для розробника: менше токенів на ті ж задачі, та сама швидкість що й GPT-5.4, і якісно новий...

GPT-Realtime-2: технічний гід — WebSocket API, підключення і приклади коду

GPT-Realtime-2: технічний гід — WebSocket API, підключення і приклади коду

Ця стаття — практичний гід для розробників що хочуть підключити GPT-Realtime-2 до свого проєкту. Ми розберемо архітектуру Realtime API, виберемо правильний метод підключення для вашого сценарію, напишемо першу робочу сесію з нуля і налаштуємо preambles, tool calls і recovery з реальним...