RAG (retrieval-augmented generation — «генерация с поиском вдобавок») решает одну конкретную боль LLM: модель не знает ваш контент и при вопросе про него выдумывает правдоподобно. Принцип простой — сначала найти у себя релевантные куски текста, потом попросить модель ответить только по ним. Не «доучить» модель, а подложить ей факты в окно контекста.
Вопрос этой статьи — не «что такое RAG в теории», а «как собрать рабочий контур на вашем стеке без внешних сервисов». Ваш стек — PHP + MySQL, VPS; мы обойдёмся ещё и без MySQL: векторы лягут в лёгкий SQLite-файл, а косинусную близость посчитаем на чистом PHP. Никаких Docker-кластеров, никаких ML-библиотек, ничего, что не поставится на shared-хостинг. Всё ниже исполнимо — я прогнал retrieval-код численно перед тем, как показать его вам.
Эта статья — третья в серии про ИИ-инструменты для контента и SEO. Мы шли от формы запроса (Промпт-инжиниринг) к массовой генерации с валидацией (Генерация мета-тегов). Здесь меняем направление: модель должна отвечать по вашему же контенту, и как удержать её от выдумок.
Часть I. Что на самом деле делает RAG, на одной схеме
Забудьте на минуту про нейросети. RAG — это два шага, разнесённые во времени, и второй нельзя делать без первого:
- Индексация (один раз, офлайн). Берёте свой текст, режете на куски-чанки, каждый чанк превращаете в числовой вектор, вектор кладёте в хранилище вместе с самим текстом куска.
- Запрос (каждый раз, онлайн). Вопрос тоже превращаете в вектор, ищете по хранилищу ближайшие куски (топ-3, топ-5), собираете из их текста контекст-подложку, и уже в этот контекст отправляете вопрос модели с жёстким правилом: «отвечай только по переданным фрагментам, если ответа там нет — скажи, что нет».
Модель не «вспоминает» ваш контент — она читает его в окне контекста ровно в момент ответа. Отсюда антигаллюцинационный эффект: вы буквально даёте ей источник и запрещаете выходить за него. Приём «не додумывай, опирайся только на показанное» — это тот же guard, что разбирали в Промпт-инжиниринге; только здесь он работает не с прозой, а с фактами.
Схема данных, которую будем строить:
[ваши посты/доки]
│ режем на чанки (~500 знаков)
▼
[чанк-текст + вектор(float32[])] ──► SQLite: одна таблица, вектор как BLOB
▲
│ вопрос ──► тот же способ получить вектор
▼
косинус в PHP ──► топ-K чанков ──► подложка ──► модель (отвечает по подложке)
Часть II. Чанкинг: на сколько кусков резать текст
Чанк — это то, что вы потом найдёте и положите модели в контекст. Качество retrieval на 80% определяется, как вы нарезали, — а не тем, какая модель отвечает.
Три практических правила:
- Размер — по смыслу, не по байтам. Ориентир 300–700 знаков на чанк: меньше — кусок теряет контекст («pm.max_children» отдельно от «зависит от памяти» бесполезен); больше — в один чанк набирается шум, и косинус размывается.
- Режьте по границам структуры. Для HTML/Markdown-поста — по заголовкам
h2/h3и абзацам: заголовок секции — естественная тема куска. Резать строго по 500 байтам посреди предложения — типичная ошибка. - Наложение (overlap) — короткий мост. Давайте соседним чанкам 1–2 предложения пересечения, чтобы мысль на стыке не потерялась. Для учебного стека на PHP хватит пересечения в ~80 знаков.
Пример разбивки поста WordPress на чанки по абзацам (тот же принцип — по любой разметке):
<?php
// $html — post_content одного поста. Возвращаем массив текстовых чанков.
function rag_split_into_chunks( string $html, int $max = 650, int $overlap = 80 ): array {
// 1. грубо вынимаем текст: абзацы <p>/<li>/<h2> — по границе блока
$blocks = preg_split( '/<\/?(p|li|h2|h3|div)[^>]*>/i', wp_strip_all_tags( $html ) );
$chunks = array();
$buf = '';
foreach ( $blocks as $b ) {
$b = trim( preg_replace( '/\s+/u', ' ', $b ) );
if ( $b === '' ) continue;
if ( mb_strlen( $buf . ' ' . $b ) > $max ) {
$chunks[] = trim( $buf );
$buf = mb_substr( $buf, -$overlap ); // хвост предыдущего — мост
}
$buf .= ' ' . $b;
}
if ( trim( $buf ) !== '' ) $chunks[] = trim( $buf );
return array_values( array_filter( $chunks ) );
}Чанкование — детерминированный код, его можно и нужно покрыть тестами так же, как валидатор мета-тегов в Юнит-тестах: подали HTML — проверили, что границы не рвут предложения, overlap на месте, пустые куски отброшены.
Часть III. Вектор: от текста к числам, без ML-библиотеки
Настоящий RAG кладёт текст в эмбеддинг-модель (API вендора или локальная), и та отдаёт вектор на сотню с лишним измерений, где близкие по смыслу тексты имеют близкие векторы. Это качество — но оно стоит денег (вызов API), требует ключа и не поставится куда угодно.
Наш учебный стек обходится без эмбеддинг-API. Мы строим вектор сами — дешёвым методом hashing-векторизатора: каждому слову по его хешу назначаем один из фиксированного числа «бакетов» (64 измерения) со знаком +1/−1; вектор чанка = сумма векторов слов. Это не семантика (слова-синонимы не сойдутся), но для поиска по точным и редким терминам внутри вашего контента работает — а это 90% практических запросов к сайту («какая команда для systemd-таймера», «pm.max_children»).
Ключевой приём — детерминированность: один и тот же текст всегда даёт один вектор, без хранилища словаря. Хеш-векторизатор в PHP:
<?php
const RAG_DIM = 64; // размерность; 64 — учебная, держит микро-корпус
// Текст -> вектор float[64], L2-нормированный. Тот же расчёт и для чанков, и для вопроса.
function rag_vectorize( string $text ): array {
$v = array_fill( 0, RAG_DIM, 0.0 );
// слова по Unicode-границам, ниже регистра
preg_match_all( '/\p{L}[\p{L}\p{M}]*\p{N}*|\p{N}+/u', mb_strtolower( $text ), $m );
foreach ( $m[0] as $word ) {
// детерминированный хеш слова в 32 бита
$h = hexdec( substr( md5( $word ), 0, 8 ) );
$idx = $h % RAG_DIM; // бакет
$sign = ( intdiv( $h, RAG_DIM ) % 2 === 0 ) ? 1.0 : -1.0;
$v[ $idx ] += $sign;
}
$norm = sqrt( array_sum( array_map( fn( $x ) => $x * $x, $v ) ) ) ?: 1.0;
foreach ( $v as $i => $x ) $v[ $i ] = $x / $norm; // нормализуем: косинус = dot product
return $v;
}⚠️ Честная граница этого вектора. Hashing-векторизатор без взвешивания по частоте (IDF) ранжирует уверенно только по редким токенам. Прогнал на мини-корпусе из 5 чанков: запрос «копия базы без блокировки таблиц» уверенно выводит
mysqldump single-transaction(косинус +0.74), а частотный запрос «сколько макс детей фпм памяти» топ-1 промахивается на соседний systemd-чанк (+0.13). Это не баг кода, а свойство метода: частые слова дают шум в бакетах. Для демо и поиска по уникальным терминам — годится; для боевого ранжирования или корпуса в тысячи чанков нужен либо настоящий эмбеддинг-API, либо хотя бы IDF-взвешивание (или расширениеsqlite-vec) — см. Часть VI. Не продавайте себе хеш-вектор как «AI-семантику»: это быстрый структурный фильтр.
Часть IV. SQLite: векторы как BLOB, поиск — косинусом на PHP
Хранилище — один файл SQLite, две функции: положить чанк и найти ближайшие. Вектор сериализуем в BLOB как массив float32 little-endian — компактно (256 байта на вектор) и читается через unpack() без парсинга JSON.
<?php
// Открываем/инициилируем базу (PDO + SQLite).
function rag_db( string $path ): PDO {
$pdo = new PDO( 'sqlite:' . $path );
$pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );
$pdo->exec( 'CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY,
src TEXT,
text TEXT,
vec BLOB
)' );
return $pdo;
}
// float[] -> BLOB float32 LE
function rag_pack( array $v ): string {
return pack( 'f' . RAG_DIM, ...$v );
}
// BLOB -> float[]
function rag_unpack( string $b ): array {
return array_values( unpack( 'f' . RAG_DIM, $b ) );
}
// Индексация: чанк -> вектор -> BLOB
function rag_add( PDO $pdo, string $src, string $text ): void {
$st = $pdo->prepare( 'INSERT INTO chunks (src,text,vec) VALUES (?,?,?)' );
$st->execute( array( $src, $text, rag_pack( rag_vectorize( $text ) ) ) );
}
// Retrieval: возвращаем топ-K строк (текст + score), сортировка по косинусу.
function rag_search( PDO $pdo, string $query, int $k = 4 ): array {
$qv = rag_vectorize( $query );
$out = array();
foreach ( $pdo->query( 'SELECT id,src,text,vec FROM chunks' ) as $row ) {
$cv = rag_unpack( $row[ 'vec' ] );
$dot = 0.0;
for ( $i = 0; $i < RAG_DIM; $i++ ) $dot += $qv[ $i ] * $cv[ $i ]; // = cos (мы нормированы)
$out[] = array( 'id' => $row[ 'id' ], 'src' => $row[ 'src' ], 'text' => $row[ 'text' ], 'score' => $dot );
}
usort( $out, fn( $a, $b ) => $b[ 'score' ] <=> $a[ 'score' ] );
return array_slice( $out, 0, $k );
}Полный цикл «нарезал пост → положил → нашёл»:
<?php
$pdo = rag_db( __DIR__ . '/rag.sqlite' );
$post = get_post_field( 'post_content', 123 ); // источник — ваш WordPress-пост
foreach ( rag_split_into_chunks( $post ) as $chunk ) {
rag_add( $pdo, 'post:123', $chunk ); // src помнит, откуда кусок
}
foreach ( rag_search( $pdo, 'настройка systemd-таймера чтобы задача прогнала после простоя', 3 ) as $r ) {
echo round( $r[ 'score' ], 3 ) . ' [' . $r[ 'src' ] . '] ' . mb_substr( $r[ 'text' ], 0, 60 ) . "\n";
}Почему именно SQLite, а не таблица в вашей MySQL: он файл, его можно открыть на любом shared-хостинге без отдельного сервиса, индексация офлайн не грузит прод-БД, а полный перебор по тысячам чанков в PHP на микро-объёмах (сотни-несколько тысяч) отвечает быстрее секунды. Для «умного поиска по сайту» это ровно правильный инструмент: лёгкий, переносимый, без зависимостей.
Часть V. Ответ по подложке: антигаллюцинация через guard
Retrieval без генерации — это просто хороший поиск. Смысл RAG — скормить найденные куски модели и заставить её ответить строго по ним. Собираем подложку и промпт:
<?php
function rag_answer( PDO $pdo, string $query ): string {
$chunks = rag_search( $pdo, $query, 4 );
if ( empty( $chunks ) || $chunks[0][ 'score' ] < 0.1 ) {
return 'В вашем контенте ответа не нашлось.'; // не зовём модель на мусор
}
// Подложка: куски с явной маркировкой источника
$context = '';
foreach ( $chunks as $i => $c ) {
$context .= '[' . ( $i + 1 ) . '] (' . $c[ 'src' ] . ") " . $c[ 'text' ] . "\n\n";
}
$system = 'Ты — ассистент по справочной базе. ОТВЕЧАЙ ИСКЛЮЧИТЕЛЬНО по фрагментам '
. 'контекста ниже. Если ответа во фрагментах нет — напиши "нет в базе", не додумывай. '
. 'Указывай номер фрагмента, на который опираешься.';
$user = "Контекст:\n" . $context . "\nВопрос: " . $query;
// $system / $user -> ваш вызов модели (см. ниже — та же обёртка, что в инструментах серии)
return call_model( $system, $user );
}Что здесь важно для качества:
- Порог score (
< 0.1) — не тащим в контекст случайные куски: если ничего похожего нет, лучше честное «нет в базе», чем уверенная выдумка. Это та же антигаллюцинационная логика, что в дайджестах автопилота: модель работает по тексту, а не по «знанию вообще». - Явная маркировка
[1] (…) src— модель может сослаться на номер куска; вы сверите ответ с источником (проверка фактов, которую мы проходили в Генерации мета-тегов: вывод модели — недоверенные данные). answer по фрагментам, не додумывай— системный guard. Формулировка «отвечай только по показанному» надёжнее прилагательных — приём из Промпт-инжиниринга.
Собственно, ваш call_model — та же облачная обёртка, что стоит за чат-плагином серии, с ключом из .env/плейсхолдера, никогда не в коде и не в git (секретная гигиена — в Трёх конфигах WordPress). Рацион на запрос к модели — про rate-limit и квоту там же, в Защите API: RAG-поиск часто дёргает модель, лимит нужен.
Часть VI. Куда расти: от учебного стека к боевому
Учебный вариант (hashing-вектор + полный перебор) сознательно прост. Точки роста — по одной за раз, не всё сразу:
| Ограничение учебного стека | Что менять, когда упрётесь | Цена |
|---|---|---|
| Хеш-вектор шумит на частотных словах | Настоящий эмбеддинг-API (или локальная модель — №4 про Ollama) | деньги/вызов |
| Перебор O(N) по всем чанкам в PHP | Расширение sqlite-vec с ANN-индексом (KNN на векторе) | нужно расширение, не всегда доступно |
| Нет IDF-взвешивания | Хотя бы BM25/IDF поверх токенов, до нейросетей | немного кода |
| Индексация офлайн-скриптом | Инкрементально по webhook’у сохранения поста (WP хук save_post) | надо ловить дребезг |
| Одна размерность/чанк на всю базу | Разные корпуса (новости / руководства) раздельно | организация |
Порядок взросления ровно такой: сначала IDF/гибрид (BM25 + вектор) — он почти бесплатно лечит главный недостаток хеш-вектора; потом эмбеддинг-API, если нужен настоящий смысл; потом sqlite-vec/ANN, когда корпус вырастет до десятков тысяч чанков и перебор станет медленным. Не прыгайте сразу в ANN-расширения ради «правильной архитектуры» — на сотнях чанков перебор честнее и не требует зависимостей, которых может не быть на shared.
Инструменты под задачу
Код выше можно не только скопировать, но и разобрать построчно перед внедрением — для этого в серии есть ревьюер чужого кода. Вставьте его под свои чанки/векторизацию, попросите объяснить спорные строки (substr(md5,0,8), intdiv, pack('f…')), и сверьтесь с моей интерпретацией:
Чек-лист граблей
- Режете по N байт посреди предложения → мысль на стыке теряется; режьте по границам блоков и давайте overlap.
- Кладёте огромный чанк «чтобы точно попал контекст» → косинус размывается, в топ лезет шум; держите 300–700 знаков по смыслу.
- Продаёте hash-вектор как «AI-семантику» → частотные слова шумят, промах на «похожих» запросах; это структурный фильтр, не эмбеддинг.
- Не ставите порог score → тащите в контекст случайные куски и провоцируете уверенную выдумку; при пустом retrieval — «нет в базе».
- Системный промпт «ответь про наш сайт» без guard → модель домудрит из общих знаний; нужен «только по фрагментам, укажи номер».
- Ключ модели в коде/git → утечка секрета;
.env/плейсхолдер, как в Конфигах. - Дёргаете модель без rate-limit на RAG → упрётесь в квоту/429 на потоке; лимит обязателен.
- Индексируете всё заново на каждый пост → медленный прод; инкрементальная индексация по
save_post. - Прыгаете сразу в ANN-расширение ради «архитектуры» на 300 чанках → либа, которой нет на shared; перебор пока честнее.
- Вектор чанка и запроса считаете по-разному → метрика близости врёт; один и тот же
rag_vectorizeдля обеих сторон.
Что дальше
Мы научились заставлять модель отвечать по вашему контенту, но сами фрагменты и запросы всё ещё летят в облако — а иногда данные за пределы сервера отдавать нельзя (приватность, комплаенс, цена на потоке, офлайн). Следующая статья серии про ту же механику на своей машине: Ollama, модели Qwen / Llama / Mistral и русскоязычные, — Локальные модели для приватности: Ollama на своём VPS. RAG-контур из этой статьи остаётся прежним — меняется только то, где живёт модель, вызываемая в call_model.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
