В продолжении статьи автор углубляется в возможности генерации с поддержкой поиска (RAG), чтобы LLM могла учесть некоторую контекстуальную информацию данных, первоначально невходившую в ее обучающую выборку.

В новом переводе от команды Spring АйО рассматривается польза Spring AI и векторной БД, благодаря которым система не просто сопоставляет ключевые слова, но и понимает смысловые запросы пользователей, делая взаимодействие с приложением еще более естественным.


Обзор первой части

В первой части этой серии статей мы изучили основы интеграции Spring AI с крупными языковыми моделями. Мы рассмотрели создание кастомного ChatClient, использование Function Calling для динамических взаимодействий и доработку промптов, чтобы адаптировать их под потребности проекта Spring Petclinic. К концу первой части у нас был функциональный ИИ-ассистент, способный понимать и обрабатывать запросы, связанные с ветеринарной клиникой.

Теперь, во второй части, мы пойдем дальше и рассмотрим метод генерации с поддержкой RAG (Retrieval-Augmented Generation) — технику, которая позволяет работать с большими наборами данных, не подходящими для типичного подхода Function Calling. Давайте посмотрим, как RAG может бесшовно интегрировать ИИ со знаниями, специфичными для домена.

Retrieval-Augmented Generation (RAG)

Хотя вывод списка ветеринаров мог бы быть реализован стандартным способом, я решил использовать этот пример, чтобы продемонстрировать возможности генерации с поддержкой поиска (RAG).

RAG объединяет крупные языковые модели с реальным извлечением данных, что позволяет создавать более точные и контекстно значимые тексты. Хотя эта концепция связана с нашей предыдущей работой, RAG обычно акцентирует внимание на извлечении данных из векторной БД.

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

Например, рассмотрим следующих ветеринаров и их специализации:

  • Д-р Алиса Браун — Кардиология

  • Д-р Боб Смит — Стоматология

  • Д-р Кэрол Уайт — Дерматология

При обычном поиске запрос «Чистка зубов» не даст точного совпадения. Однако, с семантическим поиском, основанным на эмбеддингах, система поймет, что «Чистка зубов» относится к «Стоматологии». В результате д‑р Боб Смит будет выведен как лучший результат, даже если в запросе прямо не упоминается его специализация. Это показывает, как эмбеддинги улавливают смысл, не ограничиваясь точным совпадением по ключевым словам. Хотя реализация этого процесса выходит за рамки статьи, вы можете узнать больше, посмотрев это видео на YouTube.

Забавный факт — этот пример был сгенерирован самим ChatGPT.

По сути, поиск по сходству работает путем нахождения ближайших числовых значений запроса по отношению к данным в источнике. Возвращается самое близкое совпадение. Процесс преобразования текста в числовые эмбеддинги также обрабатывается языковой моделью (LLM).

Генерация тестовых данных

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

В данном примере ветеринары могут иметь ноль, одну или две специализации, аналогично оригинальным примерам из Spring Petclinic. Чтобы избежать утомительной задачи создания всех этих тестовых данных вручную, я обратился за помощью к ChatGPT. Он сгенерировал объединенный запрос, который создает 250 ветеринаров и назначает специализации 80% из них:

-- Создание списка имен и фамилий
WITH first_names AS (
    SELECT 'James' AS name UNION ALL
    SELECT 'Mary' UNION ALL
    SELECT 'John' UNION ALL
    ...
),
last_names AS (
    SELECT 'Smith' AS name UNION ALL
    SELECT 'Johnson' UNION ALL
    SELECT 'Williams' UNION ALL
    ...
),
random_names AS (
    SELECT
        first_names.name AS first_name,
        last_names.name AS last_name
    FROM
        first_names
    CROSS JOIN
        last_names
    ORDER BY
        RAND()
    LIMIT 250
)
INSERT INTO vets (first_name, last_name)
SELECT first_name, last_name FROM random_names;

-- Добавление специализаций для 80% ветеринаров
WITH vet_ids AS (
    SELECT id
    FROM vets
    ORDER BY RAND()
    LIMIT 200  -- 80% of 250
),
specialties AS (
    SELECT id
    FROM specialties
),
random_specialties AS (
    SELECT 
        vet_ids.id AS vet_id,
        specialties.id AS specialty_id
    FROM 
        vet_ids
    CROSS JOIN 
        specialties
    ORDER BY 
        RAND()
    LIMIT 300 -- В среднем 2 специализации на одного ветеринара

)
INSERT INTO vet_specialties (vet_id, specialty_id)
SELECT 
    vet_id,
    specialty_id
FROM (
    SELECT 
        vet_id,
        specialty_id,
        ROW_NUMBER() OVER (PARTITION BY vet_id ORDER BY RAND()) AS rn
    FROM 
        random_specialties
) tmp
WHERE 
    rn <= 2;  -- Назначить не более 2 специализаций на одного ветеринара

-- Оставшиеся 20% ветеринаров не будут иметь специализаций, поэтому дополнительные команды вставки не требуются

Чтобы гарантировать, что мои данные остаются статичными и последовательными при каждом запуске, я экспортировал соответствующие таблицы из базы данных H2 в виде жестко заданных команд вставки (INSERT). Эти команды были добавлены в файл data.sql:

INSERT INTO vets VALUES (default, 'James', 'Carter');
INSERT INTO vets VALUES (default, 'Helen', 'Leary');
INSERT INTO vets VALUES (default, 'Linda', 'Douglas');
INSERT INTO vets VALUES (default, 'Rafael', 'Ortega');
INSERT INTO vets VALUES (default, 'Henry', 'Stevens');
INSERT INTO vets VALUES (default, 'Sharon', 'Jenkins');
INSERT INTO vets VALUES (default, 'Matthew', 'Alexander');
INSERT INTO vets VALUES (default, 'Alice', 'Anderson');
INSERT INTO vets VALUES (default, 'James', 'Rogers');
INSERT INTO vets VALUES (default, 'Lauren', 'Butler');
INSERT INTO vets VALUES (default, 'Cheryl', 'Rodriguez');
...
...
-- Всего 256 ветеринаров 

-- Сначала убедимся, что у нас есть 5 специализаций
INSERT INTO specialties (name) VALUES ('radiology');
INSERT INTO specialties (name) VALUES ('surgery');
INSERT INTO specialties (name) VALUES ('dentistry');
INSERT INTO specialties (name) VALUES ('cardiology');
INSERT INTO specialties (name) VALUES ('anesthesia');

INSERT INTO vet_specialties VALUES ('220', '2');
INSERT INTO vet_specialties VALUES ('131', '1');
INSERT INTO vet_specialties VALUES ('58', '3');
INSERT INTO vet_specialties VALUES ('43', '4');
INSERT INTO vet_specialties VALUES ('110', '3');
INSERT INTO vet_specialties VALUES ('63', '5');
INSERT INTO vet_specialties VALUES ('206', '4');
INSERT INTO vet_specialties VALUES ('29', '3');
INSERT INTO vet_specialties VALUES ('189', '3');
...
…

Внедрение тестовых данных

Для реализации векторной БД у нас есть несколько вариантов. Наиболее популярным выбором, вероятно, является Postgres с расширением pgVector. Greenplum — масштабируемая параллельная база данных на основе Postgres — также поддерживает pgVector. В справочной документации Spring AI перечислены поддерживаемые на данный момент векторные БД.

Для нашего простого случая я выбрал предоставленный Spring AI класс SimpleVectorStore. Этот класс реализует векторную БД с использованием простой Java ConcurrentHashMap, чего более чем достаточно для нашего небольшого набора данных из 256 ветеринаров. Конфигурация для этого хранилища, а также реализация памяти чата, определены в классе AIBeanConfiguration, аннотированном @Configuration:

@Configuration
@Profile("openai")
public class AIBeanConfiguration {

	@Bean
	public ChatMemory chatMemory() {
		return new InMemoryChatMemory();
	}

	@Bean
	VectorStore vectorStore(EmbeddingModel embeddingModel) {
		return new SimpleVectorStore(embeddingModel);
	}

}

Векторная БД должна включать данные о ветеринарах сразу после запуска приложения. Для этого я добавил бин VectorStoreController, который содержит аннотированный метод @EventListener, отслеживающий событие ApplicationStartedEvent. Этот метод автоматически вызывается Spring`ом сразу после запуска приложения, что гарантирует добавление данных о ветеринарах в векторную БД в нужный момент:

@EventListener
public void loadVetDataToVectorStoreOnStartup(ApplicationStartedEvent event) throws IOException {
    // Извлекает все сущности Vet и создает документ для каждого ветеринара
    Pageable pageable = PageRequest.of(0, Integer.MAX_VALUE);
    Page<Vet> vetsPage = vetRepository.findAll(pageable);
    Resource vetsAsJson = convertListToJsonResource(vetsPage.getContent());
    DocumentReader reader = new JsonReader(vetsAsJson);
    List<Document> documents = reader.get();

    // добавляет документы в векторную БД
    this.vectorStore.add(documents);

    if (vectorStore instanceof SimpleVectorStore) {
        var file = File.createTempFile("vectorstore", ".json");
        ((SimpleVectorStore) this.vectorStore).save(file);
        logger.info("vector store contents written to {}", file.getAbsolutePath());
    }
    logger.info("vector store loaded with {} documents", documents.size());
}

public Resource convertListToJsonResource(List<Vet> vets) {
    ObjectMapper objectMapper = new ObjectMapper();
    try {
        // Convert List<Vet> to JSON string
        String json = objectMapper.writeValueAsString(vets);
        // Convert JSON string to byte array
        byte[] jsonBytes = json.getBytes();
        // Create a ByteArrayResource from the byte array
        return new ByteArrayResource(jsonBytes);
    }
    catch (JsonProcessingException e) {
        e.printStackTrace();
        return null;
    }
}

Здесь достаточно много нюансов, поэтому разберем код пошагово:

  1. Как и в методе listOwners, мы начинаем с извлечения всех ветеринаров из базы данных.

  2. Spring AI добавляет в векторную БД сущности типа Document. Document представляет собой числовые данные эмбеддингов вместе с оригинальным, читаемым текстом. Такое двойное представление позволяет коду сопоставлять связи между встроенными векторами и изначальным текстом.

  3. Чтобы создать эти сущности Document, нам нужно преобразовать наши сущности Vet в текстовый формат. Spring AI предоставляет два встроенных readers для этой цели: JsonReader и TextReader. Так как наши сущности Vet имеют структурированный формат, имеет смысл представить их в виде JSON. Для этого мы используем вспомогательный метод convertListToJsonResource, который с помощью парсера Jackson преобразует список ветеринаров в JSON-ресурс, загружаемый в память.

  4. Затем мы вызываем метод add(documents) для векторной БД. Этот метод отвечает за добавление данных, поочередно обрабатывая каждый документ (наших ветеринаров в формате JSON) и встраивая его, связывая с оригинальными метаданными.

  5. Хотя это и не обязательно, мы также создаем файл vectorstore.json, который представляет текущее состояние нашей базы данных SimpleVectorStore. Этот файл позволяет нам увидеть, как Spring AI интерпретирует сохраненные данные «за кулисами». Давайте посмотрим на сгенерированный файл, чтобы понять, что именно видит Spring AI.

{
  "dd919c71-06bb-4777-b974-120dfee8b9f9" : {
    "embedding" : [ 0.013877872, 0.03598228, 0.008212427, 0.00917901, -0.036433823, 0.03253927, -0.018089917, -0.0030867155, -0.0017038669, -0.048145704, 0.008974405, 0.017624263, 0.017539598, -4.7888185E-4, 0.013842596, -0.0028221398, 0.033414137, -0.02847539, -0.0066955267, -0.021885695, -0.0072387885, 0.01673529, -0.007386951, 0.014661016, -0.015380662, 0.016184973, 0.00787377, -0.019881975, -0.0028785826, -0.023875304, 0.024778388, -0.02357898, -0.023748307, -0.043094076, -0.029322032, ... ],
    "content" : "{id=31, firstName=Samantha, lastName=Walker, new=false, specialties=[{id=2, name=surgery, new=false}]}",
    "id" : "dd919c71-06bb-4777-b974-120dfee8b9f9",
    "metadata" : { },
    "media" : [ ]
  },
  "4f9aabed-c15c-43f6-9dbc-46ed9a18e176" : {
    "embedding" : [ 0.01051745, 0.032714732, 0.007800559, -0.0020621764, -0.03240663, 0.025530376, 0.0037602335, -0.0023702774, -0.004978633, -0.037364256, 0.0012831709, 0.032742742, 0.005430281, 0.00847278, -0.004285406, 0.01146276, 0.03036196, -0.029941821, 0.013220336, -0.03207052, -7.518716E-4, 0.016665466, -0.0052062077, 0.010678503, 0.0026591222, 0.0091940155, ... ],
    "content" : "{id=195, firstName=Shirley, lastName=Martinez, new=false, specialties=[{id=1, name=radiology, new=false}, {id=2, name=surgery, new=false}]}",
    "id" : "4f9aabed-c15c-43f6-9dbc-46ed9a18e176",
    "metadata" : { },
    "media" : [ ]
  },
  "55b13970-cd55-476b-b7c9-62337855ae0a" : {
    "embedding" : [ -0.0031563698, 0.03546827, 0.018778138, -0.01324492, -0.020253662, 0.027756566, 0.007182742, -0.008637386, -0.0075725033, -0.025543278, 5.850768E-4, 0.02568248, 0.0140383635, -0.017330453, 0.003935892, ... ],
    "content" : "{id=19, firstName=Jacqueline, lastName=Ross, new=false, specialties=[{id=4, name=cardiology, new=false}]}",
    "id" : "55b13970-cd55-476b-b7c9-62337855ae0a",
    "metadata" : { },
    "media" : [ ]
  },
...
...
...

Очень здорово! У нас есть ветеринар в формате JSON, рядом с набором чисел, которые, возможно, не имеют для нас особого смысла, но весьма значимы для языковой модели. Эти числа представляют собой данные эмбеддингов, которые модель использует для понимания связей и семантики сущности Vet на уровне, значительно превышающем простое сопоставление текста.

Оптимизация затрат и ускорение запуска

Если запускать этот метод векторизации данных при каждом перезапуске приложения, это приведет к двум значительным недостаткам:

  • Долгое время запуска: каждый JSON-документ с данными о ветеринаре придется повторно встраивать, отправляя запросы к языковой модели, что негативно повлияет на загрузку приложения.

  • Повышенные затраты: векторизация 256 документов потребует отправки 256 запросов к языковой модели при каждом запуске, что приведет к дополнительной тарификации.

Эмбеддинги лучше всего подходят для процессов ETL (Extract, Transform, Load) или потоковой обработки, которые выполняются независимо от основного веб-приложения. Эти процессы могут выполнять векторизацию в фоновом режиме, не влияя на user experience и избегая лишних затрат.

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

@EventListener
public void loadVetDataToVectorStoreOnStartup(ApplicationStartedEvent event) throws IOException {
    Resource resource = new ClassPathResource("vectorstore.json");
    // Проверка существования файла
    if (resource.exists()) {
        // Чтобы сэкономить кредиты на ИИ, используем pre-embedded базу данных, которая была сохранена
        // на диск на основе текущих данных в файле h2 data.sql
        File file = resource.getFile();
        ((SimpleVectorStore) this.vectorStore).load(file);
        logger.info("vector store loaded from existing vectorstore.json file in the classpath");
        return;
    }

    // Остальная часть метода остается прежней
    ...
    ...
}

Файл vectorstore.json расположен в каталоге src/main/resources, что гарантирует, что приложение всегда будет загружать заранее подготовленную векторную БД при запуске, а не создавать её заново. Если нам потребуется обновить векторную БД, мы можем просто удалить существующий файл vectorstore.json и перезапустить приложение. После того как новая БД будет создано, мы можем поместить обновленный файл vectorstore.json обратно в src/main/resources. Такой подход обеспечивает гибкость, избегая ненужной повторной векторизации данных при регулярных перезапусках.

Реализация поиска по сходству

Теперь, когда наша векторная БД готова, реализация функции listVets становится простой. Функция определяется следующим образом:

@Bean
@Description("Список ветеринаров, работающих в клинике для животных")
public Function<VetRequest, VetResponse> listVets(AIDataProvider petclinicAiProvider) {
    return request -> {
        try {
            return petclinicAiProvider.getVets(request);
        }
        catch (JsonProcessingException e) {
            e.printStackTrace();
            return null;
        }
    };
}
record VetResponse(List<String> vet) {
};

record VetRequest(Vet vet) {
}

Вот реализация в AIDataProvider:

public VetResponse getVets(VetRequest request) throws JsonProcessingException {
    ObjectMapper objectMapper = new ObjectMapper();
    String vetAsJson = objectMapper.writeValueAsString(request.vet());
    SearchRequest sr = SearchRequest.from(SearchRequest.defaults()).withQuery(vetAsJson).withTopK(20);
    if (request.vet() == null) {
        // Provide a limit of 50 results when zero parameters are sent
        sr = sr.withTopK(50);
    }
    List<Document> topMatches = this.vectorStore.similaritySearch(sr);
    List<String> results = topMatches.stream().map(document -> document.getContent()).toList();
    return new VetResponse(results);
}

Давайте рассмотрим, что мы сделали:

  1. Мы начинаем с сущности Vet из запроса. Поскольку записи в нашей векторной БД представлены в формате JSON, первым шагом является преобразование сущности Vet также в JSON.

  2. Затем мы создаем SearchRequest, который передается в метод similaritySearch векторной БД. SearchRequest позволяет настроить поиск в зависимости от наших конкретных потребностей. В данном случае мы в основном используем параметры по умолчанию, за исключением параметра topK, который определяет количество возвращаемых результатов. По умолчанию это значение установлено на 4, но в нашем случае мы увеличиваем его до 20, чтобы обрабатывать более широкие запросы, такие как «Сколько ветеринаров специализируются на кардиологии?»

  3. Если в запросе не указаны фильтры (то есть сущность Vet пуста), мы увеличиваем значение topK до 50. Это позволяет нам возвращать до 50 ветеринаров для таких запросов, как «показать список ветеринаров клиники». Конечно, это будет не весь список, ведь мы хотели бы избежать перегрузки LLM избыточными данными. Тем не менее, это должно быть оптимально, так как мы постарались тщательно настроить системный текст для обработки подобных случаев:

    При работе с ветеринарами, если пользователь не уверен в полученных результатах, объясните, что может существовать дополнительная информация, которая не была возвращена. Только если пользователь спрашивает об общем количестве всех ветеринаров, ответьте, что их много, и запросите дополнительные критерии. Для владельцев, питомцев иливизитов — предоставьте точные данные.

  4. Последним шагом является вызов метода similaritySearch. Затем мы преобразуем getContent() каждого возвращенного результата, так как именно он содержит фактические данные JSON о ветеринарах, а не встроенные данные эмбеддингов.

Далее все идет как обычно. Языковая модель завершает вызов функции, получает результаты и определяет наилучший способ отображения данных в чате.

Давайте посмотрим, как это работает на практике:

Похоже, наш системный текст работает как ожидается, предотвращая любую перегрузку. Теперь давайте попробуем указать некоторые конкретные критерии:

Данные, возвращенные от языковой модели, именно такие, как мы ожидали. Давайте попробуем задать более общий вопрос:

Языковая модель успешно определила как минимум 20 ветеринаров, специализирующихся на кардиологии, соблюдая установленный нами верхний предел topK(20). Однако, если есть неопределенность в результатах, модель отмечает, что могут быть доступны дополнительные ветеринары, как указано в нашем системном тексте.

Реализация пользовательского интерфейса

Создание интерфейса для чат-бота включает работу с Thymeleaf, JavaScript, CSS и препроцессором SCSS.

После анализа кода я решил разместить чат-бота в месте, доступном с любой вкладки, что делает layout.html идеальным выбором.

Во время обсуждения PR с доктором Дейвом Сайером я понял, что не следует изменять файл petclinic.css напрямую, так как Spring Petclinic использует SCSS-препроцессор для генерации CSS-файла.

Признаюсь, я в основном backend-разработчик, специализирующийся на Spring, облачной архитектуре, Kubernetes и Cloud Foundry. У меня есть некоторый опыт работы с Angular, но я не являюсь экспертом в разработке фронтенда. Вероятно, я мог бы что-то создать, но это вряд ли выглядело бы профессионально.

К счастью, у меня был отличный партнер для парного программирования — ChatGPT. Если вам интересно, как я разработал код для интерфейса, вы можете ознакомиться с этой сессией ChatGPT. Удивительно, сколько можно узнать, сотрудничая с большими языковыми моделями в упражнениях по кодированию. Просто помните, что всегда следует тщательно проверять предложенные решения, а не копировать их вслепую.

Заключение

После нескольких месяцев работы со Spring AI я по-настоящему оценил вложенные в проект мысли и усилия. Spring AI действительно уникален, так как позволяет разработчикам изучать мир ИИ, не требуя от них освоения нового языка, такого как Python. Более того, этот опыт подчеркивает еще одно важное преимущество: ваш код с ИИ может сосуществовать в том же коде, что и ваша бизнес-логика. Вы можете легко расширить старую кодовую базу возможностями ИИ, добавив всего несколько дополнительных классов. Способность избегать необходимости перестраивать все ваши данные с нуля в новом приложении, специально ориентированном на ИИ, значительно повышает производительность. Даже такие простые функции, как автоматическое завершение кода для существующих сущностей JPA в IDE, вносят огромный вклад.

Spring AI имеет потенциал существенно улучшить приложения на базе Spring, упрощая интеграцию возможностей ИИ. Он дает разработчикам возможность использовать модели машинного обучения и сервисы на основе ИИ без необходимости глубоких знаний в области науки о данных. Абстрагируя сложные операции ИИ и встраивая их непосредственно в привычные фреймворки Spring, разработчики могут сосредоточиться на быстром создании интеллектуальных, управляемых данными функций. Этот бесшовный союз ИИ и Spring создает среду, где инновации не ограничиваются техническими барьерами, открывая новые возможности для разработки более умных и адаптивных приложений.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Ждем всех, присоединяйтесь

Комментарии (1)


  1. ENick
    02.11.2024 17:41

    Это типа LangChain ?