Покажу вам практическую реализацию семантического поиска на основе векторных представлений — эмбеддингов из текста. Здесь я создам систему, которая анализирует статьи с Хабра, извлекает из них темы и ключевые слова с помощью локально работающих больших языковых моделей LLM, и на основе этих данных создает векторные представления для эффективного поиска по смыслу, а не по запросу на вхождение определенного текста.
Главное применение LLM это не только и не столько написание рефератов для школьников или писем на работе и получение быстрых ответов на ваши вопросы. Это в первую очередь удобная технология для структурирования и индексирования текстов и мультимедиа содержимого в интернет. Возможно, скоро случится то самое воплощение Семантической Паутины (Веба) которое не произошло из-за трудоемкости ручной разметки людьми данных в интернет.
Что такое семантический поиск
Семантический поиск — это метод поиска информации, основанный на понимании смысла запроса и контента, а не просто на совпадении ключевых слов. Он использует векторные представления — embeddings из текста, созданные с помощью моделей машинного обучения, для того чтобы находить семантически похожие документы.
Традиционный поиск по ключевым словам имеет ряд ограничений:
-
Не учитывает контекст и смысл слов
-
Чувствителен к точности формулировки
-
Не распознает синонимы и связанные понятия
Семантический поиск решает эти проблемы, преобразуя тексты в многомерные векторы, где семантически близкие тексты располагаются рядом в векторном пространстве. Все это благодаря моделям машинного обучения, предобученным на больших корпусах текстов. Как пример, открытая модель nomic-embed-text.
Почему стоит генерировать embedding не по исходному тексту
Одна из основных идей моего подхода — создание векторных представлений не для исходного текста статей, а для извлеченных из них тем и ключевых слов. Это имеет ряд преимуществ:
-
Извлечение только главного — иногда исходные тексты статей содержат много информации, не относящейся к основной теме, рекламу итп.
-
Смысловое выравнивание — авторы статей субъективны в выборе тегов, LLM помогает создать более последовательную классификацию
-
Сужение поиска — извлеченные темы и ключевые слова фокусируются на сути контента.
Архитектура системы
Разработанная система состоит из следующих компонентов:
-
База данных 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
Схема базы данных
Реализация системы
Структура проекта
Проект реализован на Java с использованием Spring Boot и включает следующие основные компоненты:
-
HabrApplication— основной класс приложения -
DatabaseManager— класс для работы с базой данных -
Модели данных:
Article— доступ к полям JSON статьи в программе,Topics— семантическая информация на основе статьи,Chapterи др.
Процесс обработки статей представлен на диаграмме:
Это приложение, которое запускается из консоли и ожидает на входе системное свойство с указанием директории где находятся скачанные с хабра статьи -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 для английских синонимов слов больше, в третьих — меньше генерируемых токенов в ответе нейросети при извлечении информации.
База после индексирования содержит нужные мне данные статей
Сначала я найду свои статьи, что попали в эту базу данных:
select id, title from habr where properties->'author'->>'alias' = 'igor_suhorukov'
Теперь, я поинтересуюсь что же проиндексировано по моей статье о создании конечных автоматов на SQL в PostgreSQL:
select id,idx, notes from habr_vectors where id=728196
И теперь хочу быстро найти 5 результатов поиска максимально близких по семантике фрагмента из всех статей, кроме моей текущей:
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/
Добавить комментарий