Семантический поиск по статьям Хабра в PostgreSQL + индексация текстов LLM в Ollama. Java.. Java. llm-приложения.. Java. llm-приложения. ollama.. Java. llm-приложения. ollama. Open source.. Java. llm-приложения. ollama. Open source. pgvector.. Java. llm-приложения. ollama. Open source. pgvector. PostgreSQL.. Java. llm-приложения. ollama. Open source. pgvector. PostgreSQL. spring ai.. Java. llm-приложения. ollama. Open source. pgvector. PostgreSQL. spring ai. искусственный интеллект.. Java. llm-приложения. ollama. Open source. pgvector. PostgreSQL. spring ai. искусственный интеллект. обработка естественного языка.. Java. llm-приложения. ollama. Open source. pgvector. PostgreSQL. spring ai. искусственный интеллект. обработка естественного языка. поисковые системы.. Java. llm-приложения. ollama. Open source. pgvector. PostgreSQL. spring ai. искусственный интеллект. обработка естественного языка. поисковые системы. семантический поиск.

Покажу вам практическую реализацию семантического поиска на основе векторных представлений – эмбеддингов из текста. Здесь я создам систему, которая анализирует статьи с Хабра, извлекает из них темы и ключевые слова с помощью локально работающих больших языковых моделей 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

Итог

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

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

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

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

  • Использует локальные модели в 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 позволяют написать почти любой запрос.

Автор: igor_suhorukov

Источник

Rambler's Top100