Семантический поиск по статьям Хабра в PostgreSQL + индексация текстов LLM в Ollama

от автора

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


ссылка на оригинал статьи https://habr.com/ru/articles/915348/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *