Spring AI + pgvector: 6 помилок які я зробив будуючи RAG для блогу

Updated:
Spring AI + pgvector: 6 помилок які я зробив будуючи RAG для блогу

Перша година після підключення Spring AI — і застосунок не стартує.

NoUniqueBeanDefinitionException: expected single matching bean but found 2: ollamaChatModel, openAiChatModel. Гугл каже додати spring.ai.openai.chat.enabled=false. Не працює. Документація мовчить. Це була тільки перша з шести проблем.

У мене блог webscraft.org — 2500+ статей на чотирьох мовах. Я додавав семантичний пошук і RAG-чатбот через Spring AI і pgvector. Планував тиждень. Зайняло два. Ця стаття — про те що пішло не так і як кожна проблема вирішилась.

Концепція RAG без прив'язки до Java — окрема стаття

📚 Зміст статті

🎯 Задача: чому SQL-пошуку не вистачало

SQL LIKE знаходить точні збіги слів. Semantic search знаходить схожий зміст навіть без спільних слів. Для блогу з 2500+ статей на чотирьох мовах — це принципова різниця. Я зрозумів це не з теорії — а коли побачив що реальні користувачі не знаходять контент який очевидно мав би з'явитися.

Конкретний момент коли я вирішив щось змінити: запит «як зробити сайт» — нуль результатів. Стаття «Веб-розробка під ключ» існує, релевантна, добре написана. LIKE її не знаходить бо немає точного збігу слів. Для людини — очевидний зв'язок. Для бази — порожній результат.

Що не так зі звичайним пошуком

Блог webscraft.org — 2500+ статей на чотирьох мовах: українська, англійська, німецька, іспанська. Пошук через SQL LIKE працював і я ним був задоволений — поки не почав дивитися на реальні пошукові запити користувачів.

Три класи проблем які я бачив регулярно:

  • ⚠️ Синоніми і перефразування: «Spring Boot помилки» не знаходить «Common Spring Boot Exceptions», «налаштування бази» не знаходить «database configuration»
  • ⚠️ Концептуальні запити: «як зробити сайт» не знаходить «Веб-розробка під ключ», «прискорити завантаження» не знаходить статтю про оптимізацію
  • ⚠️ Міжмовний пошук: запит українською не знаходить контент англійською навіть якщо він ідеально відповідає на питання

LIKE шукає збіг рядків. Semantic search шукає збіг змісту. Для технічного блогу де одна тема може бути описана десятком способів — різниця суттєва.

Що я хотів побудувати

Два незалежних компоненти з різними пріоритетами:

  • ✔️ Semantic search — доповнити SQL LIKE vector search-ом. Не замінити повністю: LIKE залишається для коротких і точних запитів, vector search підключається для семантичних.
  • ✔️ RAG-чатбот — AI-асистент який відповідає на питання спираючись виключно на контент блогу. Не загальні знання моделі — а конкретні статті з посиланнями.

Чому Spring AI — і чому не вручну

Можна все зробити без Spring AI. Ollama має REST API — можна писати HTTP-клієнти, вручну генерувати ембединги, вручну виконувати SQL до pgvector, самому реалізовувати batch індексацію і retry. Технічно нічого надскладного.

Але це той самий шлях що і писати DAO вручну через JDBC коли є Spring Data JPA. Можна — але навіщо.

Spring AI робить для AI-інтеграції те саме що JPA зробив для роботи з базою даних. Порівняй:

Без Spring AI — вручну Зі Spring AI
HTTP-запит до Ollama, парсинг JSON chatClient.prompt().user(q).call().content()
Генерація ембедингів + INSERT до pgvector vectorStore.add(documents)
SQL cosine similarity запит вручну vectorStore.similaritySearch(request)
RAG пайплайн: пошук + промпт + виклик LLM new QuestionAnswerAdvisor(vectorStore)
Retry, timeout, observability вручну Auto-configuration з коробки

Так само як userRepository.findById(id) приховує з'єднання, маппінг і транзакцію — vectorStore.similaritySearch(...) приховує генерацію ембединга запиту, SQL до pgvector і десеріалізацію. Ти пишеш бізнес-логіку, не інфраструктуру.

Чому pgvector а не окрема vector DB

Ще одне питання яке я задав собі: можливо варто взяти спеціалізовану vector DB — Chroma, Pinecone або Milvus?

Я вибрав pgvector з однієї простої причини: PostgreSQL вже є в проєкті. pgvector — це розширення до існуючої бази, не окремий сервіс. Нічого нового не встановлювати, нічого не підтримувати, нульові додаткові витрати на інфраструктуру. Для 500 статей і ~2500 чанків — pgvector більш ніж достатньо. Окрема vector DB виправдана при мільйонах векторів і складних вимогах до масштабування.

Стек

  • ✔️ Spring Boot 3.5.5
  • ✔️ Spring AI 1.1.3
  • ✔️ PostgreSQL + pgvector розширення
  • ✔️ Ollama — nomic-embed-text (ембединги) + llama3.3:8b (генерація)
  • ✔️ OpenRouter free tier — для генерації на продакшні

Висновок: Задача, стек і архітектурні рішення зрозумілі. Тепер про те що пішло не так — починаючи з першої години після підключення Spring AI.

🎯 Помилка 1-2: залежності і конфлікт бінів

Spring AI 1.1.3 + Spring Boot 3.5.5 сумісні — але якщо додати два стартери (Ollama і OpenAI) одночасно, Spring не знає який ChatModel і EmbeddingModel використовувати. Стандартні properties для вимкнення не працюють. Рішення — exclude в анотації.

Перша година після підключення Spring AI — це не «написав код і запустив». Це «читаю stack trace і не розумію чому два біни конфліктують».

Помилка 1 — Неправильні назви артефактів

Перша спроба — я використав старі назви стартерів які знайшов у гуглі:

<!-- НЕПРАВИЛЬНО — стара назва до версії 1.1 -->
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>

Maven не знаходив артефакт. Причина: у версії 1.1 Spring AI перейменував всі стартери. Правильні назви:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-bom</artifactId>
      <version>1.1.3</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <!-- Ollama -->
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-ollama</artifactId>
  </dependency>
  <!-- OpenAI -->
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
  </dependency>
  <!-- pgvector -->
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
  </dependency>
</dependencies>

Помилка 2 — Конфлікт бінів ChatModel і EmbeddingModel

Після виправлення назв — нова помилка. Spring AI auto-configure реєструє ChatModel і EmbeddingModel для кожного провайдера. З двома стартерами Spring не знає який бін інʼєктувати:

NoUniqueBeanDefinitionException: expected single matching bean
but found 2: ollamaChatModel, openAiChatModel

Моя перша спроба вирішити через properties — не спрацювала:

# НЕ ПРАЦЮЄ в Spring AI 1.1.3
spring.ai.openai.chat.enabled=false
spring.ai.openai.embedding.enabled=false

Правильне рішення — виключити автоконфігурацію OpenAI через анотацію:

@SpringBootApplication(exclude = {
    OpenAiEmbeddingAutoConfiguration.class,
    OpenAiChatAutoConfiguration.class
})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Це виключає автоматичну реєстрацію OpenAI бінів — і залишає тільки Ollama. На продакшні де потрібен OpenRouter — профіль перемикає конфігурацію.

Висновок: Дві помилки на старті — неправильні назви артефактів і конфлікт бінів. Обидві вирішуються за 5 хвилин якщо знаєш де шукати.

🎯 Помилка 3: pgvector — підводні камені яких немає в документації

Коротка відповідь:

Три речі які документація Spring AI не пояснює достатньо чітко: розширення vector треба створити вручну перед стартом, index-type=hnsw падає на старих версіях pgvector з незрозумілою помилкою, і dimensions=768 — обов'язковий параметр без якого insert не пройде. Кожна з них з'їла у мене від 20 хвилин до години дебагу.

Документація Spring AI описує ідеальний сценарій: свіжа база, свіжий pgvector, нічого зайвого. Реальний VPS з PostgreSQL який працює вже рік — це трохи інша історія.

Підводний камінь 1 — CREATE EXTENSION вручну

Перша помилка яку я отримав після підключення Spring AI pgvector стартера:

ERROR: type "vector" does not exist

Що відбувається насправді: initialize-schema: true в Spring AI створює таблицю vector_store — але не створює розширення vector. Розширення — це рівень бази даних, і Spring AI не чіпає його. Потрібно виконати вручну один раз:

-- Підключитись до потрібної бази і виконати:
CREATE EXTENSION IF NOT EXISTS vector;

-- Перевірити що розширення встановлено:
SELECT * FROM pg_extension WHERE extname = 'vector';

Важливо: виконувати в контексті саме тієї бази до якої підключається застосунок. Якщо є кілька середовищ (dev, staging, prod) — в кожному окремо.

Підводний камінь 2 — index-type=hnsw і bad SQL grammar

Я спробував налаштувати HNSW індекс для кращої продуктивності:

spring.ai.vectorstore.pgvector.index-type=hnsw

Застосунок не стартував. Помилка:

org.springframework.jdbc.BadSqlGrammarException:
PreparedStatementCallback; bad SQL grammar [
  CREATE INDEX ON vector_store
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64)
]

Причина: HNSW з'явився в pgvector тільки у версії 0.5.0. На моєму VPS стояла старіша версія. Як перевірити:

SELECT extversion FROM pg_extension WHERE extname = 'vector';

Якщо версія менше 0.5.0 — вимкни індекс:

spring.ai.vectorstore.pgvector.index-type=none

Для 2500 чанків лінійний пошук займає мілісекунди — цілком прийнятно. HNSW виправданий при десятках тисяч векторів і вище.

Підводний камінь 3 — dimensions: обов'язковий і точний

Наступна помилка при першій спробі проіндексувати статті:

ERROR: expected 1536 dimensions, not 768

nomic-embed-text генерує вектори 768 вимірів. Якщо не вказати це явно — Spring AI може створити таблицю з дефолтним розміром який не збігається. Обов'язково вказати точне значення:

spring.ai.vectorstore.pgvector.dimensions=768   # nomic-embed-text
# spring.ai.vectorstore.pgvector.dimensions=1536  # OpenAI text-embedding-3-small
# spring.ai.vectorstore.pgvector.dimensions=384   # all-minilm

Якщо таблиця вже створена з неправильним розміром — initialize-schema: true не перестворить її автоматично. Потрібно видалити вручну:

DROP TABLE IF EXISTS vector_store;
-- Перезапустити — Spring AI створить таблицю заново з правильними dimensions

Повна робоча конфігурація pgvector

spring:
  ai:
    vectorstore:
      pgvector:
        initialize-schema: true     # створює vector_store (не розширення!)
        dimensions: 768             # точний розмір вектора nomic-embed-text
        distance-type: COSINE_DISTANCE
        index-type: none            # none якщо pgvector < 0.5.0

І обов'язково перед першим запуском — вручну на базі даних:

CREATE EXTENSION IF NOT EXISTS vector;

Коротко: що перевірити якщо pgvector не працює

  • ✔️ type "vector" does not existCREATE EXTENSION IF NOT EXISTS vector на потрібній базі
  • ✔️ bad SQL grammar при старті → перевір версію pgvector, якщо менше 0.5.0 → index-type=none
  • ✔️ expected N dimensions, not M → встанови правильне значення dimensions і перествори таблицю через DROP TABLE vector_store

Висновок: Жодна з трьох помилок не очевидна без досвіду роботи з pgvector. Але кожна вирішується за хвилини якщо знаєш що шукати. Сподіваюся цей розділ заощадить тобі ту годину яку я витратив на дебаг.

🎯 Помилка 4: nomic-embed-text і обмеження на нелатинських мовах

Коротка відповідь:

nomic-embed-text добре працює на англійських довгих запитах. На коротких запитах (1-2 слова) і нелатинських мовах — якість пошуку помітно нижча. Я виявив це через логування score в продакшні — не через документацію.

Я вважав що після підключення vector search пошук «просто запрацює». Логи показали інше. Score 0.63 для нерелевантної статті — і правильна стаття навіть не в топ-5.

Що показали реальні логи

Після запуску я додав детальне логування score для кожного результату. Ось що я побачив на реальних запитах:

  • ⚠️ Запит «LLM» (одне слово) → score 0.63 для нерелевантної статті, правильна стаття не потрапила в топ-5 взагалі
  • ✔️ Запит «LLM vs RAG у 2026» (4 слова) → score 0.69 для правильної статті, результат релевантний
  • ⚠️ Запит «Spring» (одне слово) → розмитий результат, кілька нерелевантних статей з score 0.6+
  • ✔️ Запит «як налаштувати Spring Security» (4 слова) → точний релевантний результат

Закономірність очевидна: vector search через nomic-embed-text добре працює на семантичних запитах з 3+ слів. На коротких запитах — гірший ніж SQL LIKE. Це справедливо для будь-якої мови — не тільки для кирилиці.

Проблема багатомовного контенту

Мій блог має контент на чотирьох мовах: українська, англійська, німецька, іспанська. nomic-embed-text навчена переважно на англійських текстах. Це означає дві речі на практиці:

  • ⚠️ Якість ембедингів нижча для нелатинських мов — кирилиця, арабська, китайська і подібні мови дають гіршу семантичну точність порівняно з англійським текстом
  • ⚠️ Крос-мовний пошук не працює — ембединги тексту однією мовою і схожого тексту іншою мовою живуть у різних «зонах» векторного простору. Запит англійською не знайде релевантну статтю українською навіть якщо зміст ідентичний

Якщо твій контент тільки англійською — ця проблема тебе не стосується. Якщо є кілька мов або нелатинський текст — потрібне рішення.

Як я це вирішив

Два підходи які я використав:

  • ✔️ Фільтрація по locale при пошуку — не змішувати мови в одному запиті. Запит від користувача йде тільки по ембедингах тієї мови якою він задає питання. Реалізовано через FilterExpressionBuilder і метадані чанків
  • ✔️ Окремі ембединги для кожної мовної версії статті — українська версія індексується окремо від англійської, кожен чанк має метадані locale: "uk" або locale: "en"

Якби я починав заново — розглянув би multilingual ембединг модель замість nomic-embed-text. Наприклад, paraphrase-multilingual-mpnet-base-v2 підтримує 50+ мов і дає однакову якість незалежно від мови. Компроміс — більший розмір моделі і повільніша генерація ембедингів.

Висновок розділу: nomic-embed-text — правильний старт для англомовного контенту. Для нелатинських мов або багатомовних проєктів — або фільтруй по locale, або розглянь multilingual модель з самого початку.

🎯 Архітектура: стратегія з fallback

Коротка відповідь:

Після розуміння обмежень vector search — я спроектував архітектуру з двома провайдерами і автоматичним fallback. Vector search для семантичних запитів, SQL LIKE для коротких. Весь vector search вимикається одним рядком конфігу.

Найважливіше рішення в цьому проєкті — не «як реалізувати vector search», а «що робити коли він не справляється».

Інтерфейс і два провайдери

Я створив інтерфейс BlogSearchProvider з двома реалізаціями:

  • ✔️ DatabaseBlogSearchProvider — SQL LIKE, завжди працює
  • ✔️ VectorBlogSearchProvider — pgvector semantic search, з @ConditionalOnProperty
@ConditionalOnProperty(name = "app.search.vector.enabled", havingValue = "true")
public class VectorBlogSearchProvider implements BlogSearchProvider {
    // vector search реалізація
}

Фасад з логікою вибору

@Service
public class BlogPostSearchService {

    private static final int MIN_WORDS_FOR_VECTOR = 3;

    private final DatabaseBlogSearchProvider databaseProvider;
    private final BlogSearchProvider vectorProvider; // може бути null

    public SliceResponse<BlogPostCardDto> searchPosts(
            String query, Pageable pageable) {

        // 3+ слова → пробуємо vector search
        if (shouldUseVector(query)) {
            try {
                SliceResponse<BlogPostCardDto> result =
                    vectorProvider.searchPosts(query, pageable);
                // vector знайшов → повертаємо
                if (!result.getContent().isEmpty()) return result;
            } catch (Exception e) {
                // vector впав → fallback, не падаємо
                log.warn("Vector search failed, fallback: {}", e.getMessage());
            }
        }

        // fallback — database search завжди
        return databaseProvider.searchPosts(query, pageable);
    }

    private boolean shouldUseVector(String query) {
        if (vectorProvider == null || query == null) return false;
        return query.trim().split("\\s+").length >= MIN_WORDS_FOR_VECTOR;
    }
}

Дедуплікація — один документ, один результат

Vector search повертає чанки — не документи. Одна стаття може дати 4 чанки з 5 у топ-5. Я додав дедуплікацію по postId з вибором чанку з найвищим score:

// Залишаємо тільки найрелевантніший чанк per post
Map<Long, Document> uniquePosts = new LinkedHashMap<>();
for (Document doc : results) {
    Long postId = toLong(doc.getMetadata().get("postId"));
    if (postId != null) {
        uniquePosts.merge(postId, doc, (oldDoc, newDoc) ->
            newDoc.getScore() > oldDoc.getScore() ? newDoc : oldDoc
        );
    }
}

Вимикач через конфіг

# Вимикає vector search одним рядком — database search продовжує працювати
app.search.vector.enabled=false

Це виявилося дуже корисним при дебагу — можна вимкнути vector search і перевірити чи проблема в ньому або в базовому пошуку.

Висновок розділу: Fallback архітектура — не надлишковість, а необхідність. Vector search нестабільний на коротких запитах, і має бути що підхопить якщо він не справляється.

🎯 Помилка 5-6: індексація — де я витратив найбільше часу

Коротка відповідь:

Дві несподівані помилки при індексації: NumberFormatException через неправильний формат ID і нескінченна переіндексація через @PreUpdate баг. Обидві не очевидні і не описані в документації.

Я думав що найскладніше — це налаштувати Spring AI і pgvector. Насправді найскладніше — це зробити надійну індексацію яка не зависає, не дублює і правильно відновлюється після падіння.

Помилка 5 — Document ID має бути UUID

Перша версія індексації — я передавав рядок як ID документа:

// НЕПРАВИЛЬНО — pgvector очікує UUID
new Document("post-" + post.getId(), text, metadata)

Результат при першому запуску індексації:

NumberFormatException: For input string: "post"

pgvector зберігає ID як UUID і не приймає довільні рядки. Рішення — детермінований UUID через nameUUIDFromBytes:

// ПРАВИЛЬНО — детермінований UUID
// При переіндексації той самий чанк отримає той самий ID → перезапис без дублікатів
private List<Document> splitToChunks(
        String text, String idPrefix, Map<String, Object> metadata) {

    Document doc = new Document(text, metadata);
    List<Document> chunks = textSplitter.split(doc);

    List<Document> result = new ArrayList<>();
    for (int i = 0; i < chunks.size(); i++) {
        UUID id = UUID.nameUUIDFromBytes(
            (idPrefix + "-chunk-" + i).getBytes()
        );
        result.add(new Document(id.toString(), chunks.get(i).getText(), metadata));
    }
    return result;
}

Додатковий бонус детермінованого UUID: якщо запустити індексацію двічі — чанки перезаписуються, а не дублюються. База не росте безконтрольно.

Помилка 6 — @PreUpdate і нескінченна переіндексація

Це найнесподіваніша помилка з усіх. Шедулер індексації запускається щоночі і шукає статті де updated_at > indexed_at. Після першого успішного запуску — все індексовано. Другий запуск — знову 500 статей. І знову. І знову.

Я довго не міг зрозуміти чому. Виявилося:

  • 1. Шедулер знаходить неіндексовані статті
  • 2. Після індексації викликає blogPostRepository.saveAll(posts) щоб встановити indexed_at = now()
  • 3. @PreUpdate на сутності оновлює updated_at = now() при кожному saveAll()
  • 4. indexed_at і updated_at стають однаковими або indexed_at трохи менше
  • 5. При наступному запуску — всі статті знову «неіндексовані»
// НЕПРАВИЛЬНО — indexed_at може стати рівним або меншим ніж updated_at
posts.forEach(p -> p.setIndexedAt(LocalDateTime.now()));
blogPostRepository.saveAll(posts); // @PreUpdate оновить updated_at!

// ПРАВИЛЬНО — indexed_at гарантовано більше ніж updated_at після saveAll
LocalDateTime future = LocalDateTime.now().plusMinutes(10);
posts.forEach(p -> p.setIndexedAt(future));
blogPostRepository.saveAll(posts);

Встановлення indexed_at трохи у майбутнє гарантує що навіть після @PreUpdateindexed_at > updated_at і стаття не потрапить в чергу на переіндексацію.

Додаткова проблема: LAZY loading

При індексації перекладів статей — BlogPostTranslation.post завантажується LAZY. Доступ до tr.getPost().getSlug() поза транзакцією давав:

LazyInitializationException:
could not initialize proxy - no Session

Рішення — JOIN FETCH у запиті:

@Query("SELECT t FROM BlogPostTranslation t " +
       "JOIN FETCH t.post " +
       "WHERE t.indexedAt IS NULL OR t.post.updatedAt > t.indexedAt")
List<BlogPostTranslation> findNotIndexed();

Висновок розділу: Дві несподівані помилки — UUID формат і @PreUpdate баг — це ті речі де документація мовчить. Тепер ти знаєш про них заздалегідь.

🎯 BlogRagService: як влаштований RAG-чатбот

Коротка відповідь:

RAG-чатбот — це vector search по запиту + формування контексту + виклик LLM з системним промптом. Плюс streaming для кращого UX і фільтрація по locale щоб не змішувати мови.

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

Системний промпт — чому він такий жорсткий

private static final String SYSTEM_PROMPT = """
    Ти — розумний помічник блогу WebCraft Digital (webscraft.org).
    Відповідай на питання ТІЛЬКИ на основі наданого контексту зі статей блогу.
    Якщо контексту недостатньо — чесно скажи про це.
    Відповідай тією мовою, якою задано питання.
    Відповідай коротко та по суті — максимум 3-5 речень.
    В кінці відповіді додай ТІЛЬКИ ті статті, які безпосередньо стосуються питання.
    НЕ додавай статті, які лише побічно пов'язані з темою.

    Контекст зі статей:
    {context}
    """;

Три ключових обмеження: «ТІЛЬКИ на основі контексту», «чесно скажи» і «НЕ додавай нерелевантні статті». Без цього — галюцинації і сміттєві посилання.

Фільтрація по locale

Vector search без фільтра по мові мішає результати з усіх чотирьох мов. Українське питання може отримати контекст з англійської статті — і відповідь буде неповною або незрозумілою.

private List<Document> searchDocuments(String question, String locale) {
    FilterExpressionBuilder b = new FilterExpressionBuilder();
    return vectorStore.similaritySearch(
        SearchRequest.builder()
            .query(question.trim())
            .topK(5)
            .similarityThreshold(0.5)
            .filterExpression(b.eq("locale", locale).build())
            .build()
    );
}

Streaming для кращого UX

Генерація відповіді через llama3.3:8b займає 5-15 секунд. Без streaming — користувач бачить порожній екран і не знає чи запит обробляється. З streaming — відповідь з'являється токен за токеном, як в ChatGPT.

public Flux<String> askStream(String question, String locale) {
    List<Document> results = searchDocuments(question, locale);

    if (results.isEmpty()) {
        return Flux.just("На жаль, я не знайшов релевантної інформації у блозі.");
    }

    String context = buildContext(results, sources);
    String systemMessage = SYSTEM_PROMPT.replace("{context}", context);

    // Streaming токенів
    Flux<String> answerStream = chatModel.stream(
        new Prompt(List.of(
            new SystemMessage(systemMessage),
            new UserMessage(question)
        ))
    ).map(response -> {
        String token = response.getResult().getOutput().getText();
        // Escape для JSON streaming
        return "{\"t\":\"" + token
            .replace("\\", "\\\\")
            .replace("\"", "\\\"")
            .replace("\n", "\\n") + "\"}";
    });

    // Додаємо джерела в кінці стріму
    String sourcesText = buildSourcesText(sources);
    return Flux.concat(answerStream, Flux.just(sourcesText));
}

Висновок розділу: Три ключових рішення в RAG-чатботі — жорсткий системний промпт, фільтрація по locale і streaming. Кожне з них виявилося важливішим ніж здавалося на старті.

🎯 Що б я зробив інакше

Коротка відповідь:

Три речі які б заощадили мені кілька днів: тестування threshold на реальних запитах до написання коду, логування score з першого дня і вибір multilingual ембединг моделі для багатомовного контенту.

1. Спочатку протестувати threshold — потім писати код

Я почав з реалізації і тільки потім перевірив яке значення threshold реально працює на моїх даних. Виявилося що 0.5 дає забагато шуму, а 0.7 нічого не знаходить на україномовних запитах. Правильний порядок: спочатку залогувати 50-100 реальних запитів і подивитись на розподіл score — потім вибирати threshold.

2. Логування score з першого дня

Я додав детальне логування score тільки після того як помітив проблеми. Якби логував з початку — виявив би обмеження nomic-embed-text на коротких запитах на першому ж тижні, а не через місяць.

// Додай це з першого дня — і більшість проблем стануть очевидними
log.info("RAG result: score={} title={}",
    doc.getScore(),
    doc.getMetadata().get("title"));

3. Multilingual ембединг модель для багатомовного контенту

nomic-embed-text добре працює для англійського тексту. Для чотирьох мов одночасно — варто розглянути snowflake-arctic-embed або paraphrase-multilingual-mpnet-base-v2 які оптимізовані для мультилінгвального semantic search. Я вибрав nomic-embed-text через простоту — і отримав компроміс у якості на нелатинських мовах.

4. Окремий шедулер для переіндексації змінених статей

Зараз шедулер індексує тільки нові статті. При оновленні існуючої статті — вона не переіндексовується автоматично. Варто додати окрему логіку: якщо updated_at значно новіше ніж indexed_at — переіндексувати.

Висновок: Чотири речі яких я б не робив — або зробив по-іншому. Можливо збережуть тобі кілька днів дебагу.

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

Як вирішити конфлікт бінів між Ollama і OpenAI в Spring AI?

spring.ai.openai.chat.enabled=false і spring.ai.openai.embedding.enabled=false не працюють в Spring AI 1.1.3 — ці properties або відсутні або не мають ефекту в цій версії. Єдине рішення яке спрацювало:

@SpringBootApplication(exclude = {
    OpenAiEmbeddingAutoConfiguration.class,
    OpenAiChatAutoConfiguration.class
})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Це виключає автоконфігурацію OpenAI повністю — і Spring бачить тільки Ollama біни без конфлікту.

Чому шедулер переіндексовує все заново щоразу?

Найімовірніша причина — @PreUpdate на сутності оновлює updated_at при кожному saveAll(). Відразу після запису indexed_at стає рівним або меншим за updated_at — і наступний запуск шедулера знаходить ці записи як «неіндексовані».

Рішення: встановлювати indexed_at трохи у майбутнє:

// НЕ ПРАЦЮЄ — @PreUpdate оновить updated_at і indexed_at стане рівним або меншим
posts.forEach(p -> p.setIndexedAt(LocalDateTime.now()));

// ПРАЦЮЄ — indexed_at гарантовано більше ніж updated_at після saveAll()
LocalDateTime future = LocalDateTime.now().plusMinutes(10);
posts.forEach(p -> p.setIndexedAt(future));
blogPostRepository.saveAll(posts);

Яке значення similarity threshold вибрати?

Універсального значення немає — залежить від моделі, мови і типу запитів. Починай з 0.5 і логуй score на реальних запитах мінімум тиждень. З мого досвіду на nomic-embed-text:

  • ✔️ Нелатинські мови (кирилиця) — реалістично 0.5–0.6
  • ✔️ Англійські семантичні запити — можна підвищити до 0.65–0.7
  • ✔️ Якщо всі результати нижче порогу — повертай «не знайдено» замість передачі порожнього контексту в LLM

Без логування score — неможливо діагностувати чому пошук дає погані результати. Додай це з першого дня:

log.info("RAG result: score={} title={}",
    doc.getScore(), doc.getMetadata().get("title"));

Чи варто використовувати QuestionAnswerAdvisor замість ручного підходу?

Якщо у тебе простий кейс — одна мова, без кастомної фільтрації — QuestionAnswerAdvisor вирішує задачу одним рядком і цього достатньо:

ChatClient.create(chatModel)
    .prompt()
    .advisors(new QuestionAnswerAdvisor(vectorStore))
    .user(question)
    .call()
    .content();

Я вибрав ручний підхід бо мав три специфічні вимоги: фільтрація по locale (чотири мови), кастомна дедуплікація по postId і streaming відповіді через Flux<String>. QuestionAnswerAdvisor цього з коробки не дає.

Якщо ще не знайомий зі Spring AI загалом — що це таке, які провайдери підтримує і як влаштований ChatClient — почни з огляду: Spring AI 2026: що це таке і як використовувати у Spring Boot.

Скільки часу зайняла реалізація?

Планував тиждень. Зайняло два. Розподіл часу приблизно такий:

  • ✔️ Написання коду — 30% часу
  • ✔️ Дебаг шести помилок описаних в цій статті — 50% часу
  • ✔️ Тюнінг threshold і тестування на реальних запитах — 20% часу

Більшість часу іде не на написання коду — а на помилки яких немає в документації.

✅ Висновки

Spring AI + pgvector + Ollama — це робочий стек для RAG на Java. Інтеграція не складна — коли знаєш підводні камені. Більшість часу я витратив не на написання коду, а на помилки яких немає в документації.

Шість помилок які я зробив — і рішення для кожної:

  • ✔️ Неправильні назви артефактів → нові назви з BOM 1.1.3
  • ✔️ Конфлікт бінів Ollama + OpenAI@SpringBootApplication(exclude = {...})
  • ✔️ pgvector підводні камені → CREATE EXTENSION вручну, index-type=none, dimensions=768
  • ✔️ nomic-embed-text на коротких запитах → fallback на SQL для запитів менше 3 слів
  • ✔️ NumberFormatException при індексації → детермінований UUID через nameUUIDFromBytes
  • ✔️ @PreUpdate баг з indexed_atLocalDateTime.now().plusMinutes(10)

Загальна концепція RAG і мовонезалежні патерни — в статті RAG з Ollama: від пайплайну до продакшну.

Якщо ще не знайомий з Ollama — почни з огляду Ollama 2026.

📎 Джерела

  1. Spring AI 1.1 GA Released — офіційний анонс
  2. Spring AI Reference Documentation — офіційна документація
  3. Spring AI GitHub — вихідний код і приклади
  4. DEV Community: Spring AI RAG — від демо до продакшну — ETL, PGVector, тюнінг
  5. Baeldung: Semantic Search з Spring AI і PGVector — практичний приклад
  6. Ollama: nomic-embed-text — характеристики ембединг моделі

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

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

Як працює AI: токени, трансформери і навчання LLM

Як працює AI: токени, трансформери і навчання LLM

Щоразу, коли ви відправляєте повідомлення в ChatGPT, Claude або Gemini, за лічені секунди відбувається щось неймовірно складне: система, навчена на трильйонах слів, прогнозує наступний токен, зважує контекст тисяч попередніх слів і генерує відповідь, яка здається осмисленою. Але як саме це працює...

Spring AI + pgvector: 6 помилок які я зробив будуючи RAG для блогу

Spring AI + pgvector: 6 помилок які я зробив будуючи RAG для блогу

Перша година після підключення Spring AI — і застосунок не стартує. NoUniqueBeanDefinitionException: expected single matching bean but found 2: ollamaChatModel, openAiChatModel. Гугл каже додати spring.ai.openai.chat.enabled=false. Не працює. Документація мовчить. Це була тільки...

RAG з Ollama: як навчити AI відповідати по твоїх документах — від пайплайну до продакшну

RAG з Ollama: як навчити AI відповідати по твоїх документах — від пайплайну до продакшну

RAG з Ollama: навчи AI відповідати по твоїх документах У тебе є документи — PDF, статті, нотатки, база знань. Ти хочеш задавати питання і отримувати відповіді саме по цих документах, а не по загальних знаннях моделі. І все це — локально, без відправки даних у хмару....

Comet проти Safari та Chrome: чи варто переходити на AI-браузер у 2026

Comet проти Safari та Chrome: чи варто переходити на AI-браузер у 2026

Щороку з'являються десятки нових браузерів — і майже всі зникають непомітно. Але Comet від Perplexity — інший випадок. Це не чергова косметична надбудова над Chrome. Це спроба переосмислити саму роль браузера у твоєму житті. Спойлер: Comet не замінить Safari чи Chrome для...

Браузер Comet від Perplexity вийшов на iOS

Браузер Comet від Perplexity вийшов на iOS

Ми звикли до того, що браузер — це просто вікно в інтернет. Ти відкриваєш сторінку, читаєш, закриваєш. Але що, якщо браузер сам читає сторінку за тебе, знаходить потрібне і виконує завдання? Саме таку ідею просуває Perplexity зі своїм новим браузером Comet, який 18...

Контекстне вікно LLM: чому AI забуває і скільки це коштує

Контекстне вікно LLM: чому AI забуває і скільки це коштує

Ти коли-небудь помічав, що ChatGPT або Claude на початку розмови пам'ятає все ідеально, а через годину починає плутати деталі або перепитувати те, що ти вже пояснював? Це не баг — це фундаментальне обмеження, яке визначає, скільки AI може "тримати в голові" одночасно. Називається воно...