Почему 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 ГБ), но дает качественные результаты.
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 — не взят с потолка. В тестах на технической документации он показывает лучший баланс между семантическим поиском и точным попаданием.
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-систему
- Не чистите текст перед чанкингом. PDF-файлы содержат мусор: нумерацию страниц, headers/footers. Их нужно удалять, иначе они попадут в эмбеддинги и испортят поиск.
- Игнорируете метаданные. Сохраняйте в payload Qdrant не только текст, но и раздел документа, автора, дату. Потом можно фильтровать по ним: "Ищи только в API-документации за 2025 год".
- Забываете про инкрементальное обновление. Документы меняются. Нужно уметь обновлять чанки, а не пересоздавать всю коллекцию. Храните хеш исходного контента в payload и сравнивайте.
- Используете одну модель для всего. Эмбеддинги для поиска и LLM для генерации — это разные задачи. Не пытайтесь заставить Llama 3.2 делать эмбеддинги или nomic-embed-text генерировать ответы.
Если столкнулись с проблемой "система находит нерелевантные фрагменты", сначала проверьте эмбеддинги. Заэмбеддите тестовые фразы и посмотрите на их косинусную близость. Может оказаться, что модель эмбеддингов просто плохо понимает вашу предметную область.
А что насчет продакшена?
Эта система — MVP. Для продакшена нужно добавить:
- Кэширование. Эмбеддинги одних и тех же фраз не должны генерироваться каждый раз. Redis на помощь.
- Мониторинг. Считайте accuracy: сколько ответов были полезными. Без метрик вы летите вслепую.
- Очереди для обработки документов. Загрузка 100-страничного PDF не должна блокировать API. Отправляйте в RabbitMQ или Symfony Messenger.
- Аутентификацию и аудит. Кто что искал — должно логироваться.
И главное — не пытайтесь сделать идеальную систему с первого раза. Запустите MVP на реальных пользователях, соберите feedback. Узнайте, какие вопросы они задают чаще всего, какие документы ищут. Потом оптимизируйте.
PHP-экосистема для AI ещё молода, но она развивается. Такие инструменты, как Neuron AI, делают её конкурентоспособной для внутренних проектов. И иногда лучший инструмент — не самый модный, а тот, который уже стоит у вашей команды в продакшене.
Если хотите глубже погрузиться в создание AI-систем, посмотрите курс «AI-креатор: создаём контент с помощью нейросетей». Там разбирают не только техническую часть, но и как интегрировать AI в реальные бизнес-процессы.