- BrainTools - https://www.braintools.ru -
Покажу вам практическую реализацию семантического поиска на основе векторных представлений – эмбеддингов из текста. Здесь я создам систему, которая анализирует статьи с Хабра, извлекает из них темы и ключевые слова с помощью локально работающих больших языковых моделей LLM, и на основе этих данных создает векторные представления для эффективного поиска по смыслу, а не по запросу на вхождение определенного текста.
Главное применение LLM это не только и не столько написание рефератов для школьников или писем на работе и получение быстрых ответов на ваши вопросы. Это в первую очередь удобная технология для структурирования и индексирования текстов и мультимедиа содержимого в интернет. Возможно, скоро случится то самое воплощение Семантической Паутины (Веба) которое не произошло из-за трудоемкости ручной разметки людьми данных в интернет.
Семантический поиск — это метод поиска информации, основанный на понимании смысла запроса и контента, а не просто на совпадении ключевых слов. Он использует векторные представления – embeddings из текста, созданные с помощью моделей машинного обучения [1], для того чтобы находить семантически похожие документы.
Традиционный поиск по ключевым словам имеет ряд ограничений:
Не учитывает контекст и смысл слов
Чувствителен к точности формулировки
Не распознает синонимы и связанные понятия
Семантический поиск решает эти проблемы, преобразуя тексты в многомерные векторы, где семантически близкие тексты располагаются рядом в векторном пространстве. Все это благодаря моделям машинного обучения, предобученным на больших корпусах текстов. Как пример, открытая модель nomic-embed-text.
Одна из основных идей моего подхода — создание векторных представлений не для исходного текста статей, а для извлеченных из них тем и ключевых слов. Это имеет ряд преимуществ:
Извлечение только главного — иногда исходные тексты статей содержат много информации, не относящейся к основной теме, рекламу итп.
Смысловое выравнивание — авторы статей субъективны в выборе тегов, 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);
Созданные векторные представления сохраняются в базу данных для последующего использования при поиске.
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 при вызове ChatClient.create(chatModel).prompt() … call().entity(Topics.class) делает автоматический вывод схемы из классов проекта. Ведь под капотом почти все LLM принимают на вход JSON Schema, для того чтобы выдавать ответ в структурированной форме.
Чтобы помочь нейросети с семантикой полей и классов, необходимо только добавить аннотацию @JsonPropertyDescription [2], которая превращается в поле 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, по которым теперь могу выполнять произвольные 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 для обработки оправдан как в хобби проектах, так и в корпоративных системах обработки данных на основе технологий машинного обучения/ Искуственного Интеллекта [3].
Такой подход позволяет реализовать поиск по смыслу, который во многом удобнее чем традиционный поиск по ключевым словам. Система может быть адаптирована для работы с другими источниками данных. Тут я показал принцип построения таких систем. А дальше сложность запросов к данным Хабра уже ограничена только воображением. Так как выразительные возможности SQL и расширяемость PostgreSQL позволяют написать почти любой запрос.
Автор: igor_suhorukov
Источник [4]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/15830
URLs in this post:
[1] обучения: http://www.braintools.ru/article/5125
[2] @JsonPropertyDescription: https://www.braintools.ru/users/JsonPropertyDescription
[3] Интеллекта: http://www.braintools.ru/article/7605
[4] Источник: https://habr.com/ru/articles/915348/?utm_source=habrahabr&utm_medium=rss&utm_campaign=915348
Нажмите здесь для печати.