RAG и векторные БД: НЕ Сизифов LLM на Java и Spring Ai

от автора

Привет! Меня зовут Бромбин Андрей, и сегодня я разберу на практике, что такое RAG-системы и как они помогают улучшать поиск. Покажу, как использовать Spring AI, векторные базы данных и LLM. Ты получишь теорию и пример реализации на Java и Spring Boot — от идеи до работающего сервиса. Без сложных формул — только чёткие объяснения и код.

О чём эта статья?

Ответим на следующие вопросы:

  • Что такое векторные базы данных и почему они незаменимы для приложений с ИИ.

  • Что такое embeddings и почему без них RAG-системы теряют свою силу.

  • Как реализовать сервис для хранения и поиска знаний по смыслу с помощью Spring Boot, Spring AI и Qdrant.

  • Как реализовать сервис для подкреплённой генерации ответов LLM с помощью Spring AI.

  • Способы улучшить RAG: как повысить точность и полезность ответов.

  • Как собрать всё вместе в работающую систему, о которой можно честно написать, например, в резюме.

Проблема и решение: зачем нужен RAG?

Представьте: у вас есть LLM (большая языковая модель), которая умеет отлично генерировать текст, но без контекста не знает, что именно хочет пользователь. Допустим, сотрудник спрашивает: «Как исправить ошибку X в системе Y?».

Если у модели нет доступа к внутренним инструкциям, тикетам и документации, она либо выдумает ответ (галлюцинация), либо честно признается: «Не знаю». RAG решает эту проблему, добавляя модели нужный контекст.

Он ничего не стёр, он просто блефует — он вообще не в курсе про наш payment service

Он ничего не стёр, он просто блефует — он вообще не в курсе про наш payment service

RAG решает эту проблему, комбинируя два этапа:

  1. Retrieval (поиск): Находит релевантные данные в базе знаний с помощью векторного поиска. Это быстрее и точнее, чем поиск по ключевым словам, так как учитывает семантику.

  2. Augmented Generation (подкреплённая генерация): Передаёт найденные данные LLM, чтобы она сгенерировала точный и полезный ответ.

Первый ключ к успеху RAG — это эмбеддинги и векторные базы данных. Давайте разберём, как это работает, пошагово внедрив её в систему инцидентов. Зелёным цветом я выделил Retrieval блок, который будет отвечать за поиск данных, а синим — блок Agumented Generation для подкреплённой генерации ответа.

Структурная схема интеграции RAG-системы

Структурная схема интеграции RAG-системы

Когда использовать RAG?

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

Основные понятия

Что такое embeddings?

Эмбеддинги — это числовые векторы, которые описывают смысл текста. Например:

«Поезд прибыл на станцию» = [0.27, -0.41, 0.88, ...]
«Поезд подъехал к платформе» = [0.26, -0.40, 0.87, ...]

«База данных легла под нагрузкой» = [-0.11, 0.17, -0.56, ...]
«БД ушла в закат на пике» = [-0.08, 0.16, -0.51, ...]

Эти векторы близки по расстоянию, так как их смысл похож. Расстояние между векторами измеряется метриками, например, такими как:

  • Косинусное сходство: cos(θ) = (A·B)/(|A|·|B|), где A и B — векторы. Значение от -1 (противоположный смысл) до 1 (идентичный смысл).

  • Евклидово расстояние: √Σ(Ai - Bi)², где меньшее расстояние означает большую схожесть.

Как создаются эмбеддинги? Модель, например BERT, обучается на огромном массиве текстов, чтобы улавливать контекст и смысл слов. Текст сначала разбивается на токены — слова или их части. Эти токены проходят через слои трансформера, и на выходе получается векторное представление.

Зачем нужны векторные базы данных?

Векторные базы данных хранят специальные числовые представления объектов — эмбеддинги. Их можно представить как точки на карте: близкие точки означают схожий смысл, а далёкие друг от друга — разный. Благодаря этому поиск работает не по буквальному совпадению слов, а по смыслу. Эмбеддинги создаются один раз и сохраняются, поэтому нужный фрагмент находится быстро и без лишних вычислений. Это делает векторные БД удобным инструментом для работы с LLM и большими массивами данных.

Векторное пространство IT-компетенций и их расположение по семантике

Векторное пространство IT-компетенций и их расположение по семантике

Основные составляющие конечной JSON-подобной структуры в векторной БД:

  • ID — уникальный идентификатор объекта.

  • Vector (Embedding) — сам вектор, который хранится в специальной структуре для быстрого поиска ближайших соседей.

  • Payload (Metadata) — дополнительные данные в формате ключ-значение (часто JSON): путь к файлу, тип документа, автор, дата и т.д.

  • Оригинальный текст — сам фрагмент или ссылка на внешний источник.

Про безопасность: для обеспечения безопасности документов в векторных хранилищах, в первую очередь, следует использовать встроенные механизмы аутентификации, а также управление API-ключами. Кроме того, для разграничения доступа можно присваивать каждому документу метку access_level или access_role, определяющую, кто имеет право на просмотр, либо кластеризировать данные по уровням доступа.

Порядок обработки документа и конечная JSON-подобная структура

Порядок обработки документа и конечная JSON-подобная структура

Как работает поиск: данные переводятся в векторы и индексируются. Запрос пользователя также конвертируется в вектор и база ищет самые близкие к нему по смыслу. Алгоритмы вроде HNSW (обход многоуровневого графа) или IVF (обход векторных кластеров) делают это очень быстро — даже при миллионах записей.

Реализация на Java и Spring Ai

Для работы с RAG на Java и Spring AI нужны несколько библиотек. В проект необходимо добавить следующие зависимости:

pom.xml
<dependencies> <dependency> <groupId>io.grpc</groupId> <artifactId>grpc-services</artifactId> </dependency> <dependency> <groupId>org.springframework.grpc</groupId> <artifactId>spring-grpc-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-vector-store-qdrant</artifactId> </dependency>         // остальные базовые: lombok, spring-boot и тд </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.grpc</groupId> <artifactId>spring-grpc-dependencies</artifactId> <version>${spring-grpc.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

Превращаем текст в вектор

Spring AI предоставляет готовые реализации для интеграции с популярными моделями эмбеддингов через интерфейс EmbeddingModel. Среди них: OpenAiEmbeddingModel, OllamaEmbeddingModel, PostgresMlEmbeddingModel и другие из пакетов spring-ai-starter-model-<modelName>. Это избавляет от ручной реализации методов call() или embed(), предлагая единый API для генерации векторов.

Для русского языка мы в каждый момент времени ориентируемся на актуальные бенчмарки. Сейчас хороший выбор — модель ru-en-RoSBERTa на Hugging Face: она обучена на русском и английском и подходит для двуязычных задач.

Конфиг клиента для модели RoSBERTa
@Configuration @FieldDefaults(level = AccessLevel.PRIVATE) public class RosbertaClientConfig {      @Value("${huggingface.token}")     String hfToken;      @Value("${huggingface.rosberta.url}")     String rosbertaUrl;      @Bean     public RestClient ruEnHuggingFaceRestClient() {         if (hfToken == null || hfToken.isBlank()) {             throw new IllegalStateException("huggingface token is not set");         }                  return RestClient.builder()                 .baseUrl(rosbertaUrl)                 .defaultHeader("Authorization", "Bearer " + hfToken)                 .build();     } }

Класс RosbertaEmbeddingModel расширяет абстрактный класс и переопределяет его методы для работы с моделью. В call формируется запрос к API Hugging Face: текст запроса дополняется префиксом "search_query", упаковывается в payload и отправляется POST-запросом через RestClient. В ответ приходит массив чисел (эмбеддинг). Метод embed делегирует работу методу call, передавая текст из документа. При необходимости модель можно развернуть локально, а не использовать облачный API.

Класс RosbertaEmbeddingModel
@Service @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class RosbertaEmbeddingModel extends AbstractEmbeddingModel {     RestClient restClient;      @Override     public @NotNull EmbeddingResponse call(@NotNull EmbeddingRequest request) {         var payload = Map.of(             "inputs", "search_query: " + request.getInstructions().get(0),             "parameters",              Map.of("pooling_method", "cls", "normalize_embeddings", true)         );          List<Double> responseList = restClient.post()                 .contentType(MediaType.APPLICATION_JSON)                 .body(payload)                 .retrieve()                 .body(new ParameterizedTypeReference<>() {});          float[] floats = convertDoubleListToFloatArray(responseList);          return new EmbeddingResponse(List.of(new Embedding(floats, 0)));     }      @Override     public @NotNull float[] embed(@NotNull Document document) {         return call(new EmbeddingRequest(List.of(document.getFormattedContent()), null))                 .getResults().getFirst().getOutput();     } }

Теперь можно проверить результат через Postman, отправив запрос к нашему API:

Пример: текст и его числовой вектор

Пример: текст и его числовой вектор

Сохранение в векторное хранилище

Поднимем Qdrant локально с помощью docker-compose.yml:

docker-compose.yml
services:   qdrant:     image: qdrant/qdrant:latest     restart: always     container_name: qdrant     ports:       - 6333:6333       - 6334:6334     expose:       - 6333       - 6334       - 6335     configs:       - source: qdrant_config         target: /qdrant/config/production.yaml     volumes:       - ./qdrant_data:/qdrant/storage  configs:   qdrant_config:     content: |       log_level: INFO

Ранее мы подключили пакеты Spring AI и gRPC — его использует Qdrant для общения с клиентом. Для конфигурации есть два варианта: задать её вручную или использовать автоконфигурацию. Spring умеет автоматически настраивать QdrantVectorStore, если в application.yml указать параметры вида:»

spring:   ai:     vectorstore:       qdrant:         host: <qdrant host>         port: <qdrant grpc port>         api-key: <qdrant api key>         collection-name: <collection name>         use-tls: false         initialize-schema: true

Ручная настройка несложна и пригодится нам, так как мы используем не стандартную реализацию пакетного EmbeddingModel, а собственную — RosbertaEmbeddingModel, которую мы указываем в @Bean QdrantVectorStore.

class QdrantConfig
@Slf4j @Configuration @FieldDefaults(level = AccessLevel.PRIVATE) public class QdrantConfig {      @Value("${qdrant.host:localhost}")     String qdrantHost;      @Value("${qdrant.port:6334}")     int qdrantPort;      @Value("${qdrant.collection-name:incidents}")     String collectionName;      @Value("${qdrant.api-key:}")     String apiKey;      @Bean     @Primary     public QdrantClient qdrantClient() {         QdrantGrpcClient.Builder builder = QdrantGrpcClient                   .newBuilder(qdrantHost, qdrantPort, false);         if (apiKey != null && !apiKey.trim().isEmpty()) {             builder.withApiKey(apiKey);         }         return new QdrantClient(builder.build());     }      @Bean     @Primary     public QdrantVectorStore qdrantVectorStore(QdrantClient qdrantClient,                                                RosbertaEmbeddingModel rosbertaEmbeddingModel) {         QdrantVectorStore voStore = QdrantVectorStore                 .builder(qdrantClient, rosbertaEmbeddingModel)                 .collectionName(collectionName)                 .initializeSchema(true)                 .build();          return voStore;     } }

На завершающем этапе мы формируем документ с метаданными и сохраняем его. При сохранении текст преобразуется в embedding с помощью указанной EmbeddingModel в конфигурации.

Класс сохранения документа в векторное хранилище Qdrant
@Slf4j @Service @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class IncidentEmbeddingService {      QdrantVectorStore qdrantVectorStore;      public void storeIncident(String text, List<String> tags) {         Map<String, Object> metadata = new HashMap<>();         metadata.put("tags", tags);         metadata.put("timestamp", System.currentTimeMillis());          Document document = new Document(text, metadata);         qdrantVectorStore.doAdd(List.of(document));     } }

После вызова метода открываем Qdrant Web UI и проверяем данные:

Сохранённый Point и его данные в Qdrant

Сохранённый Point и его данные в Qdrant

Обогатим хранилище данными тем же способом. Чтобы граф был понятнее, оставим самые важные связи, построив остовое дерево.

Минимальное/максимальное остовое дерево из векторов в Qdrant

Минимальное/максимальное остовое дерево из векторов в Qdrant

Поиск похожих по семантике данных

Создали эндпоинт /incidents/similar, который принимает текстовый запрос и возвращает список документов из векторного хранилища. Параметр limit задаёт максимальное количество результатов.

Эндпоинт для поиска семантически похожих документов
@PostMapping(path = "/incidents/similar", produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<List<Document>> getSimilarIncidents(         @RequestBody String query,         @RequestParam(defaultValue = "3") int limit) {     try {         List<Document> responseDocuments = incidentEmbeddingService                                         .searchSimilarIncidents(query, limit);         return ResponseEntity.ok(responseDocuments);     } catch (Exception e) {         return ResponseEntity.internalServerError().build();     } }

Метод searchSimilarIncidents собирает запрос (SearchRequest) с текстом и topK. Система превращает текст в эмбеддинг и сравнивает его с векторами в базе. Внутри Qdrant для оценки близости используется косинусное расстояние — угол между векторами.

Метод поиска семантически похожих документов в Qdrant
public List<Document> searchSimilarIncidents(String query, Integer limit) {     SearchRequest searchRequest = SearchRequest.builder()             .query(query)             .topK(limit)             //.filterExtension("key == 'value'")             //.similarityThreshold(0.6)             .build();     return qdrantVectorStore.similaritySearch(searchRequest); }

При запросе к API мы получаем k документов, наиболее близких по смыслу. Для каждого документа доступны метрики: distance — косинусное расстояние до запроса (чем меньше, тем ближе по смыслу) иscore = 1 - distance. Эти показатели можно использовать для ранжирования результатов.

Ответ API: список релевантных документов

Ответ API: список релевантных документов

Минимальное значение косинусного сходства не всегда значит лучший результат. Поэтому мы увеличиваем выборку — берём k ближайших соседей и улучшаем поиск с помощью RAG Fusion. О нём подробнее будет в разделе про повышение качества поиска.

Подкреплённая генерация ответа

Spring AI поддерживает работу с разными LLM, например, OpenAI, Gemini, LLaMA, Amazon AI и другие. Вместо того чтобы писать специфичный код для каждой модели, можно использовать интерфейсы ChatModel и ChatClient и переключать модели через конфигурацию. Это упрощает RAG-сценарии и работу с несколькими поставщиками одновременно. Из российских LLM-моделей не без труда подключаются GigaChat и YandexGPT.

Конфигурация

Для работы с Open AI достаточно добавить соответствующую зависимость в pom.xml:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency>

Далее нужно указать ключевые параметры: модель, температуру генерации, лимит токенов в ответе, API-ключ и URL для вызовов.

spring:   ai:     openai:       api-key: sk-<OPEN_AI_API_KEY>       base-url: <OPEN_AI_API_URL>       chat:         completions-path: /v1/chat/completions         options:           model: gpt-5           temperature: 1           max-completion-tokens: 1000

Параметр temperature в LLM (в том числе в OpenAI) управляет степенью «случайности» или креативности генерации текста. При temperature = 0 модель работает детерминировано и выбирает самый вероятный вариант. При temperature > 1 генерация становится более свободной — появляются нестандартные, иногда неожиданные ответы.

Не все модели поддерживают параметр temperature, например, gpt-5 вернул мне
Unsupported value. Only the default (1) value is supported.

Завершающий этап — создание бинаChatClient, который будет использоваться для работы с LLM.

@Configuration public class ChatClientConfig {     @Bean     public ChatClient chatClient(ChatClient.Builder builder) {         return builder                 .defaultAdvisors(                         new SimpleLoggerAdvisor()                 )                 .build();     } }

Полный цикл: от обработки запроса до ответа LLM

Процесс включает четыре шага:

  1. Предобработка: приводим вопрос в удобный для анализа вид — нормализуем текст, исправляем ошибки, убираем лишние символы, уточняем формулировки. Также создаём n альтернативных вариантов вопроса для дальнейшей работы.

  2. Трансформация и поиск: преобразуем запросы в векторы и ищем k наиболее близких документов. В результате имеем n × k кандидатов.

  3. Ранжирование: оцениваем найденные документы по релевантности, удаляем дубликаты, формируем упорядоченный список.

  4. Подкреплённая генерация: передаём отобранный контекст в LLM, которая на основе него составляет ответ для пользователя.

Базовый цикл работы RAG-системы

Базовый цикл работы RAG-системы

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

Системный промпт и метод предобработки запроса
private static final String PREPROCESSED_SYSTEM_PROMPT =      "Ты специалист по семантической оптимизации пользовательских вопросов для поиска по эмбеддингам. "   + "Преобразуй входной ВОПРОС по правилам:\n"   + "1) Нормализация:\n"   + "   - Удали спецсимволы (оставь только !?.,)\n"   + "   - Убери лишние пробелы и переносы строк\n"   + "   - Приведи все кавычки к виду \\\"\\\"\n"   + "   - Расшифруй сокращения: «н-р» → «например», «т.д.» → «и так далее»\n"   + "2) Семантическое уплотнение:\n"   + "   - Сохрани ключевые термины, числа, имена собственные без изменений\n"   + "   - Удали стоп-слова («очень», «просто», «ну») и вводные фразы («кстати», «в общем»)\n"   + "   - Устрани повторы, сделай формулировку точной и ёмкой\n"   + "   - Заменяй местоимения на конкретные референсы (напр. «он» → «алгоритм авторизации»)\n"   + "3) Контекстуализация:\n"   + "   - Добавь недостающие уточнения в [квадратных скобках], если это повышает однозначность\n"   + "   - Делай вопрос самодостаточным: «Как он работает?» → «Как работает алгоритм авторизации?»\n"   + "Формат вывода:\n"   + "   - Сначала выведи ТОЛЬКО итоговый очищенный и уточнённый вопрос (без комментариев)\n"   + "   - Если вопрос состоит из нескольких смысловых частей, раздели их пустой строкой\n"   + "   - Затем выведи 3 альтернативные формулировки, сохраняя смысл:\n"   + "Вывод ТОЛЬКО в JSON с полями:\n"   + "{\\\"normalized\\\": \\\"<строка>\\\",\\\"alternatives\\\": [\\\"<строка>\\\",\\\"<строка>\\\",\\\"<строка>"]}"   + "Без пояснений и текста вне JSON";  public PreprocessedQuestion preprocessQuestion(String question) {     String raw = chatClient.prompt(new Prompt(         List.of(new SystemMessage(PREPROCESSED_SYSTEM_PROMPT),                 new UserMessage(question))     )).call().content();      // Парсим ответ LLM на основной вопрос и варианты     return extractVariants(raw); }

Для получения семантически похожих документов для каждого из вариантов запросов достаточно вызвать раннее реализованный метод — searchSimilarDocuments(variant, topK). Далее необходимо ранжировать полученные документы. Простой способ — убрать дубликаты и отсортировать по убыванию score.

Метод ранжирования
private List<Document> rankDocuments(List<Document> documents) {     Map<String, Document> uniqueDocs = documents.stream()             .collect(Collectors.toMap(                 Document::getId,                 d -> d,                 (d1, d2) -> d1.getScore() >= d2.getScore() ? d1 : d2             ));      List<Document> ranked = uniqueDocs.values().stream()             .sorted(Comparator.comparingDouble(Document::getScore).reversed())             .toList();      return ranked; }

Финальный этап — передать вопрос и контекст в LLM, взамен получив ответ.

Системный промпт и метод подкреплённой генерации
String AG_SYSTEM_PROMPT = """     Ты эксперт по написанию лаконичных и понятных ответов для пользователей.     На вход получаешь:     1) Вопрос пользователя.     2) Контекст, состоящий из релевантных документов.          Задача:     - Сформулировать ответ на вопрос, опираясь на предоставленный контекст.     - Если информации недостаточно — честно сообщи об этом.     - Ответ должен быть ясным, структурированным и по возможности кратким.     - Не добавляй лишние комментарии. """;  public String generateAnswerFromContext(String userQuestion, String context) {     SystemMessage systemMessage = new SystemMessage(AG_SYSTEM_PROMPT);     String userContent = "Вопрос пользователя: " + userQuestion                                + "\n\nКонтекст документов:\n" + context;      UserMessage userMessage = new UserMessage(userContent);     Prompt prompt = new Prompt(List.of(systemMessage, userMessage));      String response = chatClient.prompt(prompt).call().content();      return response.strip(); }

Результаты

Сделаем несколько запросов и посмотрим, как ведёт себя система. Для начала — вопрос к LLM без RAG-составляющей:

Без RAG LLM не даёт полезного ответа на вопрос

Без RAG LLM не даёт полезного ответа на вопрос

Теперь сделаем запрос на api реализованной RAG-системы:

Прямого ответа в базе нет, но модель вернула контакты ответственных — честный, полезный результат

Прямого ответа в базе нет, но модель вернула контакты ответственных — честный, полезный результат

Посмотрим, как ведёт себя модель, когда у неё есть весь нужный контекст:

Отличный вопрос, отличный ответ — всё, как и должно быть

Отличный вопрос, отличный ответ — всё, как и должно быть

Способы повышения качества поиска

Часть способов мы уже применили, а теперь посмотрим на весь набор детальнее.

Даже в безжизненной пустыне можно найти ответы. Достаточно лишь использовать простой советский...

Даже в безжизненной пустыне можно найти ответы. Достаточно лишь использовать простой советский…

Предобработка и очистка пользовательского вопроса

Первый шаг — нормализовать вопрос: убрать «шум», исправить опечатки, провести лемматизацию. Это даёт более точный запрос и повышает релевантность поиска.

Часто применяют подход RAG Fusion: просим LLM предложить несколько вариантов очищенного запроса и по каждому выполняем семантический поиск. В результате получаем выборку размером [число предобработанных запросов] × [число релевантных документов], которую можно ранжировать и объединять в общий контекст.

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

Ансамбли

Помимо ансамблирования разных формулировок запроса, используют и ансамбли из нескольких LLM или нескольких сервисов-ретриверов. В нашем простом примере была связка «трансформер — вектор — векторное хранилище» (Dense Retriever). Также можно подключать и традиционные методы поиска (Sparse Retriever), где релевантность оценивается по частоте вхождений терминов в документ.

RAG с ансамблем бурлаков: несколько моделей тянут поиск к точности

RAG с ансамблем бурлаков: несколько моделей тянут поиск к точности

Дополнительный шаг поиска — динамическое обучение

Прежде чем выдавать клиенту финальный ответ, можно сгенерировать несколько черновых вариантов на основе ранжированных документов и использовать их как подсказки для LLM. Это позволяет модели опираться на конкретные примеры и давать более релевантный ответ. На этом этапе полезно применять zero-shot, few-shot и похожие методики.

Оценка результата

Оценка RAG — задача непростая: важно считать, сколько из извлечённых документов действительно релевантные, и насколько ответ опирается на них.
Для извлечения precision и recall. Для генерации — faithfulness/groundedness, совпадение с эталоном или оценка результата другой LLM-моделью.

Кроме того, смотрят порог уверенности по logits — распределение вероятностей для токенов в сгенерированном тексте. Это помогает понять, насколько модель уверена в ответе, и при низкой уверенности подставлять честное сообщение: «На данный вопрос не найден ответ».

Заключение

Мы разобрали, как собрать простую RAG-систему на Java и Spring AI: от теории до кода, от нормализации запросов и поиска векторных представлений до ансамблирования и повышения качества ответов. Теперь у вас есть рабочие примеры и понимание принципов, которые можно расширять под свои задачи.

Он больше не бурлак, не рыбак, не Сизиф, а путник, оставивший пустыню позади

Он больше не бурлак, не рыбак, не Сизиф, а путник, оставивший пустыню позади

Надеюсь, статья оказалась полезной и вдохновляющей. Если у вас есть вопросы, идеи или замечания — делитесь ими в комментариях. Буду рад конструктивной критике: вместе мы сделаем наш путь ещё интереснее и полезнее.

Присоединяйтесь к моему Telegram-каналу, чтобы не пропустить следующие материалы.
До встречи в новой статье!

© 2025 ООО «МТ ФИНАНС»


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


Комментарии

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

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