- BrainTools - https://www.braintools.ru -

RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний

В этой статье покажу, как мы собрали RAG-систему на PHP и Qdrant: выбрали векторную базу и LLM, настроили гибридный поиск и реализовали чат-бота на Symfony с использованием PHP фреймворка Neuron AI.

К нам обратился клиент с задачей: сделать чат-бота для поиска информации по внутренней базе знаний (статьи, документация, корпоративные тексты). Главное требование – быстро собрать MVP, чтобы проверить гипотезу и принять решение о дальнейшем развитии системы. Первую версию запустили, получаем хорошие отзывы от пользователей, поэтому решил поделиться и, возможно, получить полезную обратную связь от сообщества.

Стек и компоненты

Пайплайн запроса в нашей системе выглядит так:

  1. Пользователь отправляет вопрос

  2. Вопрос векторизуется embedding моделью

  3. Выполняется гибридный поиск в Qdrant

  4. Результаты объединяются и ранжируются в Qdrant

  5. Топ документов передаются в LLM

  6. LLM генерирует финальный ответ

  7. Сохраняем историю чата

Бэкенд – Symfony на PHP 8.5. Для ускорения MVP выбирали между готовыми решениями https://github.com/neuron-core/neuron-ai [1] и https://github.com/LLPhant/LLPhant [2]. Выбор пал на Neuron AI благодаря качественной документации.

Ключевой момент RAG-системы – поиск релевантных документов по запросу пользователя. Именно найденные документы формируют контекст, на основе которого LLM генерирует ответ. Тексты документов хранятся в векторном хранилище в виде embedding-векторов (способ представления данных в виде числовых векторов фиксированной длины, где похожие по смыслу объекты находятся близко друг к другу). Сейчас на рынке довольно много баз данных для хранения embedding-векторов, от специализированных (Chroma, Qdrant, Faiss, Milvus) до, так сказать, гибридных, где хранение векторов не было основной задачей, но такая возможность была добавлена со временем (Elasticsearch, Postgres с расширением pg_vector).
Выбрали Qdrant по следующим причинам:

  1. Open-source и активное развитие

  2. Гибридный поиск. Dense vector хорошо работает с семантикой, но может терять точные совпадения терминов. Sparse vector отлично находит документы с точным совпадением ключевых слов, но не понимает перефразирования

  3. Встроенный re-ranker (используя гибридный поиск, нужно как-то объединить и проранжировать результаты разных подходов)

  4. Встроенная модель для создания Sparse vector (qdrant/bm25)

  5. Стемминг с поддержкой русского языка

Оставшиеся элементы RAG-системы: Embedding Model и Large Language Model. Они нужны для трёх сценариев:

  1. Векторизация текстов документов (Embedding)

  2. Векторизация вопроса пользователя (Embedding)

  3. Генерация ответа пользователю на основе контекста (LLM)

Для векторизации используются модели, задача которых преобразовать текст в числовой вектор, размерность которого зависит от выбранной модели. У всех популярных провайдеров есть отдельная Embedding API ручка, где на вход подается текст, а на выходе получаем вектор вида [0.0023064255, -0.009327292, ... -0.0028842222].

Выбирали между Gigachat и YandexGPT. Для тестирования взяли 50 документов, векторизировали их, составили prompt и погоняли тесты, периодически меняя prompt. Оценивали все вручную, да и в целом выбор был довольно субъективный. На нашем наборе документов не увидели значительной разницы между моделями. Выбрали YandexGPT, ввиду того, что тариф был ниже на момент разработки, плюс сам проект был уже развернут на ресурсах Yandex Cloud.
На мой взгляд для генерации ответа подойдёт большинство современных LLM. Можно даже сделать fallback – в случае если основной LLM-провайдер не ответил или отвечает долго, то отправить запрос в другой. Единственная сложность – замена embedding модели, так как нужно сделать повторную векторизацию базы данных документов. Embedding-векторы документов и embedding-вектор вопроса пользователя должны быть сгенерированы одной моделью, поэтому переключать модели на лету не получится. Процесс повторной векторизации потребует дополнительных затрат. Однако стоимость невысока: например генерация embedding-вектора для текста объёмом 1000 токенов в YandexGPT стоит около 0,01 ₽.

Развёртывание Qdrant

Переходим к реализации. Начнём с развёртывания Qdrant и подготовки коллекции для хранения векторов.

Qdrant docker-compose.yaml (QDRANT_API_KEY – можно задать любой)
services:
  qdrant:
    image: qdrant/qdrant:v1.16.3
    ports:
      - "6333:6333"
    volumes:
      - qdrant-data:/qdrant/storage
    environment:
      QDRANT__LOG_LEVEL: INFO
      QDRANT__SERVICE__API_KEY: ${QDRANT_API_KEY}
    restart: on-failure:3
    healthcheck:
      test: ["CMD", "bash", "-c", ":> /dev/tcp/127.0.0.1/6333 || exit 1"]
      interval: 10s
      timeout: 30s
      retries: 5
      start_period: 5s
    logging:
      driver: "json-file"
      options:
      max-file: "5"
      max-size: "2m"
  
volumes:
  qdrant-data:
    driver: local
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 1 [3]

После успешного старта контейнера, будет доступен UI по адресу http://localhost:6333/dashboard [4] (при входе нужно указать QDRANT_API_KEY). Здесь можно создавать и просматривать коллекции, а также делать вызовы к API Qdrant в разделе Console.

Создадим Collection, в которую будем сохранять векторы (в виде сущности Point). Point состоит из уникального ID, vector и payload. В payload можно передать любой набор данных.

Создание коллекции (запрос можно выполнить в Console Qdrant UI)
PUT http://localhost:6333/collections/COLLECTION_NAME
Content-Type: application/json
api-key: QDRANT_API_KEY

{  
    "vectors": {
	    "dense_cosine": {
			"size": 256,
			"distance": "Cosine",
			"on_disk": false,
			"datatype": "float32"
		}
	},
	"sparse_vectors": {
		"sparse_bm25": {
			"model": "bm25"
		}
	},
	"quantization_config": {
		"scalar": {
			"always_ram": true
		}
	}
}
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 2 [3]

Qdrant даёт возможность осуществлять гибридный поиск, поэтому при создании коллекции указываем два набора векторов и правила поиска по ним. В примере используется модель https://yandex.cloud/ru/docs/ai-studio/concepts/embeddings [5], поэтому размерность dense_cosine.size равна 256. У нас относительно небольшой набор документов (до 5000), поэтому складываем всё в память [6] dense_cosine.on_disk: false и quantization_config.scalar.always_ram: true, при этом данные по-прежнему персистятся на диск.

Фреймворк Neuron AI при вставке в Qdrant для payload по умолчанию передаёт следующие атрибуты:

  1. Оригинальный текст документа, на основе которого был сгенерирован вектор (атрибут content)

  2. Название или техническое название источника (атрибут sourceName)

  3. Тип источника (атрибут sourceType)

Нас интересуют поля sourceName и content, по которым дополнительно создадим индексы. По sourceName создадим индекс типа keyword – Qdrant рекомендует его для поиска по точному совпадению строки. Этот индекс пригодится, чтобы находить и удалять векторы из базы. Например, мы удаляем старую статью из базы компании, соответственно, нам нужно найти все связанные с ней векторы и удалить их из Qdrant. По полю content после создания индекса можно осуществлять полнотекстовый поиск (в нашей первой версии он не используется, но стоит знать, что такая возможность есть).

Создание индекса по sourceName
PUT http://localhost:6333/collections/COLLECTION_NAME/index
Content-Type: application/json
api-key: QDRANT_API_KEY
  
{  
    "field_name": "sourceName",
    "field_schema": {
		"type": "keyword"
	}
}
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 3 [3]
Создание индекса по content
PUT http://localhost:6333/collections/COLLECTION_NAME/index
Content-Type: application/json
api-key: QDRANT_API_KEY
  
{  
    "field_name": "content",
    "field_schema": {
		"type": "text",
		"tokenizer": "word",
		"min_token_len": 4,
		"max_token_len": 20,
		"lowercase": true,
		"phrase_matching": true,
		"stemmer": {
			"type": "snowball",
			"language": "russian"
		},
		"stopwords": "russian"
	}
}
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 4 [3]

На самом деле можно создать индексы по любому из полей в payload. Например, у вас есть набор документов за 2025 год и в payload фигурирует поле year. Когда пользователь задаёт вопрос “Покажи мне список мероприятий в Москве в 2025 году”, то просто передавая такой вопрос в Qdrant нет полной уверенности, что вы получите в топе документы за 2025 год, но вытащив год из вопроса и применив фильтрацию по year можно сократить выборку до начала векторного поиска.

Наполнение базы: чанкинг и векторизация

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

  1. При добавлении или изменении текстового документа в админке компании, в рамках одной транзакции делаем запись в отдельную таблицу (outbox), где каждая строчка – это event с нужным набором метаданных (id документа, тип документа, тип события и прочее), сигнализирующий, что есть документ, для которого нужно сгенерировать embedding-вектор.

  2. Отдельный worker раз в минуту вычитывает данные из таблицы, подготавливает их и отправляет в Embedding API провайдера, полученный результат сохраняется в Qdrant.

Перед тем как реализовать сам worker, нам нужны клиенты для работы с Qdrant и YandexGPT.

Для работы с API Qdrant в Neuron AI уже есть реализация, но в ней нет возможности делать гибридный поиск, поэтому реализуем интерфейс VectorStoreInterface (не будем наследоваться, просто заберем некоторые части из стандартной реализации) и добавляем метод hybridSearch.

QdrantVectorStore.php
<?php

namespace AppModuleRAGVectorStore;

use AppSharedHelperUtils;
use GuzzleHttpClient;
use GuzzleHttpExceptionGuzzleException;
use GuzzleHttpRequestOptions;
use NeuronAIRAGDocument;
use NeuronAIRAGVectorStoreVectorStoreInterface;

final class QdrantVectorStore implements VectorStoreInterface
{
    public function __construct(
        protected string $collectionUrl,
        protected int $topK = 3,
        private ?Client $client = null
    ) {
        $this->client ??= new Client([
            'base_uri' => rtrim($this->collectionUrl, '/').'/',
            'headers' => [
                'Content-Type' => 'application/json',
                'api-key' => Utils::getEnvVariable('QDRANT_API_KEY')
            ],
        ]);
    }

    /**
     * @throws GuzzleException
     */
    public function addDocument(Document $document): VectorStoreInterface
    {
        return $this->addDocuments([$document]);
    }

    /**
     * @throws GuzzleException
     */
    public function addDocuments(array $documents): VectorStoreInterface
    {
        $points = array_map(fn (Document $document): array => [
            'id' => $document->getId(),
            'payload' => [
                'content' => $document->getContent(),
                'sourceType' => $document->getSourceType(),
                'sourceName' => $document->getSourceName(),
                ...$document->metadata,
            ],
            'vector' => [
                'dense_cosine' => $document->getEmbedding(),
                'sparse_bm25' => [
                    'text' => $document->getContent(),
                    'model' => 'qdrant/bm25',
                    'options' => [
                        'language' => 'russian'
                    ]
                ],
            ]
        ], $documents);

        $this->client->put('points', [
            RequestOptions::JSON => ['points' => $points]
        ]);

        return $this;
    }

    /**
     * @throws GuzzleException
     */
    public function deleteBySource(string $sourceType, string $sourceName): VectorStoreInterface
    {
        $this->client->post('points/delete', [
            RequestOptions::JSON => [
                'wait' => true,
                'filter' => [
                    'must' => [
                        [
                            'key' => 'sourceType',
                            'match' => [
                                'value' => $sourceType,
                            ]
                        ],
                        [
                            'key' => 'sourceName',
                            'match' => [
                                'value' => $sourceName,
                            ]
                        ]
                    ]
                ]
            ]
        ]);

        return $this;
    }

    public function similaritySearch(array $embedding): iterable
    {
        $response = $this->client()->post('points/search', [
            RequestOptions::JSON => [
                'vector' => $embedding,
                'limit' => $this->topK,
                'with_payload' => true,
                'with_vector' => true,
            ]
        ])->getBody()->getContents();
    
        $response = json_decode($response, true);
    
        return $this->iterateSearchResults($response['result']);
    }
    
    /**
     * @throws GuzzleException
     */
    public function hybridSearch(array $embedding, string $text): iterable
    {
        $response = $this->client->post('points/query', [
            RequestOptions::JSON => [
                'prefetch' => [
                    [
                        'query' => $embedding,
                        'using' => 'dense_cosine',
                        'limit' => 10
                    ],
                    [
                        'query' => [
                            'text' => $this->prepareTextQuery($text),
                            'model' => 'qdrant/bm25',
                            'options' => [
                                'language' => 'russian'
                            ]
                        ],
                        'using' => 'sparse_bm25',
                        'limit' => 10
                    ]
                ],
                'query' => ['fusion' => 'rrf'],
                'limit' => $this->topK,
                'with_payload' => true,
                'with_vector' => true,
            ]
        ])->getBody()->getContents();
    
        $response = json_decode($response, true);
    
        return $this->iterateSearchResults($response['result']['points']);
    } 

    /**
     * @param string $text
     * @return string
     */
    private function prepareTextQuery(string $text): string
    {
        // Тут вы можете удалить слова, которые присутствуют во многих документах, но не несут смысловой нагрузки, а только мешают поиску, либо сделать дополнительную нормализацию
        return $text;
    }

    /**
     * @param array $points
     * @return array<int, Document>
     */
    private function iterateSearchResults(array $points): array
    {
        $documents = [];
        foreach ($points as $item) {
            $document = new Document($item['payload']['content']);
            $document->id = $item['id'];
            $document->embedding = $item['vector'];
            $document->sourceType = $item['payload']['sourceType'];
            $document->sourceName = $item['payload']['sourceName'];
            $document->score = $item['score'];

            foreach ($item['payload'] as $name => $value) {
                if (!in_array($name, ['content', 'sourceType', 'sourceName', 'score', 'embedding', 'id'])) {
                    $document->addMetadata($name, $value);
                }
            }

            $documents[] = $document;
        }

        return $documents;
    }
}
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 5 [3]

Для работы с Embedding API YandexGPT в Neuron AI нет реализации, поэтому по примеру реализации других провайдеров наследуемся от AbstractEmbeddingsProvider

YandexGPTEmbedding.php
<?php

namespace AppModuleRAGEmbedding;

use AppSharedHelperUtils;
use GuzzleHttpClient;
use GuzzleHttpRequestOptions;
use NeuronAIRAGEmbeddingsAbstractEmbeddingsProvider;

class YandexGPTEmbedding extends AbstractEmbeddingsProvider
{
    const string MODEL_TYPE_DOC = 'doc';
    const string MODEL_TYPE_QUERY = 'query';

    public function __construct(private string $modelType, private ?Client $client = null)
    {
        $this->client ??= new Client([
            'base_uri' => 'https://llm.api.cloud.yandex.net/',
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Authorization' => 'Api-Key '.Utils::getEnvVariable('YC_API_KEY'),
                'x-folder-id' => Utils::getEnvVariable('YC_FOLDER_ID'),
            ]
        ]);
    }

    public function embedText(string $text): array
    {
        $response = $this->client->post('foundationModels/v1/textEmbedding', [
            RequestOptions::JSON => [
                'modelUri' => $this->getModelUri(),
                'text' => $text,
            ]
        ]);

        $response = json_decode($response->getBody()->getContents(), true);
        return $response['embedding'] ?? throw new RuntimeException('Embedding not found in response');
    }

    private function getModelUri(): string
    {
        return 'emb://'.Utils::getEnvVariable('YC_FOLDER_ID').'/text-search-'.$this->modelType.'/latest';
    }
}
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 6 [3]

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

  1. Исходный текст нужно разбивать на чанки

  2. К каждому получившемуся чанку стоит добавить название документа

Зачем разбивать текст на более мелкие чанки? Для точности поиска и для уменьшения входного контекста в LLM. Есть разные техники получения чанков:

  • разбить по кол-ву токенов;

  • разбить по предложениям;

  • разбить по абзацам;

  • разбить по заголовкам;

  • скомбинировать – разбить по заголовкам, внутри разбить по предложениям или абзацам;

  • отправить в LLM и попросить разбить текст на смысловые чанки заданной длины (без замены содержимого). Это неплохо работает, но стоит дорого. Если у вас совсем маленькая база, которая редко обновляется, то возможно такой вариант вам подойдет.

Во всех случаях мы соблюдаем лимит токенов, а также добавляем так называемый overlap 10-20% (добавить к чанку 10% токенов до и столько же после). Хорошо структурированные документы – важная составляющая любой RAG-системы, это нужно донести бизнесу. В итоге мы выбрали вариант разбивать по абзацам в пределах 1000 токенов и overlap 200 токенов. На самом деле пробовали разные варианты от 500 до 3000 с шагом в 500 (учитывая что цена за генерацию embedding крайне низкая, в нашем случае можно было проводить эксперименты на всей базе документов). Вариант с 1000 токенов показался самым оптимальным исходя из наших текстов.

Зачем добавлять название документа к чанку? В нашей базе было много документов по каким-либо мероприятиям и в каждом из них был раздел “Программа мероприятия”, где по часам были расписаны активности. При тестировании наткнулись на то, что на вопрос “Какая программа у конференции Туризм в России”, поиск возвращал чанки нерелевантных мероприятий, так как в абзаце с программой не было упоминаний названия мероприятия.

В качесте чанкера используем свою реализацию TextPreprocessor (ну как свою, спасибо Cluade). Либо можно взять одну из реализаций фреймворка vendor/neuron-core/neuron-ai/src/RAG/Splitter.

TextPreprocessor.php
<?php

namespace AppModuleTextVectorChunker;

class TextPreprocessor
{
    /**
     * Теги, после которых нужен перенос строки.
     */
    private const array BLOCK_TAGS = [
        'p', 'div', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
        'li', 'tr', 'td', 'th', 'blockquote', 'pre', 'hr',
        'section', 'article', 'header', 'footer', 'nav', 'ul', 'ol',
    ];

    /**
     * Теги, содержимое которых следует игнорировать.
     */
    private const array SKIP_TAGS = [
        'script', 'style', 'head', 'meta', 'link', 'noscript', 'svg',
    ];

    public function __construct(
        private readonly int $chunkSize = 1000,
        private readonly int $chunkOverlap = 150,
        private readonly int $minChunkSize = 200,
    ) {}

    /**
     * Извлекает чистый текст из HTML.
     */
    public function stripHtml(string $html): string
    {
        // Удаляем содержимое skip-тегов полностью
        foreach (self::SKIP_TAGS as $tag) {
            $html = preg_replace(
                sprintf('#<%sb[^>]*>.*?</%s>#si', $tag, $tag),
                '',
                $html
            );
        }

        // Добавляем переносы строк перед и после блочных тегов
        foreach (self::BLOCK_TAGS as $tag) {
            $html = preg_replace(
                sprintf('#</?%sb[^>]*>#i', $tag),
                "n",
                $html
            );
        }

        // Удаляем все оставшиеся HTML-теги
        $text = strip_tags($html);

        // Декодируем HTML-сущности
        return html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    }

    /**
     * Нормализует текст для эмбеддингов.
     *
     * Выполняет:
     * - Unicode-нормализация (NFC)
     * - Нормализация пробелов и переносов строк
     * - Удаление спецсимволов, не несущих смысла
     */
    public function normalizeText(string $text): string
    {
        // Unicode-нормализация (NFC)
        if (class_exists('Normalizer')) {
            $text = Normalizer::normalize($text, Normalizer::FORM_C);
        }

        // Заменяем различные виды пробелов на обычные
        $text = str_replace(
            ["xC2xA0", "t", "r"],  // non-breaking space, tab, carriage return
            [' ', ' ', "n"],
            $text
        );

        // Удаляем zero-width символы
        $text = preg_replace('/[x{200B}-x{200D}x{FEFF}]/u', '', $text);

        // Нормализуем пробелы (несколько пробелов -> один)
        $text = preg_replace('/[ t]+/', ' ', $text);

        // Нормализуем переносы строк (более 2 -> 2)
        $text = preg_replace('/n{3,}/', "nn", $text);

        // Удаляем пробелы в начале строк
        $text = preg_replace('/n +/', "n", $text);

        // Удаляем пустые строки с пробелами
        $text = preg_replace('/ns*n/', "nn", $text);

        return trim($text);
    }

    /**
     * Разбивает текст на предложения.
     *
     * @return string[]
     */
    private function splitIntoSentences(string $text): array
    {
        // Разбиваем по параграфам сначала
        $paragraphs = preg_split('/nn+/', $text);
        $sentences = [];

        foreach ($paragraphs as $para) {
            $para = trim($para);
            if ($para === '') {
                continue;
            }

            // Разбиваем параграф на предложения
            // Учитываем русские и английские окончания
            $parts = preg_split(
                '/(?<=[.!?])s+(?=[А-ЯЁA-Z«"])/u',
                $para
            );

            foreach ($parts as $part) {
                $part = trim($part);
                if ($part !== '') {
                    $sentences[] = $part;
                }
            }
        }

        return $sentences;
    }

    /**
     * Разбивает текст на параграфы.
     *
     * @return string[]
     */
    private function splitIntoParagraphs(string $text): array
    {
        $paragraphs = preg_split('/nn+/', $text);
        return array_values(array_filter(
            array_map('trim', $paragraphs),
            fn(string $p): bool => $p !== ''
        ));
    }

    /**
     * Разбивает текст на чанки для эмбеддингов.
     *
     * @return Chunk[]
     */
    public function chunkText(string $text, bool $preserveParagraphs = true): array
    {
        $textLength = mb_strlen($text, 'UTF-8');
        if ($textLength <= $this->minChunkSize) {
            return [
                new Chunk(
                    text: $text,
                    index: 0,
                    startChar: 0,
                    endChar: $textLength-1,
                )
            ];
        }

        $chunks = [];
        $currentChunk = [];
        $currentLength = 0;
        $currentStart = 0;
        $charPosition = 0;

        $units = $preserveParagraphs
            ? $this->splitIntoParagraphs($text)
            : $this->splitIntoSentences($text);

        $separator = $preserveParagraphs ? "nn" : ' ';
        $separatorLen = mb_strlen($separator, 'UTF-8');

        foreach ($units as $unit) {
            $unitLength = mb_strlen($unit, 'UTF-8');

            // Если один unit больше chunkSize, разбиваем его дополнительно
            if ($unitLength > $this->chunkSize) {
                // Сохраняем накопленное
                if (!empty($currentChunk)) {
                    $chunkText = implode($separator, $currentChunk);
                    $chunks[] = new Chunk(
                        text: $chunkText,
                        index: count($chunks),
                        startChar: $currentStart,
                        endChar: $charPosition,
                    );
                    $currentChunk = [];
                    $currentLength = 0;
                }

                // Разбиваем большой unit по предложениям
                $subSentences = $this->splitIntoSentences($unit);
                $subChunk = [];
                $subLength = 0;
                $subStart = $charPosition;

                foreach ($subSentences as $sentence) {
                    $sentenceLen = mb_strlen($sentence, 'UTF-8');

                    if ($subLength + $sentenceLen > $this->chunkSize && !empty($subChunk)) {
                        $chunkText = implode(' ', $subChunk);
                        $chunks[] = new Chunk(
                            text: $chunkText,
                            index: count($chunks),
                            startChar: $subStart,
                            endChar: $charPosition,
                        );

                        // Overlap: берем последние предложения
                        $overlapText = count($subChunk) >= 2
                            ? implode(' ', array_slice($subChunk, -2))
                            : end($subChunk);

                        if (mb_strlen($overlapText, 'UTF-8') < $this->chunkOverlap && !empty($subChunk)) {
                            $overlapText = end($subChunk);
                        }

                        $subChunk = $overlapText ? [$overlapText] : [];
                        $subLength = mb_strlen($overlapText, 'UTF-8');
                        $subStart = $charPosition - $subLength;
                    }

                    $subChunk[] = $sentence;
                    $subLength += $sentenceLen + 1;
                    $charPosition += $sentenceLen + 1;
                }

                if (!empty($subChunk)) {
                    $chunkText = implode(' ', $subChunk);
                    if (mb_strlen($chunkText, 'UTF-8') >= $this->minChunkSize) {
                        $chunks[] = new Chunk(
                            text: $chunkText,
                            index: count($chunks),
                            startChar: $subStart,
                            endChar: $charPosition,
                        );
                    } elseif (!empty($chunks)) {
                        // Добавляем к предыдущему чанку
                        $lastChunk = array_pop($chunks);
                        $chunks[] = new Chunk(
                            text: $lastChunk->text . ' ' . $chunkText,
                            index: $lastChunk->index,
                            startChar: $lastChunk->startChar,
                            endChar: $charPosition
                        );
                    }
                }

                $currentStart = $charPosition;
                continue;
            }

            // Проверяем, поместится ли unit в текущий чанк
            if ($currentLength + $unitLength > $this->chunkSize && !empty($currentChunk)) {
                // Сохраняем текущий чанк
                $chunkText = implode($separator, $currentChunk);
                $chunks[] = new Chunk(
                    text: $chunkText,
                    index: count($chunks),
                    startChar: $currentStart,
                    endChar: $charPosition,
                );

                // Создаем перекрытие
                $overlapUnits = [];
                $overlapLength = 0;
                foreach (array_reverse($currentChunk) as $u) {
                    if ($overlapLength + mb_strlen($u, 'UTF-8') <= $this->chunkOverlap) {
                        array_unshift($overlapUnits, $u);
                        $overlapLength += mb_strlen($u, 'UTF-8');
                    } else {
                        break;
                    }
                }

                $currentChunk = $overlapUnits;
                $currentLength = $overlapLength;
                $currentStart = $charPosition - $overlapLength;
            }

            $currentChunk[] = $unit;
            $currentLength += $unitLength;
            $charPosition += $unitLength + $separatorLen;
        }

        // Добавляем последний чанк
        if (!empty($currentChunk)) {
            $chunkText = implode($separator, $currentChunk);
            if (mb_strlen($chunkText, 'UTF-8') >= $this->minChunkSize) {
                $chunks[] = new Chunk(
                    text: $chunkText,
                    index: count($chunks),
                    startChar: $currentStart,
                    endChar: $charPosition,
                );
            } elseif (!empty($chunks)) {
                // Объединяем с предыдущим
                $lastChunk = array_pop($chunks);
                $chunks[] = new Chunk(
                    text: $lastChunk->text . $separator . $chunkText,
                    index: $lastChunk->index,
                    startChar: $lastChunk->startChar,
                    endChar: $charPosition
                );
            }
        }

        // Переиндексируем
        return array_map(
            fn(Chunk $chunk, int $i): Chunk => new Chunk(
                text: $chunk->text,
                index: $i,
                startChar: $chunk->startChar,
                endChar: $chunk->endChar
            ),
            $chunks,
            array_keys($chunks)
        );
    }

    /**
     * Полный пайплайн обработки HTML в чанки.
     *
     * @return Chunk[]
     */
    public function process(string $html): array
    {
        // 1. Извлекаем текст из HTML
        $text = $this->stripHtml($html);

        // 2. Нормализуем текст
        $text = $this->normalizeText($text);

        // 3. Разбиваем на чанки
        return $this->chunkText($text);
    }
}
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 7 [3]
TextVectorTaskService.php
<?php

namespace AppModuleTextVectorService;

use AppModuleRAGEmbeddingYandexGPTEmbedding;
use AppModuleRAGVectorStoreQdrantVectorStore;
use AppModuleTextVectorChunkerTextPreprocessor;
use AppModuleTextVectorEntityTextVectorTaskEntity;
use AppModuleTextVectorExceptionEmbeddingException;
use AppSharedHelperUtils;
use NeuronAIRAGDocument;

class TextVectorTaskService
{
    public function consume(TextVectorTaskEntity $task): void
    {
        $chunks = new TextPreprocessor(1000, 200, 200)
            ->process($task->document_text);
          
        $client = new YandexGPTEmbedding(YandexGPTEmbedding::MODEL_TYPE_DOC); 
        
        $vectorStore = new QdrantVectorStore(
            Utils::getEnvVariable('QDRANT_DOCS_COLLECTION_URL')
        );
        
        foreach ($chunks as $chunk) {
            $text = $chunk->text;
            // Добавляем название источника к тексту, для лучшего поиска
            $text = $task->document_name.' | '.$text; 
        
            try {
                $embedding = $client->embedText($text);
            } catch (Throwable $e) {
                throw new EmbeddingException('Ошибка при генерации эмбеддингов: '.$e->getMessage());
            }
            
            if (count($embedding) !== (int) Utils::getEnvVariable('VECTOR_DIMENSION')) {
                throw new EmbeddingException('Ошибка при генерации эмбеддингов: неверная размерность');
            }

            $embedding = array_map(static function ($v) {
                return is_numeric($v) ? (float)$v : 0.0;
            }, $embedding);
            
            try {
                $document = new Document($text);
                $document->embedding = $embedding;
                // храним в виде строки из вариантов event|article|doc...
                $document->sourceType = $task->document_type;
                // храним в виде строки, где указан тип документа и id, например article_123
                $document->sourceName = $task->document_id;
                $document->metadata = [
                    'chunkIndex' => $chunk->index,
                ];
            
                $vectorStore->addDocument($document);
            } catch (Throwable $e) {
                throw new EmbeddingException('Ошибка при добавлении эмбеддинга в базу: '.$e->getMessage());
            }
        }
    }
}
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 8 [3]

Реализация чат-бота

Базу мы наполнили, приступаем к реализации чат-бота. У нас это ручка, на вход которой подаём вопрос авторизованного пользователя.

CreateChatMessageCommandHandler.php
<?php

namespace AppModuleChatBotCommand;

use AppModuleChatBotDTOChatMessageDTO;
use AppModuleChatBotEntityChatMessageEntity;
use AppModuleChatBotExceptionChatNotFoundException;
use AppModuleChatBotQueryChatMessageListQuery;
use AppModuleChatBotRepositoryChatMessageRepository;
use AppModuleChatBotRepositoryChatRepository;
use AppModuleRAGChatBot;
use AppSharedCommandCommandHandlerInterface;
use AppSharedDTOEntityListDTO;
use AppSharedQueryQueryBusInterface;
use DoctrineORMEntityManagerInterface;
use NeuronAIChatMessagesAssistantMessage;
use NeuronAIChatMessagesUserMessage;

readonly class CreateChatMessageCommandHandler implements CommandHandlerInterface
{
    public function __construct(
        private ChatRepository $chatRepository,
        private ChatMessageRepository $chatMessageRepository,
        private QueryBusInterface $queryBus,
        private EntityManagerInterface $entityManager
    ) {}

    /**
     * @throws ChatNotFoundException
     */
    public function __invoke(CreateChatMessageCommand $command): ChatMessageDTO
    {
        $chat = $this->chatRepository->findOneBy(['uuid' => $command->chatUuid]);
        if ($chat === null) {
            throw new ChatNotFoundException();
        }

        // Обработка запроса пользователя
        $userQuestion = $command->question;
        $userQuestion = html_entity_decode($userQuestion, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $userQuestion = strip_tags($userQuestion);
        $userQuestion = preg_replace('/s+/', ' ', $userQuestion);

        // Получаем последних 10 сообщений чата, для формирования истории для LLM
        $query = new ChatMessageListQuery(
            $chat->getId(),
            ChatMessageListQuery::SORT_BY_ID_DESC,
            1,
            100
        );

        /** @var EntityListDTO $dto */
        $dto = $this->queryBus->execute($query);
        $messages = [];
        /** @var ChatMessageDTO $message */
        foreach ($dto->entities as $message) {
            if ($message->getRole() === ChatMessageEntity::ROLE_USER) {
                $messages[] = new UserMessage($message->getText());
            }
            else {
                $messages[] = new AssistantMessage($message->getText());
            }
        }

        // Контролируем кол-во входящих токенов и при необходимости убираем более старые сообщения
        // ...
        // ... выше опустил часть кода, которая контролирует кол-во входящих токенов

        $messages[] = new UserMessage($userQuestion);

        $response = ChatBot::make()->chat($messages);

        $json = json_decode($response->getContent(), true);
        if (json_last_error() === JSON_ERROR_NONE) {
            $answer = $json['answer'] ?? 'По вашему запросу ничего не найдено';
            if (isset($json['sources'])) {
                $links = [];
                $documentsIDs = [];

                foreach ($json['sources'] as $source) {
                    if (!preg_match('/^[a-z]+_d+$/', $source)) {
                        continue;
                    }

                    [$docType, $docID] = explode('_', $source);
                    $documentsIDs[$docType][$docID] = $docID;
                }

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

                if (count($links)) {
                    $answer .= "nnИсточники: ".join(', ', array_unique($links));
                }
            }
        }
        else {
            $answer = 'Не удалось обработать ваш запрос';
        }

        return $this->entityManager->wrapInTransaction(function () use ($chat, $userQuestion, $answer) {

            $userChatMessage = new ChatMessageEntity();
            $userChatMessage->setChat($chat);
            $userChatMessage->setText($userQuestion);
            $userChatMessage->setRole(ChatMessageEntity::ROLE_USER);
            $this->chatMessageRepository->save($userChatMessage);

            $assistantChatMessage = new ChatMessageEntity();
            $assistantChatMessage->setChat($chat);
            $assistantChatMessage->setText($answer);
            $assistantChatMessage->setRole(ChatMessageEntity::ROLE_ASSISTANT);
            $this->chatMessageRepository->save($assistantChatMessage);

            return new ChatMessageDTO($assistantChatMessage);
        });
    }
}
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 9 [3]

Перед тем как взглянуть на реализацию класса ChatBot, нужно имплементировать последний недостающий элемент системы – обращение в API YandexGPT для генерации ответа на вопрос пользователя на основе переданного контекста. Для работы с API YandexGPT в Neuron AI нет реализации, но есть реализация OpenAI API, и ввиду того, что YandexGPT поддерживает совместимость с OpenAI API, то нам достаточно унаследоваться от текущей реализации OpenAI.

<?php

class YandexGPTOpenAICompatibilityProvider extends OpenAI  
{  
    protected string $baseUri = 'https://llm.api.cloud.yandex.net/v1';
}
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 10 [3]

На самом деле сначала реализация была всего несколько строк, но в ходе тестирования выяснилось, что на некоторые запросы YandexGPT может вернуть ответ с пометкой “Запрещённый контент”, где в ответе отсутствует параметр usage.completion_tokens, что приводило к выбросу исключения. Пришлось скопировать функцию chatAsync, чтобы добавить Null Coalescing Operator

//...
$response->setUsage(
    new Usage(
        $result['usage']['prompt_tokens'],
        $result['usage']['completion_tokens'] ?? 0
    )
);
//...
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 11 [3]

В итоге полный код вышел таким:

YandexGPTOpenAICompatibilityProvider.php
<?php

namespace AppModuleRAGProvider;

use GuzzleHttpPromisePromiseInterface;
use GuzzleHttpRequestOptions;
use NeuronAIChatEnumsMessageRole;
use NeuronAIChatMessagesMessage;
use NeuronAIChatMessagesUsage;
use NeuronAIProvidersOpenAIOpenAI;
use PsrHttpMessageResponseInterface;

class YandexGPTOpenAICompatibilityProvider extends OpenAI
{
    protected string $baseUri = 'https://llm.api.cloud.yandex.net/v1';

    public function chatAsync(array $messages): PromiseInterface
    {
        if (isset($this->system)) {
            array_unshift($messages, new Message(MessageRole::SYSTEM, $this->system));
        }

        $json = [
            'model' => $this->model,
            'messages' => $this->messageMapper()->map($messages),
            ...$this->parameters
        ]; 

        if (!empty($this->tools)) {
            $json['tools'] = $this->toolPayloadMapper()->map($this->tools);
        }

        return $this->client->postAsync('chat/completions', [RequestOptions::JSON => $json])
            ->then(function (ResponseInterface $response) {
                $result = json_decode($response->getBody()->getContents(), true);

                if ($result['choices'][0]['finish_reason'] === 'tool_calls') {
                    $response = $this->createToolCallMessage($result['choices'][0]['message']);
                } else {
                    $response = $this->createAssistantMessage($result);
                }

                if (array_key_exists('usage', $result)) {
                    $response->setUsage(
                        new Usage(
                            $result['usage']['prompt_tokens'],
                            $result['usage']['completion_tokens'] ?? 0
                        )
                    );
                }

                return $response;
            });
    }
}
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 12 [3]

Собираем всё воедино в классе ChatBot. В отличие от примера из документации Neuron AI [7], пришлось дополнительно подключить собственный SimilarityRetrieval, поскольку используем гибридный поиск и нужно передавать оригинальный текст вопроса в Qdrant (в стандартной реализации передаётся только готовый embedding). Также добавили системный prompt в instructions() и формат ответа для LLM в конструкторе YandexGPTOpenAICompatibilityProvider.

SimilarityRetrieval.php
<?php

namespace AppModuleRAG;

use NeuronAIChatMessagesMessage;
use NeuronAIRAGEmbeddingsEmbeddingsProviderInterface;
use NeuronAIRAGRetrievalRetrievalInterface;
use NeuronAIRAGVectorStoreVectorStoreInterface;

class SimilarityRetrieval implements RetrievalInterface
{
    public function __construct(
        private readonly VectorStoreInterface $vectorStore,
        private readonly EmbeddingsProviderInterface $embeddingProvider,
    ) {
    }

    public function retrieve(Message $query): array
    {
        return $this->vectorStore->hybridSearch(
            $this->embeddingProvider->embedText($query->getContent()),
            $query->getContent()
        );
    }
}
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 13 [3]
ChatBot.php
<?php

namespace AppModuleRAG;

use AppModuleRAGEmbeddingYandexGPTEmbedding;
use AppModuleRAGProviderYandexGPTOpenAICompatibilityProvider;
use AppModuleRAGVectorStoreQdrantVectorStore;
use AppSharedHelperUtils;
use NeuronAIProvidersAIProviderInterface;
use NeuronAIProvidersHttpClientOptions;
use NeuronAIRAGEmbeddingsEmbeddingsProviderInterface;
use NeuronAIRAGRAG;
use NeuronAIRAGRetrievalRetrievalInterface;
use NeuronAIRAGVectorStoreVectorStoreInterface;

class ChatBot extends RAG
{
    protected function retrieval(): RetrievalInterface
    {
        return new SimilarityRetrieval(
            $this->resolveVectorStore(),
            $this->resolveEmbeddingsProvider()
        );
    }

    public function instructions(): string
    {
        return 'Ты - ассистент, который отвечает только на основе предоставленного контекста. Отвечай без каких-либо дополнительных текстов, комментариев или форматирования. Не вступай в диалог с пользователем. Если ответить на вопрос не получается, тогда отвечай фразой - По вашему запросу ничего не найдено. ';
    }

    protected function provider(): AIProviderInterface
    {
        return new YandexGPTOpenAICompatibilityProvider(
            '',
            'gpt://'.Utils::getEnvVariable('YC_FOLDER_ID').'/yandexgpt/latest',
            [
                'response_format' => [
                    'type' => 'json_schema',
                    'json_schema' => [
                        'description' => 'Ответ на вопрос пользователя и источники, используемые для ответа',
                        'name' => 'answer',
                        'schema' => [
                            'type' => 'object',
                            'properties' => [
                                'answer' => [
                                    'type' => 'string',
                                    'description' => 'Текст ответа на вопрос пользователя',
                                ],
                                'sources' => [
                                    'type' => 'array',
                                    'items' => ['type' => 'string'],
                                    'description' => 'Список источников из контекста, на которые опирался ответ',
                                ],
                            ],
                            'additionalProperties' => false,
                            'required' => ['answer', 'sources']
                        ],
                        'strict' => true,
                    ],
                ]
            ],
            false,
            new HttpClientOptions(
                15,
                3,
                [
                    'Authorization' => 'Api-Key '.Utils::getEnvVariable('YC_API_KEY'),
                    'x-data-logging-enabled' => 'false',
                ]
            )
        );
    }

    protected function embeddings(): EmbeddingsProviderInterface
    {
        return new YandexGPTEmbedding(YandexGPTEmbedding::MODEL_TYPE_QUERY);
    }

    protected function vectorStore(): VectorStoreInterface
    {
        return new QdrantVectorStore(
            Utils::getEnvVariable('QDRANT_DOCS_COLLECTION_URL'),
            5
        );
    }
}
RAG на PHP + Qdrant: быстрый MVP для внутренней базы знаний - 14 [3]

Что дальше

Эксперимент удался, планируем дальше развивать систему, в планах следующее:

  1. Уйдём на написание своего “велосипеда”, т.к. по ходу разработки и тестирования наши знания о RAG расширялись и где-то становилось неудобно допиливать Neuron AI. Написать свою реализацию не займёт много времени, по своей сути, это просто цепочка вызовов разных API и работа с результатами этих вызовов.

  2. Сохранять вопрос пользователя в отдельную коллекцию Qdrant, что вместе с системой лайков может дать экономию на LLM. Например задали вопрос “Какая программа у мероприятия Туризм в России”, пользователь получил ответ, считает, что ответ верный и ставит лайк. Если в следующий раз кто-то задаст похожий вопрос, score которого будет максимально близок к 1, и при этом у ответа есть лайк, то можем сразу вернуть прошлый ответ без лишнего похода в LLM.

  3. Система подсказок. Когда чат-бот отвечает, то под полем ввода появляются подсказки-вопросы, которые релевантны текущему ответу или вопросу. С помощью них можно быстро продолжить дальнейшую беседу.

Автор: Sparknsk

Источник [8]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/25883

URLs in this post:

[1] https://github.com/neuron-core/neuron-ai: https://github.com/neuron-core/neuron-ai

[2] https://github.com/LLPhant/LLPhant: https://github.com/LLPhant/LLPhant

[3] Image: https://sourcecraft.dev/

[4] http://localhost:6333/dashboard: http://localhost:6333/dashboard

[5] https://yandex.cloud/ru/docs/ai-studio/concepts/embeddings: https://yandex.cloud/ru/docs/ai-studio/concepts/embeddings

[6] память: http://www.braintools.ru/article/4140

[7] примера из документации Neuron AI: https://docs.neuron-ai.dev/rag/rag

[8] Источник: https://habr.com/ru/articles/1001156/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1001156

www.BrainTools.ru

Rambler's Top100