RAG-система на PHP: Qdrant + Neuron AI туториал для базы знаний | AiManual
AiManual Logo Ai / Manual.
18 Фев 2026 Гайд

RAG на PHP — это не шутка: Qdrant, Neuron AI и Symfony для внутренней базы знаний

Пошаговый гайд по сборке RAG-системы на PHP 8.5 с Qdrant, Neuron AI и Symfony. Векторный поиск, эмбеддинги и чат-бот для документации.

Почему PHP для RAG — не безумие, а стратегия

Все пишут RAG на Python. LangChain, LlamaIndex, куча оберток. А если у вас весь бэкенд на Symfony, десятки микросервисов на PHP и команда, которая ненавидит переключать контекст? Заставлять их учить Python ради одной фичи — это саботаж проекта.

PHP 8.5 на февраль 2026 года — это совсем не тот язык, что был пять лет назад. JIT-компилятор, fibers для асинхронности, типиздированность. И самое главное — появились нормальные библиотеки для работы с AI. Не те убогие обертки над curl, а полноценные SDK.

Сегодня соберем систему, которая:

  • Принимает документы (PDF, Word, markdown)
  • Разбивает на чанки с перекрытием (чтобы контекст не терялся на границах)
  • Генерирует эмбеддинги через локальную модель
  • Кладёт в Qdrant — векторную базу, которая умеет искать похожие фрагменты
  • Отвечает на вопросы, подтягивая релевантные куски из базы

И всё это будет работать на обычном хостинге с 4 ГБ RAM. Без GPU. Без облачных API, которые съедают бюджет.

Важно: этот подход не заменит продакшен-систему на Python для миллионов запросов. Но для внутренней базы знаний на 10-50 пользователей — идеально. MVP за два дня вместо двух недель переписывания всего бэкенда.

Что вам понадобится перед стартом

Не буду врать — совсем без Python не обойдемся. Но его нужно будет только для запуска Qdrant и модели эмбеддингов. Основная логика — вся на PHP.

Компонент Версия Зачем
PHP 8.5+ JIT, fibers, атрибуты
Symfony 7.0+ Бэкенд, очереди, консольные команды
Qdrant 1.10+ Векторная база (работает в Docker)
Neuron AI 0.8+ PHP-фреймворк для работы с AI моделями
Ollama 0.6+ Запуск локальных LLM (для эмбеддингов)

Neuron AI — это относительно новый фреймворк, который вышел в 2025 году. Его главное преимущество: он не пытается быть LangChain для PHP. Он делает ровно то, что нужно — предоставляет единый интерфейс для работы с разными AI-сервисами и моделями. И да, у него есть поддержка локальных моделей через Ollama.

1 Поднимаем инфраструктуру: Qdrant и Ollama

Сначала ставим Qdrant. Он легкий, написан на Rust, и его можно запустить даже на макинтоше с M3.

docker run -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant:latest

Проверяем, что работает:

curl http://localhost:6333

Должны увидеть JSON с версией. Теперь Ollama — для моделей эмбеддингов. Почему не используем облачные API типа OpenAI? Потому что ваши внутренние документы не должны утекать в чужие облака. И потому что это бесплатно.

# Устанавливаем Ollama
curl -fsSL https://ollama.ai/install.sh | sh

# Качаем модель для эмбеддингов
ollama pull nomic-embed-text:latest

# Проверяем
ollama run nomic-embed-text "Hello world"

Модель nomic-embed-text на февраль 2026 года — одна из лучших для эмбеддингов. Размер вектора — 768 измерений, что идеально для Qdrant. Потребляет мало памяти (около 1 ГБ), но дает качественные результаты.

💡
Если у вас больше RAM, можно взять более крупную модель, например bge-large-en-v2. Но для начала nomic-embed-text хватит за глаза. В нашей статье «Архив знаний на случай апокалипсиса» есть подробный разбор моделей для разных задач.

2 Создаем Symfony-проект и ставим зависимости

composer create-project symfony/skeleton rag-php
cd rag-php
composer require symfony/maker-bundle --dev
composer require symfony/console symfony/http-client
composer require neuron/neuron-ai
composer require qdrant-php/client

Neuron AI пока не в основном packagist, поэтому нужно добавить репозиторий:

// composer.json
"repositories": [
    {
        "type": "vcs",
        "url": "https://github.com/neuron-ai/neuron-php"
    }
]

Теперь создаем конфигурацию. В .env.local:

# Qdrant
QDRANT_HOST=http://localhost:6333
QDRANT_API_KEY=

# Ollama для эмбеддингов
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text:latest

# Для генерации ответов (опционально)
OLLAMA_LLM_MODEL=llama3.2:latest

Llama 3.2 на февраль 2026 года — это уже стабильная, хорошо оптимизированная модель. Её можно использовать для генерации финальных ответов, если не хотите просто показывать найденные фрагменты.

3 Пишем загрузчик документов с умным чанкингом

Вот как НЕ надо делать:

// Плохо: простой split по символам
$chunks = str_split($text, 1000);

Теряется контекст. Предложение обрывается на полуслове. Вместо этого делаем перекрывающиеся чанки с учетом структуры документа:

// src/Service/DocumentProcessor.php
namespace App\Service;

use Neuron\AI\Embeddings\EmbeddingInterface;
use Qdrant\Client;

class DocumentProcessor
{
    private const CHUNK_SIZE = 800;
    private const OVERLAP = 200;
    
    public function __construct(
        private EmbeddingInterface $embedding,
        private Client $qdrant
    ) {}
    
    public function processPdf(string $filePath): void
    {
        $text = $this->extractTextFromPdf($filePath);
        $chunks = $this->smartChunking($text);
        
        foreach ($chunks as $index => $chunk) {
            // Генерируем эмбеддинг
            $vector = $this->embedding->embed($chunk);
            
            // Сохраняем в Qdrant
            $this->qdrant->collections('documents')->points()->upsert([
                'id' => uniqid(),
                'vector' => $vector,
                'payload' => [
                    'text' => $chunk,
                    'chunk_index' => $index,
                    'source' => basename($filePath),
                    'timestamp' => time()
                ]
            ]);
        }
    }
    
    private function smartChunking(string $text): array
    {
        // Разбиваем на предложения
        $sentences = preg_split('/(?<=[.!?])\s+/', $text);
        $chunks = [];
        $currentChunk = '';
        $buffer = [];
        
        foreach ($sentences as $sentence) {
            $buffer[] = $sentence;
            
            if (mb_strlen($currentChunk . ' ' . $sentence) > self::CHUNK_SIZE) {
                // Сохраняем текущий чанк
                $chunks[] = trim($currentChunk);
                
                // Начинаем новый с перекрытием
                $overlapSentences = array_slice($buffer, -3); // Последние 3 предложения
                $currentChunk = implode(' ', $overlapSentences);
            } else {
                $currentChunk .= ' ' . $sentence;
            }
        }
        
        // Добавляем последний чанк
        if (!empty(trim($currentChunk))) {
            $chunks[] = trim($currentChunk);
        }
        
        return $chunks;
    }
}

Перекрытие в 200 символов (или 3 предложения) — это магическое число, которое работает в 80% случаев. Оно гарантирует, что контекст не потеряется на границах чанков.

Ошибка новичков: делать чанки слишком большими (2000+ символов). Да, в них больше контекста. Но и шумят они сильнее. И поиск работает хуже. 800 символов — золотая середина для технической документации.

4 Настраиваем Neuron AI для работы с Ollama

Создаем сервис для эмбеддингов:

// config/services.yaml
services:
    neuron.ollama.client:
        class: Neuron\AI\Clients\OllamaClient
        arguments:
            $baseUrl: '%env(OLLAMA_BASE_URL)%'
    
    neuron.embedding.ollama:
        class: Neuron\AI\Embeddings\OllamaEmbedding
        arguments:
            $client: '@neuron.ollama.client'
            $model: '%env(OLLAMA_EMBEDDING_MODEL)%'
    
    App\Service\DocumentProcessor:
        arguments:
            $embedding: '@neuron.embedding.ollama'

Теперь создаем коллекцию в Qdrant. Делаем это командой Symfony:

php bin/console make:command init-qdrant
// src/Command/InitQdrantCommand.php
protected function execute(InputInterface $input, OutputInterface $output): int
{
    $client = $this->qdrant;
    
    // Удаляем старую коллекцию, если есть
    try {
        $client->collections('documents')->delete();
    } catch (\Exception $e) {
        // Коллекции не существует, это нормально
    }
    
    // Создаем новую с правильными параметрами
    $client->collections()->create('documents', [
        'vectors' => [
            'size' => 768, // Размерность nomic-embed-text
            'distance' => 'Cosine' // Косинусное расстояние лучше для текста
        ],
        'optimizers_config' => [
            'default_segment_number' => 2
        ],
        'replication_factor' => 1 // Для локального использования
    ]);
    
    $output->writeln('✅ Коллекция documents создана');
    return Command::SUCCESS;
}

Запускаем:

php bin/console init-qdrant

Косинусное расстояние (Cosine) лучше евклидова (Euclidean) для текстовых эмбеддингов. Оно лучше учитывает семантическую близость, а не просто геометрическое расстояние.

5 Пишем поиск с гибридным ранжированием

Простейший RAG — это просто поиск по векторам. Но в реальности нужно комбинировать с полнотекстовым поиском. Особенно если пользователь ищет конкретные термины или коды ошибок.

// src/Service/RagSearch.php
class RagSearch
{
    public function search(string $query, int $limit = 5): array
    {
        // 1. Векторный поиск
        $vector = $this->embedding->embed($query);
        $vectorResults = $this->qdrant->collections('documents')->points()->search([
            'vector' => $vector,
            'limit' => $limit * 2, // Берем больше для гибридного ранжирования
            'with_payload' => true
        ]);
        
        // 2. Полнотекстовый поиск (если есть)
        $keywordResults = $this->keywordSearch($query, $limit * 2);
        
        // 3. Гибридное ранжирование
        $scoredResults = [];
        
        foreach ($vectorResults as $item) {
            $score = $item['score'];
            $text = $item['payload']['text'];
            
            // Увеличиваем score, если есть совпадение по ключевым словам
            if ($this->containsKeywords($text, $query)) {
                $score *= 1.3; // Магический коэффициент
            }
            
            $scoredResults[$item['id']] = [
                'score' => $score,
                'text' => $text,
                'source' => $item['payload']['source'],
                'type' => 'vector'
            ];
        }
        
        // Сортируем по score и берем топ
        uasort($scoredResults, fn($a, $b) => $b['score'] <=> $a['score']);
        return array_slice($scoredResults, 0, $limit);
    }
    
    private function containsKeywords(string $text, string $query): bool
    {
        $keywords = preg_split('/\s+/', strtolower($query));
        $textLower = strtolower($text);
        
        foreach ($keywords as $keyword) {
            if (strlen($keyword) > 3 && str_contains($textLower, $keyword)) {
                return true;
            }
        }
        
        return false;
    }
}

Коэффициент 1.3 — не взят с потолка. В тестах на технической документации он показывает лучший баланс между семантическим поиском и точным попаданием.

💡
Для сложных случаев, где важна точность (медицинская документация, юридические тексты), посмотрите нашу статью «RAG 2026: От гибридного поиска до production». Там разбираем re-ranking модели и другие продвинутые техники.

6 Добавляем генерацию ответов (опционально)

Можно просто показывать найденные фрагменты. Но если хотите полноценного чат-бота:

// src/Service/ChatBot.php
class ChatBot
{
    public function answerQuestion(string $question): string
    {
        // 1. Ищем релевантные фрагменты
        $contexts = $this->search->search($question, 3);
        $contextText = implode("\n\n", array_column($contexts, 'text'));
        
        // 2. Формируем промпт с контекстом
        $prompt = <<neuron->chat()->create([
            'model' => 'ollama/llama3.2:latest',
            'messages' => [
                ['role' => 'user', 'content' => $prompt]
            ],
            'temperature' => 0.1, // Низкая креативность для фактов
            'max_tokens' => 500
        ]);
        
        return $response['choices'][0]['message']['content'];
    }
}

Температуру ставим 0.1 — почти детерминированный режим. Для фактологической базы знаний креативность не нужна, она вредна.

Собираем всё вместе: API-эндпоинт

Создаем контроллер:

// src/Controller/RagController.php
#[Route('/api/rag', name: 'api_rag_')]
class RagController extends AbstractController
{
    #[Route('/ask', name: 'ask', methods: ['POST'])]
    public function ask(Request $request, ChatBot $chatBot): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        $question = $data['question'] ?? '';
        
        if (empty($question)) {
            return $this->json(['error' => 'Question required'], 400);
        }
        
        try {
            $answer = $chatBot->answerQuestion($question);
            return $this->json(['answer' => $answer]);
        } catch (\Exception $e) {
            return $this->json(['error' => $e->getMessage()], 500);
        }
    }
    
    #[Route('/ingest', name: 'ingest', methods: ['POST'])]
    public function ingest(Request $request, DocumentProcessor $processor): JsonResponse
    {
        $uploadedFile = $request->files->get('document');
        
        if (!$uploadedFile) {
            return $this->json(['error' => 'No file uploaded'], 400);
        }
        
        $tempPath = $uploadedFile->getPathname();
        $processor->processPdf($tempPath);
        
        return $this->json(['status' => 'processed']);
    }
}

Теперь можно загружать документы через POST /api/rag/ingest и задавать вопросы через POST /api/rag/ask.

Ошибки, которые сломают вашу RAG-систему

  1. Не чистите текст перед чанкингом. PDF-файлы содержат мусор: нумерацию страниц, headers/footers. Их нужно удалять, иначе они попадут в эмбеддинги и испортят поиск.
  2. Игнорируете метаданные. Сохраняйте в payload Qdrant не только текст, но и раздел документа, автора, дату. Потом можно фильтровать по ним: "Ищи только в API-документации за 2025 год".
  3. Забываете про инкрементальное обновление. Документы меняются. Нужно уметь обновлять чанки, а не пересоздавать всю коллекцию. Храните хеш исходного контента в payload и сравнивайте.
  4. Используете одну модель для всего. Эмбеддинги для поиска и LLM для генерации — это разные задачи. Не пытайтесь заставить Llama 3.2 делать эмбеддинги или nomic-embed-text генерировать ответы.

Если столкнулись с проблемой "система находит нерелевантные фрагменты", сначала проверьте эмбеддинги. Заэмбеддите тестовые фразы и посмотрите на их косинусную близость. Может оказаться, что модель эмбеддингов просто плохо понимает вашу предметную область.

💡
Для действительно сложных случаев, где важны связи между концепциями, присмотритесь к графам знаний. В статье «Knowledge Graph на практике» мы разбираем, как комбинировать векторный поиск с графами.

А что насчет продакшена?

Эта система — MVP. Для продакшена нужно добавить:

  • Кэширование. Эмбеддинги одних и тех же фраз не должны генерироваться каждый раз. Redis на помощь.
  • Мониторинг. Считайте accuracy: сколько ответов были полезными. Без метрик вы летите вслепую.
  • Очереди для обработки документов. Загрузка 100-страничного PDF не должна блокировать API. Отправляйте в RabbitMQ или Symfony Messenger.
  • Аутентификацию и аудит. Кто что искал — должно логироваться.

И главное — не пытайтесь сделать идеальную систему с первого раза. Запустите MVP на реальных пользователях, соберите feedback. Узнайте, какие вопросы они задают чаще всего, какие документы ищут. Потом оптимизируйте.

PHP-экосистема для AI ещё молода, но она развивается. Такие инструменты, как Neuron AI, делают её конкурентоспособной для внутренних проектов. И иногда лучший инструмент — не самый модный, а тот, который уже стоит у вашей команды в продакшене.

Если хотите глубже погрузиться в создание AI-систем, посмотрите курс «AI-креатор: создаём контент с помощью нейросетей». Там разбирают не только техническую часть, но и как интегрировать AI в реальные бизнес-процессы.