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

О чём эта статья?
Ответим на следующие вопросы:
Что такое векторные базы данных и почему они незаменимы для приложений с ИИ.
Что такое embeddings и почему без них RAG-системы теряют свою силу.
Как реализовать сервис для хранения и поиска знаний по смыслу с помощью Spring Boot, Spring AI и Qdrant.
Как реализовать сервис для подкреплённой генерации ответов LLM с помощью Spring AI.
Способы улучшить RAG: как повысить точность и полезность ответов.
Как собрать всё вместе в работающую систему, о которой можно честно написать, например, в резюме.
Проблема и решение: зачем нужен RAG?
Представьте: у вас есть LLM (большая языковая модель), которая умеет отлично генерировать текст, но без контекста не знает, что именно хочет пользователь. Допустим, сотрудник спрашивает: «Как исправить ошибку X в системе Y?».
Если у модели нет доступа к внутренним инструкциям, тикетам и документации, она либо выдумает ответ (галлюцинация), либо честно признается: «Не знаю». RAG решает эту проблему, добавляя модели нужный контекст.

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

Когда использовать 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, ...]
Эти векторы близки по расстоянию, так как их смысл похож. Расстояние между векторами измеряется метриками, например, такими как:
Косинусное сходство:
, где A и B — векторы. Значение от -1 (противоположный смысл) до 1 (идентичный смысл).
Евклидово расстояние:
, где меньшее расстояние означает большую схожесть.
Как создаются эмбеддинги? Модель, например BERT, обучается на огромном массиве текстов, чтобы улавливать контекст и смысл слов. Текст сначала разбивается на токены — слова или их части. Эти токены проходят через слои трансформера, и на выходе получается векторное представление.
Зачем нужны векторные базы данных?
Векторные базы данных хранят специальные числовые представления объектов — эмбеддинги. Их можно представить как точки на карте: близкие точки означают схожий смысл, а далёкие друг от друга — разный. Благодаря этому поиск работает не по буквальному совпадению слов, а по смыслу. Эмбеддинги создаются один раз и сохраняются, поэтому нужный фрагмент находится быстро и без лишних вычислений. Это делает векторные БД удобным инструментом для работы с LLM и большими массивами данных.

Основные составляющие конечной JSON-подобной структуры в векторной БД:
ID — уникальный идентификатор объекта.
Vector (Embedding) — сам вектор, который хранится в специальной структуре для быстрого поиска ближайших соседей.
Payload (Metadata) — дополнительные данные в формате ключ-значение (часто JSON): путь к файлу, тип документа, автор, дата и т.д.
Оригинальный текст — сам фрагмент или ссылка на внешний источник.
Про безопасность: для обеспечения безопасности документов в векторных хранилищах, в первую очередь, следует использовать встроенные механизмы аутентификации, а также управление API-ключами. Кроме того, для разграничения доступа можно присваивать каждому документу метку access_level
или access_role
, определяющую, кто имеет право на просмотр, либо кластеризировать данные по уровням доступа.

Как работает поиск: данные переводятся в векторы и индексируются. Запрос пользователя также конвертируется в вектор и база ищет самые близкие к нему по смыслу. Алгоритмы вроде 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 и проверяем данные:

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

Поиск похожих по семантике данных
Создали эндпоинт /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
. Эти показатели можно использовать для ранжирования результатов.

Минимальное значение косинусного сходства не всегда значит лучший результат. Поэтому мы увеличиваем выборку — берём 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
Процесс включает четыре шага:
Предобработка: приводим вопрос в удобный для анализа вид — нормализуем текст, исправляем ошибки, убираем лишние символы, уточняем формулировки. Также создаём
n
альтернативных вариантов вопроса для дальнейшей работы.Трансформация и поиск: преобразуем запросы в векторы и ищем
k
наиболее близких документов. В результате имеемn × k
кандидатов.Ранжирование: оцениваем найденные документы по релевантности, удаляем дубликаты, формируем упорядоченный список.
Подкреплённая генерация: передаём отобранный контекст в LLM, которая на основе него составляет ответ для пользователя.

Проще всего попросить 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-составляющей:

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

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

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

Предобработка и очистка пользовательского вопроса
Первый шаг — нормализовать вопрос: убрать «шум», исправить опечатки, провести лемматизацию. Это даёт более точный запрос и повышает релевантность поиска.
Часто применяют подход RAG Fusion: просим LLM предложить несколько вариантов очищенного запроса и по каждому выполняем семантический поиск. В результате получаем выборку размером [число предобработанных запросов] × [число релевантных документов]
, которую можно ранжировать и объединять в общий контекст.
В больших системах на этапе предобработки также классифицируют запросы: например, как просьбу, вопрос или жалобу, либо по отделам (бухгалтерия, HR). В нашей Java-реализации такую метаинформацию мы сохраняли в ключе tags
.
Ансамбли
Помимо ансамблирования разных формулировок запроса, используют и ансамбли из нескольких LLM или нескольких сервисов-ретриверов. В нашем простом примере была связка «трансформер — вектор — векторное хранилище» (Dense Retriever). Также можно подключать и традиционные методы поиска (Sparse Retriever), где релевантность оценивается по частоте вхождений терминов в документ.

Дополнительный шаг поиска — динамическое обучение
Прежде чем выдавать клиенту финальный ответ, можно сгенерировать несколько черновых вариантов на основе ранжированных документов и использовать их как подсказки для LLM. Это позволяет модели опираться на конкретные примеры и давать более релевантный ответ. На этом этапе полезно применять zero-shot, few-shot и похожие методики.
Оценка результата
Оценка RAG — задача непростая: важно считать, сколько из извлечённых документов действительно релевантные, и насколько ответ опирается на них.
Для извлечения precision и recall
. Для генерации — faithfulness/groundedness
, совпадение с эталоном или оценка результата другой LLM-моделью.
Кроме того, смотрят порог уверенности по logits
— распределение вероятностей для токенов в сгенерированном тексте. Это помогает понять, насколько модель уверена в ответе, и при низкой уверенности подставлять честное сообщение: «На данный вопрос не найден ответ».
Заключение
Мы разобрали, как собрать простую RAG-систему на Java и Spring AI: от теории до кода, от нормализации запросов и поиска векторных представлений до ансамблирования и повышения качества ответов. Теперь у вас есть рабочие примеры и понимание принципов, которые можно расширять под свои задачи.

Надеюсь, статья оказалась полезной и вдохновляющей. Если у вас есть вопросы, идеи или замечания — делитесь ими в комментариях. Буду рад конструктивной критике: вместе мы сделаем наш путь ещё интереснее и полезнее.
Присоединяйтесь к моему Telegram-каналу, чтобы не пропустить следующие материалы.
До встречи в новой статье!
© 2025 ООО «МТ ФИНАНС»
Комментарии (4)
federini123
28.08.2025 16:14Самое интересное впереди начинается на прикладном уровне оценки и оптимизации работы. Для погружения статья отличная, покрывает всю просветительскую базовую базу
vitminc
28.08.2025 16:14С моей точки зрения, именно такими и должны быть статьи на тему LLM. Техническая основа, ясные принципы и обоснованное применение.
Статья заинтересовала с практической точки зрения. Есть огромная база уже существующего кода, отчасти легаси, отчасти нового. На поддержку уходит много времени. Для себя написал плагин для Intellij, который умеет работать с различными AI платформами (Ollama, Antropic, OpenAI). Т.к. кодовая база закрыта, то публичными моделями пользоваться нельзя и используются локальные модели (или внутренний Mistral, который порой хуже открытых моделей). Плагин умеет автоматически вытаскивать зависимости, подмешивать релевантные данные и добавлять все это в контекст, что помогает. Однако длины контекста для более точного анализа не хватает.
Вот здесь похоже и может помочь метод приведенный в статье. Если перевести кодовую базу в эмбеддинг формат и подмешивать релевантные данные в контекст, то предположительно ответ должен качественно улучшится. По сути это микс поисковой системы и LLM анализа. Сюда можно добавить и техническую документацию и даже переписку по интересующим темам.
Автор задал направление для движения, за что ему спасибо. Интересно было бы взглянуть на реализацию или код, который судя по содержанию статьи, несомненно, существует.
ekzes
Очень интересно, здорово, что на пальцах объяснена математика векторного поиска по тексту. И здорово, когда у компании файлы базы знаний находятся в txt формате или хотя бы адекватно сделан pdf. Правда куча геморроя начинается, когда начинаешь использовать OCR для документов, особенно когда в ПДФках есть таблицы :) Так что если автор решит проблему адекватной векторизации ПДФ, буду раз почитать)