Покажу вам практическую реализацию семантического поиска на основе векторных представлений - эмбеддингов из текста. Здесь я создам систему, которая анализирует статьи с Хабра, извлекает из них темы и ключевые слова с помощью локально работающих больших языковых моделей LLM, и на основе этих данных создает векторные представления для эффективного поиска по смыслу, а не по запросу на вхождение определенного текста.

Habr data indexing
Habr data indexing

Главное применение LLM это не только и не столько написание рефератов для школьников или писем на работе и получение быстрых ответов на ваши вопросы. Это в первую очередь удобная технология для структурирования и индексирования текстов и мультимедиа содержимого в интернет. Возможно, скоро случится то самое воплощение Семантической Паутины (Веба) которое не произошло из-за трудоемкости ручной разметки людьми данных в интернет.

Что такое семантический поиск

Семантический поиск — это метод поиска информации, основанный на понимании смысла запроса и контента, а не просто на совпадении ключевых слов. Он использует векторные представления - embeddings из текста, созданные с помощью моделей машинного обучения, для того чтобы находить семантически похожие документы.

Традиционный поиск по ключевым словам имеет ряд ограничений:

  • Не учитывает контекст и смысл слов

  • Чувствителен к точности формулировки

  • Не распознает синонимы и связанные понятия

Семантический поиск решает эти проблемы, преобразуя тексты в многомерные векторы, где семантически близкие тексты располагаются рядом в векторном пространстве. Все это благодаря моделям машинного обучения, предобученным на больших корпусах текстов. Как пример, открытая модель nomic-embed-text.

Почему стоит генерировать embedding не по исходному тексту

Одна из основных идей моего подхода — создание векторных представлений не для исходного текста статей, а для извлеченных из них тем повествования и ключевых слов. Это имеет ряд преимуществ:

  1. Извлечение только главного — иногда исходные тексты статей содержат много информации, не относящейся к основной теме, рекламу итп.

  2. Смысловое выравнивание — авторы статей субъективны в выборе тегов, LLM помогает создать более последовательную классификацию

  3. Сужение поиска — извлеченные темы и ключевые слова фокусируются на сути контента.

Архитектура системы

Разработанная система состоит из следующих компонентов:

  • База данных PostgreSQL с расширением pgvector для хранения и поиска векторных представлений

  • Языковая модель (LLM) в Ollama для извлечения тем и ключевых слов из статей

  • Модель для создания embeddings в Ollama, преобразующая тексты в векторные представления

  • Java-приложение на базе Spring Boot и Spring AI, координирующее процесс индексирования данных и их записи в СУБД.

Для сборки кода нужны зависимости проекта, библиотеки и фреймворки: org.postgresql:postgresql:42.7.5, com.fasterxml.jackson.core:jackson-databind:2.19.0, org.springframework.ai:spring-ai-starter-model-ollama:1.0.0, org.projectlombok:lombok:1.18.34, org.testcontainers:postgresql:1.21.0

Схема базы данных

Database Schema
Database Schema

Реализация системы

Структура проекта

Проект реализован на Java с использованием Spring Boot и включает следующие основные компоненты:

  • HabrApplication — основной класс приложения

  • DatabaseManager — класс для работы с базой данных

  • Модели данных: Article - доступ к полям JSON статьи в программе, Topics - семантическая информация на основе статьи, Chapter и др.

Процесс обработки статей представлен на диаграмме:

Application Flow
Application Flow

Это приложение, которое запускается из консоли и ожидает на входе системное свойство с указанием директории где находятся скачанные с хабра статьи -Darticles=/home/habr/articles

Хранение данных

Для хранения данных буду использовать PostgreSQL с расширением pgvector. База данных содержит две основные таблицы:

CREATE TABLE habr (
    id BIGINT PRIMARY KEY,
    title TEXT,
    text TEXT,
    properties JSONB
);

CREATE TABLE habr_vectors (
    id BIGINT,
    idx INTEGER,
    notes TEXT,
    search_vector vector(768),
    PRIMARY KEY (id, idx),
    FOREIGN KEY (id) REFERENCES habr(id)
);

Таблица habr хранит исходные статьи, а habr_vectors — темы, описания и ключевые слова вместе с векторными представления для них:

  • idx = 0 — краткое описание статьи

  • idx = 1 — ключевые слова

  • idx > 1 — отдельные темы, извлеченные из статьи

Код работы с данными

Расположен в одном классе, абстрагирующем HabrApplication от особенности реализации.

База данных создается и запускается в коде приложения. При этом контейнеру СУБД передаются точки монтирования к файловой системе хоста для возможности обмена данными и сохранением состояния между запусками. Это сделал чтобы получился самодостаточное приложение, которому для запуска нужны только JVM, Docker, Ollama.

package com.github.isuhorukov;

import com.github.isuhorukov.model.Chapter;
import lombok.Getter;
import org.postgresql.ds.PGSimpleDataSource;
import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.PostgreSQLContainer;

import javax.sql.DataSource;
import java.io.Closeable;
import java.io.File;
import java.sql.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * Менеджер базы данных, обеспечивающий взаимодействие с PostgreSQL.
 * Класс инициализирует контейнер PostgreSQL с расширением pgvector,
 * создает необходимые таблицы и предоставляет методы для работы с данными.
 */
public class DatabaseManager implements Closeable {
    private final PostgreSQLContainer<?> postgres;
    @Getter
    private DataSource dataSource;

    /**
     * Конструктор, инициализирующий и запускающий контейнер PostgreSQL.
     * Создает необходимые директории и настраивает привязки файловой системы в контейнере.
     */
    public DatabaseManager() {
        File dbDataDir = new File("./postgres-data");
        if (!dbDataDir.exists()) {
            dbDataDir.mkdirs();
        }

        postgres = new PostgreSQLContainer<>("pgvector/pgvector:pg16")
                .withDatabaseName("habr")
                .withUsername("test")
                .withPassword("test")
                .withFileSystemBind(
                        new File("./data").getAbsolutePath(),
                        "/mnt/data",
                        BindMode.READ_ONLY
                )
                .withFileSystemBind(
                        dbDataDir.getAbsolutePath(),
                        "/var/lib/postgresql/data",
                        BindMode.READ_WRITE
                );
        
        postgres.start();
        System.out.println("Database URL: " + postgres.getJdbcUrl());
        initializeDatabase();
    }

    /**
     * Инициализирует базу данных, создавая необходимые расширения и таблицы.
     * @throws RuntimeException если инициализация не удалась
     */
    private void initializeDatabase() {
        try {
            PGSimpleDataSource pgDataSource = new PGSimpleDataSource();
            pgDataSource.setUrl(postgres.getJdbcUrl());
            pgDataSource.setUser(postgres.getUsername());
            pgDataSource.setPassword(postgres.getPassword());
            
            this.dataSource = pgDataSource;
            
            try (Connection connection = dataSource.getConnection();
                 Statement stmt = connection.createStatement()) {
                stmt.execute("CREATE EXTENSION vector");
                stmt.execute("""
                        CREATE TABLE IF NOT EXISTS habr (
                        id BIGINT PRIMARY KEY,
                        title TEXT,
                        text TEXT,
                        properties JSONB
                        )""");
                stmt.execute("""
                        CREATE TABLE IF NOT EXISTS habr_vectors(
                        id BIGINT,
                        idx INTEGER,
                        notes TEXT,
                        search_vector vector(768)
                        )""");
                stmt.execute("ALTER TABLE habr_vectors " +
                        "ADD CONSTRAINT habr_vectors_pkey PRIMARY KEY (id, idx);");

                stmt.execute("ALTER TABLE habr_vectors " +
                        "ADD CONSTRAINT fk_habr_vectors_habr " +
                        "FOREIGN KEY (id) REFERENCES habr(id);");

                stmt.execute("COMMENT ON TABLE habr IS 'Таблица статей с Хабра'");
                stmt.execute("COMMENT ON COLUMN habr.id IS 'Уникальный идентификатор статьи'");
                stmt.execute("COMMENT ON COLUMN habr.title IS 'Заголовок статьи'");
                stmt.execute("COMMENT ON COLUMN habr.text IS 'Полный текст статьи в HTML формате'");
                stmt.execute("COMMENT ON COLUMN habr.properties IS 'Дополнительные свойства статьи в JSON формате'");

                stmt.execute("COMMENT ON TABLE habr_vectors IS 'Таблица векторных представлений для статей Хабра'");
                stmt.execute("COMMENT ON COLUMN habr_vectors.id IS 'Идентификатор статьи," +
                        " как внешний ключ к таблице habr'");
                stmt.execute("COMMENT ON COLUMN habr_vectors.idx IS 'Порядковый номер фрагмента текста " +
                        "в рамках одной статьи. " +
                        "Индекс 0 - краткое описание статьи. " +
                        "Индекс 1 - ключевые слова для статьи. " +
                        "Индексы >1 - темы из статьи'");
                stmt.execute("COMMENT ON COLUMN habr_vectors.notes IS 'Текстовое описание - " +
                        "семантически отличимый фрагмент'");
                stmt.execute("COMMENT ON COLUMN habr_vectors.search_vector IS 'Векторное представление " +
                        "на основе текста их notes для поиска'");

            }
        } catch (SQLException e) {
            throw new RuntimeException("Failed to initialize database", e);
        }
    }

    /**
     * Сохраняет статью Хабра в базу данных.
     * 
     * @param id идентификатор статьи
     * @param title заголовок статьи
     * @param text текст статьи
     * @param json аттрибуты статьи в формате JSON
     * @throws RuntimeException если не записали данные
     */
    public void saveHabrArticle(long id, String title, String text, String json) {
        String sql = "INSERT INTO habr (id, title, text, properties) VALUES (?,?,?,?::jsonb)";

        try (Connection connection = dataSource.getConnection();
             PreparedStatement pstmt = connection.prepareStatement(sql)) {
            pstmt.setLong(1, id);
            pstmt.setString(2, title);
            pstmt.setString(3, text);
            pstmt.setString(4, json);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            throw new RuntimeException("Failed to save habr article", e);
        }
    }

    /**
     * Сохраняет эмбеддинг для статьи Хабра.
     * 
     * @param id идентификатор статьи
     * @param idx индекс фрагмента данных
     * @param notes текстовое значение
     * @param vector массив значений вектора
     * @throws RuntimeException если не записали данные
     */
    public void saveHabrVector(long id, int idx, String notes, float[] vector) {
        String sql = "INSERT INTO habr_vectors (id, idx, notes, search_vector) VALUES (?, ?, ?, ?)";

        try (Connection connection = dataSource.getConnection();
             PreparedStatement pstmt = connection.prepareStatement(sql)) {

            pstmt.setLong(1, id);
            pstmt.setInt(2, idx);
            pstmt.setString(3, notes);

            setVector(connection, pstmt, 4, vector);

            pstmt.executeUpdate();
        } catch (SQLException e) {
            throw new RuntimeException("Failed to save vector data", e);
        }
    }

    /**
     * Устанавливает значение вектора в PreparedStatement.
     * 
     * @param connection соединение с базой данных
     * @param pstmt запрос
     * @param parameterIndex индекс параметра
     * @param vector массив значений вектора
     * @throws SQLException если установка значения не удалась
     */
    private static void setVector(Connection connection, PreparedStatement pstmt, int parameterIndex,
                                  float[] vector) throws SQLException {
        if (vector != null) {
            Float[] boxedArray = new Float[vector.length];
            for (int i = 0; i < vector.length; i++) {
                boxedArray[i] = vector[i];
            }

            Array vectorArray = connection.createArrayOf("float4", boxedArray);
            pstmt.setArray(parameterIndex, vectorArray);
        } else {
            pstmt.setNull(parameterIndex, Types.ARRAY);
        }
    }

    /**
     * Обновляет поисковый вектор для указанной статьи и индекса.
     * 
     * @param id идентификатор статьи
     * @param idx индекс вектора
     * @param vector новый массив значений вектора
     * @throws RuntimeException если обновление не удалось
     */
    public void updateSearchVector(long id, int idx, float[] vector) {
        String sql = "UPDATE habr_vectors SET search_vector = ? WHERE id = ? AND idx = ?";

        try (Connection connection = dataSource.getConnection();
             PreparedStatement pstmt = connection.prepareStatement(sql)) {

            setVector( connection, pstmt, 1, vector);

            pstmt.setLong(2, id);
            pstmt.setInt(3, idx);

            int rowsUpdated = pstmt.executeUpdate();
            if (rowsUpdated ==0) {
                throw new SQLException("Updating search_vector failed, no rows affected. ID: " + id + ", IDX: " + idx);
            }
        } catch (SQLException e) {
            throw new RuntimeException("Failed to update search_vector for ID: " + id + ", IDX: " + idx, e);
        }
    }

    /**
     * Получает набор идентификаторов статей, для которых уже созданы векторы.
     * 
     * @return множество идентификаторов обработанных статей
     * @throws RuntimeException если получение данных не удалось
     */
    public Set<Long> getProcessedForSummaryArticleIds() {
        String sql = "SELECT DISTINCT id FROM habr_vectors";
        Set<Long> ids = new HashSet<>();

        try (Connection connection = dataSource.getConnection();
             Statement stmt = connection.createStatement();
             ResultSet rs = stmt.executeQuery(sql)) {

            while (rs.next()) {
                ids.add(rs.getLong("id"));
            }
        } catch (SQLException e) {
            throw new RuntimeException("Failed to retrieve distinct vector IDs", e);
        }

        return ids;
    }

    /**
     * Получает список глав, для которых необходимо создать векторные представления.
     * 
     * @return список глав для векторизации
     * @throws RuntimeException если получение данных не удалось
     */
    public List<Chapter> getChapterForEmbedding() {
        String sql = "SELECT hv.id, hv.idx, hv.notes, kw.notes as keywords " +
                "FROM habr_vectors hv " +
                "LEFT JOIN habr_vectors kw ON hv.id = kw.id AND kw.idx = 1 " +
                "WHERE hv.search_vector IS NULL AND hv.idx <> 1";

        List<Chapter> results = new ArrayList<>();

        try (Connection connection = dataSource.getConnection();
             Statement stmt = connection.createStatement();
             ResultSet rs = stmt.executeQuery(sql)) {

            while (rs.next()) {
                Chapter chapter = new Chapter();
                chapter.setId(rs.getLong("id"));
                chapter.setIdx(rs.getInt("idx"));
                chapter.setNotes(rs.getString("notes"));
                chapter.setKeywords(rs.getString("keywords"));
                results.add(chapter);
            }
        } catch (SQLException e) {
            throw new RuntimeException("Failed to retrieve vectors", e);
        }

        return results;
    }

    /**
     * Останавливает контейнер PostgreSQL.
     */
    @Override
    public void close() {
        postgres.stop();
    }
}

Процесс обработки данных

Рассмотрю основные этапы обработки, реализованные в классе HabrApplication:

Загрузка статей

private List<Article> loadArticles(String articlesDirPath, ObjectMapper objectMapper) {
    File[] articlesPath = new File(articlesDirPath).listFiles();
    return Arrays.stream(articlesPath)
            .parallel()
            .map(file -> getArticle(file, objectMapper))
            .toList();
}

Статьи загружаются параллельно из JSON-файлов и десериализуются в объекты Article.

Сохранение статей в базу данных

private static void saveArticle(Article article, DatabaseManager databaseManager, ObjectMapper objectMapper) {
    String textHtml = article.getTextHtml();
    article.setTextHtml(null);
    databaseManager.saveHabrArticle(article.getId(), article.getTitleHtml(), textHtml, 
            objectMapper.writeValueAsString(article));
    article.setTextHtml(textHtml);
}

Статьи сохраняются в базу данных, при этом текст статьи выделяется в отдельное поле, а остальные метаданные сохраняются в формате JSON.

Извлечение тем и ключевых слов

private static @Nullable Topics getTopics(ChatModel chatModel, Article article) {
    System.out.println("Getting topics for article. Text length: " + article.getTextHtml().length());
    try {
        return ChatClient.create(chatModel).prompt()
                .user(u -> u.text("Answer in english language only! Enumerate in details topics covered in html text: {html}")
                        .param("html", article.getTextHtml()))
                .call()
                .entity(Topics.class);
    } catch (Exception e) {
        System.out.println("Exception in article id=" + article.getId() + ": " + e.getMessage());
        return null;
    }
}

Для извлечения тем и ключевых слов используется языковая модель, доступная через Spring AI. Отправляю в Ollama текст статьи и получаю структурированный ответ в виде объекта Topics.

Создание векторных представлений

private float[] createEmbedding(EmbeddingModel embeddingModel, List<String> textForEmbeddings, int idx, String text) {
    if (idx != 1) {
        return embeddingModel.embed(textForEmbeddings.get(1) + ". " + text);
    } else {
        return embeddingModel.embed(text);
    }
}

Для каждой темы и для ключевых слов создаются векторные представления. Интересно, что для тем и описания (idx != 1) мы комбинируем текст темы с ключевыми словами, чтобы улучшить качество векторного представления.

Сохранение векторных представлений для данных

databaseManager.saveHabrVector(article.getId(), idx, text, vectors);

Созданные векторные представления сохраняются в базу данных для последующего использования при поиске.

Основной код системы

HabrApplication.java
package com.github.isuhorukov;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.isuhorukov.model.Article;
import com.github.isuhorukov.model.Topics;
import lombok.SneakyThrows;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

import java.io.File;
import java.util.*;

/**
 * Основной класс приложения для обработки статей с Habr.
 * Приложение загружает статьи, обрабатывает их содержимое и сохраняет в базу данных
 * вместе с векторными представлениями для дальнейшего анализа.
 */
@SpringBootApplication
public class HabrApplication {
    public static void main(String[] args) {
        SpringApplication.run(HabrApplication.class, args);
    }

    /**
     * CommandLineRunner для выполнения приложения.
     *
     * @param embeddingModel модель для создания векторного представления текста
     * @param chatModel модель для генерации тем и ключевых слов из текста
     * @param articlesDirPath путь к директории со статьями
     * @return экземпляр CommandLineRunner
     */
    @Bean
    CommandLineRunner run(EmbeddingModel embeddingModel, ChatModel chatModel,
                          @Value("${articles}") String articlesDirPath) {
        return args -> {
            ObjectMapper objectMapper = createObjectMapper();
            List<Article> articleList = loadArticles(articlesDirPath, objectMapper);
            processArticles(articleList, embeddingModel, chatModel, objectMapper);
        };
    }
    
    /**
     * Загружает статьи из указанной директории.
     * 
     * @param articlesDirPath путь к директории со статьями
     * @param objectMapper маппер для десериализации JSON
     * @return список статей
     */
    private List<Article> loadArticles(String articlesDirPath, ObjectMapper objectMapper) {
        File[] articlesPath = new File(articlesDirPath).listFiles();
        return Arrays.stream(articlesPath)
                .parallel()
                .map(file -> getArticle(file, objectMapper))
                .toList();
    }
    
    /**
     * Обрабатывает список статей: определяет темы и ключевые слова, создает векторные представления
     * и сохраняет их в базу данных.
     * 
     * @param articleList список статей для обработки
     * @param embeddingModel модель для создания векторных представлений
     * @param chatModel модель для анализа содержимого статей
     * @param objectMapper маппер для сериализации объектов
     */
    private void processArticles(List<Article> articleList, EmbeddingModel embeddingModel, 
                                ChatModel chatModel, ObjectMapper objectMapper) {
        try (DatabaseManager databaseManager = new DatabaseManager()) {
            articleList.forEach(article -> saveArticle(article, databaseManager, objectMapper));
            articleList.forEach(article -> processArticleEmbeddings(article, chatModel, embeddingModel, databaseManager));
        }
    }
    
    /**
     * Обрабатывает статью: извлекает темы и создает векторные представления.
     * 
     * @param article статья для обработки
     * @param chatModel модель для извлечения тем
     * @param embeddingModel модель для создания векторных представлений
     * @param databaseManager менеджер базы данных для сохранения результатов
     */
    private void processArticleEmbeddings(Article article, ChatModel chatModel, 
                                         EmbeddingModel embeddingModel, DatabaseManager databaseManager) {
        Topics topics;
        List<String> textForEmbeddings;
        
        try {
            topics = getTopics(chatModel, article);
            if (topics == null) {
                return;
            }
            textForEmbeddings = getTextForEmbeddings(topics);
        } catch (Exception e) {
            System.out.println("Error processing article " + article.getId() + ": " + e.getMessage());
            return;
        }
        
        for (int idx = 0; idx < textForEmbeddings.size(); idx++) {
            String text = textForEmbeddings.get(idx);
            float[] vectors = generateEmbedding(embeddingModel, textForEmbeddings, idx, text);
            databaseManager.saveHabrVector(article.getId(), idx, text, vectors);
        }
    }
    
    /**
     * Создает векторное представление для заданного текста.
     * 
     * @param embeddingModel модель для создания векторных представлений
     * @param textForEmbeddings список текстов для векторизации
     * @param idx индекс текущего текста
     * @param text текст для векторизации
     * @return векторное представление текста
     */
    private float[] generateEmbedding(EmbeddingModel embeddingModel, List<String> textForEmbeddings, int idx, String text) {
        if (idx != 1) {
            return embeddingModel.embed(textForEmbeddings.get(1) + ". " + text);
        } else {
            return embeddingModel.embed(text);
        }
    }

    /**
     * Сохраняет статью в базу данных.
     * 
     * @param article статья для сохранения
     * @param databaseManager менеджер базы данных для сохранения результатов
     * @param objectMapper маппер для сериализации объектов
     */
    @SneakyThrows
    private static void saveArticle(Article article, DatabaseManager databaseManager, ObjectMapper objectMapper) {
        String textHtml = article.getTextHtml();
        article.setTextHtml(null);
        databaseManager.saveHabrArticle(article.getId(), article.getTitleHtml(), textHtml, 
                objectMapper.writeValueAsString(article));
        article.setTextHtml(textHtml);
    }

    /**
     * Извлекает темы из статьи с помощью LLM модели.
     * 
     * @param chatModel модель для анализа содержимого статьи
     * @param article статья для анализа
     * @return объект с темами статьи или null в случае ошибки
     */
    private static @Nullable Topics getTopics(ChatModel chatModel, Article article) {
        System.out.println("Getting topics for article. Text length: " + article.getTextHtml().length());
        try {
            return ChatClient.create(chatModel).prompt()
                    .user(u -> u.text("Answer in english language only! Enumerate in details topics covered in html text: {html}")
                            .param("html", article.getTextHtml()))
                    .call()
                    .entity(Topics.class);
        } catch (Exception e) {
            System.out.println("Exception in article id=" + article.getId() + ": " + e.getMessage());
            return null;
        }
    }

    /**
     * Формирует список текстов для создания векторных представлений на основе тем статьи.
     * 
     * @param topics темы статьи
     * @return список текстов для векторизации
     */
    private static @NotNull List<String> getTextForEmbeddings(Topics topics) {
        List<String> topicsText = topics.getTopics().stream()
                .map(topic -> topic.getName() + ". " + topic.getDescription())
                .toList();
                
        List<String> topicsTextFull = new ArrayList<>(topicsText.size() + 2);
        topicsTextFull.add(topics.getSummary());
        topicsTextFull.add(topics.getKeywords());
        topicsTextFull.addAll(topicsText);
        
        return topicsTextFull;
    }

    /**
     * Десериализует статью из файла.
     * 
     * @param file файл со статьей в формате JSON
     * @param objectMapper маппер для десериализации
     * @return объект статьи
     */
    @SneakyThrows
    private static Article getArticle(File file, ObjectMapper objectMapper) {
        return objectMapper.readValue(file, Article.class);
    }

    /**
     * Создает и настраивает ObjectMapper для работы с JSON.
     * 
     * @return ObjectMapper
     */
    private ObjectMapper createObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        return objectMapper;
    }
}

Конфигурация моделей

В файле application.properties настраиваются параметры используемых мной моделей в Spring AI:

spring.ai.ollama.embedding.options.model=nomic-embed-text:v1.5
spring.ai.ollama.chat.options.model=gemma3:4b
spring.ai.ollama.chat.options.num-ctx=128000
spring.ai.ollama.embedding.options.num-ctx=2048

В этом примере использую:

  • gemma3:4b для извлечения тем и ключевых слов из текста статьи с указанным размером контекста.

  • nomic-embed-text:v1.5 для создания векторного представления текста (embeddings).

Рекомендую загрузить предварительно используемые модели в Ollama командой pull или run.

Как работает работает получение структурированных данных в Spring AI

Фреймворк Spring AI при вызове ChatClient.create(chatModel).prompt() ... call().entity(Topics.class) делает автоматический вывод схемы из классов проекта. Ведь под капотом почти все LLM принимают на вход JSON Schema, для того чтобы выдавать ответ в структурированной форме.

Чтобы помочь нейросети с семантикой полей и классов, необходимо только добавить аннотацию @JsonPropertyDescription, которая превращается в поле description в схеме JSON. Ну и конечно как и с людьми, называйте поля осознанно не a1, a2...

package com.github.isuhorukov.model;

import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import lombok.Data;

import java.util.List;

@Data
public class Topics {
    private List<Topic> topics;
    @JsonPropertyDescription("Text summary in english language")
    private String summary;
    @JsonPropertyDescription("Keywords for topic in english language. Format as string concatenated with,")
    private String keywords;
}
package com.github.isuhorukov.model;

import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import lombok.Data;

@Data
public class Topic {
    @JsonPropertyDescription("Short topic name in english language")
    private String name;
    @JsonPropertyDescription("Detailed description for this topic in english language")
    private String description;
}

Для классов Topics, Topic фреймворком создается следующая JSON схема:

{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "keywords" : {
      "type" : "string",
      "description" : "Keywords for topic in english language. Format as string concatenated with,"
    },
    "summary" : {
      "type" : "string",
      "description" : "Text summary in english language"
    },
    "topics" : {
      "type" : "array",
      "items" : {
        "type" : "object",
        "properties" : {
          "description" : {
            "type" : "string",
            "description" : "Detailed description for this topic in english language"
          },
          "name" : {
            "type" : "string",
            "description" : "Short topic name in english language"
          }
        },
        "additionalProperties" : false
      }
    }
  },
  "additionalProperties" : false
}

В чем преимущества использования локальных моделей

В проекте использую локальные модели через Ollama API, а это значит:

  • Конфиденциальность данных — данные не покидают инфраструктуру

  • Экономия — нет необходимости платить за API-вызовы к облачным сервисам

  • Контроль — полный контроль над моделями и их параметрами

  • Отсутствие зависимости от внешних сервисов — система работает даже без доступа к интернету. Стабильно, без зависимости от нагрузки в разное время суток, как бывает с Claude Sonet. Но для быстрой работы моделей требуется мощная видеокарта с приличным объемом видеопамяти, в зависимости от требований используемой LLM модели.

Запросы в PostgreSQL к данным Хабра

В результате прогрева комнаты от работающего ноутбука и "сжигании многих киловатт" нейросетями сохранил сотню тысяч статей с Хабра в PostgreSQL, по которым теперь могу выполнять произвольные SQL запросы, учитывая семантическую близость для текстов.

Важное замечание про индексацию: хоть скачивал статьи с хабра и на русском языке, но я задавал промпт нейросети давать ответ на английском. Для этого у меня есть несколько причин: во-первых нужно выбрать какой-либо один общий язык для информации и у английского преимущество в объемах и качестве датасетов при обучении LLM, второе - моя уверенность, что семантическая близость у nomic-embed-text для английских синонимов слов больше, в третьих - меньше генерируемых токенов в ответе нейросети при извлечении информации.

База после индексирования содержит нужные мне данные статей

Habr data
Habr data

Сначала я найду свои статьи, что попали в эту базу данных:

select id, title from habr where properties->'author'->>'alias' = 'igor_suhorukov'
My articles
My articles

Теперь, я поинтересуюсь что же проиндексировано по моей статье о создании конечных автоматов на SQL в PostgreSQL:

select id,idx, notes from habr_vectors where id=728196
Article details
Article details

И теперь хочу быстро найти 5 результатов поиска максимально близких по семантике фрагмента из всех статей, кроме моей текущей:

Article details
Article details
select id, idx, notes from habr_vectors where id<>728196 and idx=0 order by search_vector <=> (select search_vector from habr_vectors where id=728196 and idx=0) limit 5

Эти результаты получил, используя поиск по индексу для косинусного расстояния между эмбеддингами.

select 'https://habr.com/ru/articles/' ||  id as link from habr_vectors where id<>728196  order by search_vector <=> (select search_vector from habr_vectors where id=728196 and idx=0) limit 5;
              link               
-------------------------------------
 https://habr.com/ru/articles/757278
 https://habr.com/ru/articles/757278
 https://habr.com/ru/articles/760720
 https://habr.com/ru/articles/713714
 https://habr.com/ru/articles/723202
(5 rows)

Time: 3.063 ms

Для inner product между эмбеддингами:

select 'https://habr.com/ru/articles/' ||  id as link from habr_vectors where id<>728196  order by search_vector <#> (select search_vector from habr_vectors where id=728196 and idx=0) limit 5;
                link                 
-------------------------------------
 https://habr.com/ru/articles/757278
 https://habr.com/ru/articles/757278
 https://habr.com/ru/articles/757278
 https://habr.com/ru/articles/757278
 https://habr.com/ru/articles/757278
(5 rows)

Time: 127.108 ms

Для расстояния L2 между эмбеддингами:

osmworld=# select 'https://habr.com/ru/articles/' ||  id as link from habr_vectors where id<>728196  order by search_vector <+> (select search_vector from habr_vectors where id=728196 and idx=0) limit 5;
                link                 
-------------------------------------
 https://habr.com/ru/articles/760720
 https://habr.com/ru/articles/713714
 https://habr.com/ru/articles/707650
 https://habr.com/ru/articles/721464
 https://habr.com/ru/articles/784412
(5 rows)

Time: 132.400 ms

Семантический поиск в PostgreSQL

Для создания embedding векторов прямо из PostgreSQL в Ollama можно либо установить http модуль, либо написать функцию на plPython3, которая будет считать вектор с помощью nomic-embed-text для заданного в параметре пользователем текста:

CREATE OR REPLACE FUNCTION get_embedding(text_input TEXT)
RETURNS VECTOR(768) AS $$
    import requests
    import json
    payload = {
        "model": "nomic-embed-text:v1.5",
        "input": text_input
    }
    headers = {
        "Content-Type": "application/json"
    }
    try:
        response = requests.post("http://127.0.0.1:11434/api/embed", data=json.dumps(payload), headers=headers) #172.17.0.1
        response.raise_for_status()
        result = response.json()
        if 'embeddings' in result and len(result['embeddings']) > 0:
            embedding_vector = result['embeddings'][0]
            if len(embedding_vector) != 768:
                plpy.error(f"Expected vector of length 768, got {len(embedding_vector)}")
            return embedding_vector
        else:
            plpy.error("No embeddings found in response")
    except Exception as e:
        plpy.error(f"Error calling embedding API: {str(e)}")
$$ LANGUAGE plpython3u;

И теперь можно написать запросы вида: найди 5 статей с описанием наиболее похожим на SQL final state machine.

WITH embedding AS MATERIALIZED (
    SELECT get_embedding('SQL final state machine') AS vec
)
SELECT 'https://habr.com/ru/articles/' || id as link, notes
FROM habr_vectors, embedding WHERE idx=0 ORDER BY search_vector <=> embedding.vec
LIMIT 5;
                link                 |                                                                                                                                                                                                    notes
-------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 https://habr.com/ru/articles/728196 | This document details the implementation of a finite state machine using SQL in PostgreSQL. It outlines the use of a state machine to model the life of Vasily, providing a concrete example with a SQL table, data loading, and generation of PlantUML diagrams. It includes code samples for table creation, data insertion, and state machine definition, along with generation of the PlantUML diagram.
 https://habr.com/ru/articles/757278 | The text describes a method for automating business processes using structural state machines, finite state machines (FSMs), and web applications. It outlines a framework for creating FSMs based on SQLite databases, leveraging technologies like Camunda and BPMN.  The key concepts include defining states, transitions, roles, and an emphasis on configuration rather than extensive programming.
 https://habr.com/ru/articles/759070 | This publication describes the mechanisms of recording accrual registers on the server side. It details the SQL queries generated by the platform depending on the type and settings of the register, recording mode, and aggregations. The example is based on 1С 8.3.23 with MSSQL.
 https://habr.com/ru/articles/783836 | This text discusses the transition from System.Data.Sqlclient (SDS) to Microsoft.Data.SqlClient (MDS) in SQL Server Management Studio (SSMS). It covers configuration changes, particularly regarding encryption and certificate trust.  It highlights the importance of using the correct certificate setup, especially with DNS and NETBIOS names, to avoid connection errors.
 https://habr.com/ru/articles/731772 | This text discusses time series data in SQL Server, covering its characteristics, examples, analysis techniques, and recent improvements in SQL Server 2022. It highlights functions like GENERATE_SERIES and DATE_BUCKET, alongside the handling of NULL values using FIRST_VALUE and LAST_VALUE. The text also mentions Azure SQL Edge and its role in IoT solutions.
(5 rows)

Time: 117.057 ms

Интерфес пользователя для поиска - тема для отдельной статьи!

Итог

Я поделился с вами примером своей системы для семантического поиска по статьям Хабра, которая:

  • Загружает и сохраняет статьи в базу данных

  • Извлекает краткий реферат, темы и ключевые слова с помощью языковой модели

  • Создает векторные представления для эффективного поиска

  • Использует локальные модели в Ollama для обеспечения конфиденциальности и экономии. Если у вас много денег, они не ваши или нужно быстро обработать большой объем без закупки ускорителей для нейросетей, то легко можно подключить внешние модели как с OpenAI совместимым интерфейсом, так и любое из длинного списка поддерживаемых провайдеров: Anthropic Claude, Azure OpenAI, DeepSeek, Google VertexAI Gemini, Groq, HuggingFace, Mistral AI, MiniMax, Moonshot AI, NVIDIA (OpenAI-proxy), OCI GenAI/Cohere, Perplexity (OpenAI-proxy), QianFan, ZhiPu AI, Amazon Bedrock Converse

  • Сохраняет для текста темы, ключевые слова и embeddings в PostgreSQL

Исходный код проекта демонстрирует, как можно использовать современные технологии машинного обучения в Java-приложениях для решения практических задач обработки естественного языка и информационного поиска. Локальный запуск LLM для обработки оправдан как в хобби проектах, так и в корпоративных системах обработки данных на основе технологий машинного обучения/ Искуственного Интеллекта.

Такой подход позволяет реализовать поиск по смыслу, который во многом удобнее чем традиционный поиск по ключевым словам. Система может быть адаптирована для работы с другими источниками данных. Тут я показал принцип построения таких систем. А дальше сложность запросов к данным Хабра уже ограничена только воображением. Так как выразительные возможности SQL и расширяемость PostgreSQL позволяют написать почти любой запрос.

Комментарии (7)


  1. triller599
    04.06.2025 07:54

    Весьма наглядный пример и есть возможности для развития, благодарю!

    Вопрос: не будет ли преимуществом объединить(перенести в единую модель) извлечение ключевых слов и формирование эбеддингов? Ведь когда Вы одной сеткой извлекаете ключевые слова и темы, то вы разрушаете между ними связь. Т.е. для второй сетки, которая фомирует эмбеддинги, это просто отдельные слова и фразы.


    1. igor_suhorukov Автор
      04.06.2025 07:54

      Т.е. для второй сетки, которая фомирует эмбеддинги, это просто отдельные слова и фразы.

      Да, это так. Но верно если только генерировать эмбеддинги только для ключевых слов. В этом примере я подмешиваю ключевые слова к описанию темы.

      Из плюсов отдельной LLM и эмбеддинг нейросети - высокая специализация каждой и производительность. А значит можно например убрать "подмешивание" ключевых слов и заменить на подмешивание краткого описания всей статьи в отдельную тему и быстро пересчитать.

      Ваше предложение действительно рабочее для генерации эмбеддингов для исходных кодов программ, там также важно и большее контекстное окно, так как в отличии от обычного текста - без ущерба не разделить большой метод на независимые фрагменты.


  1. samako
    04.06.2025 07:54

    Спасибо за несколько полезных идей (для меня они были, увы, неочевидны) - начну их сейчас пробовать. Как раз работаю над подобной задачей. Кроме gemma3 пробовали ли Вы использовать что-то другое - Spacy, например?


    1. igor_suhorukov Автор
      04.06.2025 07:54

      Spacy это библиотека NLP для Python?

      Конечно, я использую и другие модели в разных задачах, с учётом в том числе и лицензий. Но конкретно в этом сценарии gemma3 лучше всего показала себя, с учётом размера ее контекстного окна / числа параметров.


  1. yrub
    04.06.2025 07:54

    может не в тему, но есть уже семантический поиск по вебу: https://exa.ai/

    cудя по размеру инвестиций это у них даже довольно дешево вышло


  1. SabMakc
    04.06.2025 07:54

    Статья оставила двоякое впечатление... С одной стороны - достаточно интересная тема... А с другой - осталось впечатление, что просто в рабочем проекте, "на коленках", поиграли с технологией, что-то получили, и решили по-быстрому оформить в виде статьи.

    Отсюда странный выбор технологий - использовали просто то, что и так было в проекте и под рукой в целом (подозреваю, что изначально просто junit-тест был написан для пробы).

    Зачем тут spring? Main-класса было бы достаточно. Инъекция зависимостей ради пары зависимостей?
    Зачем lombok? Пару геттеров-сеттеров можно и руками написать.
    Чем обусловлен выбор testcontainers для БД? Есть полноценные встраиваемые БД. А то и просто в RAM/файлах можно было хранить.

    Как бы да, это все "лучшие практики" в Java, но в таком маленьком проекте они просто избыточны - руками все сделать не сильно сложнее, а то и проще было бы.

    Ну и главное - а где собственно семантический поиск? Получили просто индекс близости статей к друг другу.

    Для полноценного же поиска не хватило малости - просто строки ввода, которую надо было через embedding прогнать и вывести список статей по близости к запросу (опционально - перевести через LLM текст запроса).

    P.S. ну или просто кто-то с ИИ для кодинга игрался - что тоже нельзя исключать. Ну или лабораторная работа какая...


    1. igor_suhorukov Автор
      04.06.2025 07:54

      Комментарий оставляет двоякое впечатление. С первого взгляда похож на конструктивный вопрос о выборе технологий, но по своему тону и форме выражение больше на попытку обесценить работу автора и завязать спор на эмоциях.

      "Тут технология X, а почему не Y?". Краткий мой ответ на вопросы такого типа - это личные предпочтения, основанные на опыте разработки прототипов систем. Бывает что код со временем прототипы превращаются в систему, где важнее читаемость и простота расширения, а не отсутствие фреймворков и внешних зависимостей.

      Эта статья иллюстрирует подход, который можно адаптировать под конкретные задачи. Почти любой проект можно реализовать на разных технологиях в зависимости от целей, а в данном случае баланс между образовательной ценностью и "написанием с минимумом фреймворков" казался приоритетным в первом. ИИ для кодинга это вообще отдельная тема про агенты и RAG и совсем не gemma3.

      Для минимального примера можно обойтись без Spring и Lombok. Однако цель этой публикации — демонстрация практической реализации с легкой повторяемостью и фокусе на сути идеи, а не на написании кода без использования фреймворков. Spring Boot уменьшает усилия на подключение Ollama в Spring AI и в логику индексации данных. Lombok сокращает boilerplate с сеттерами и try/catch, что важно в статье, где фокус на логике, а не на многословном синтаксисе языка программирования. В образовательных целей такой подход позволяет читателю сосредоточиться на главном.

      Выбор TestContainers здесь для быстрого старта примера, без описаний инструкций как запустить и настроить PostgreSQL. Опять же фокус на сути, а не командах развертывания СУБД. Мне был нужен именно PostgreSQL c pgvector для долгосрочного хранения данных и написаний произвольных по сложности запросов, а не Sqlite/H2database итп

      Спасибо, про семантический поиск ваш комментарий справедлив хоть и забежал в мою будущую публикацию по этой теме. В этой статье показана базовая реализация индексации текстов, вычисление эмбеддингов, но без интерфейса для пользовательского поиска. Добавление интерфейса — логичный следующий этап, который можно реализовать на основе предложенной архитектуры. Например, можно было бы расширить приложение методом вроде searchByQuery() с использованием этих же эмбеддингов. Дополнил эту статью примером получения вектора SELECT get_embedding('SQL final state machine') для ее логической завершенности.

      Проект действительно начался как эксперимент и превратился в прототип, демонстрирующий потенциал локальных LLM для структурирования данных. Я показал, как интегрировать PostgreSQL, Ollama и Java для решения задачи, которая обычно требует облачных сервисов или отдельных pipeline с DS/DevOPS экспертизой. Для меня это комплимент, что сложный функционал удалось реализовать по виду как лабораторную работу, что говорит об умении делать сложное проще для понимания читателями без ущерба для цели проекта.