RAG для сайта: как сделать умный поиск по своему контенту на SQLite и PHP

RAG (retrieval-augmented generation — «генерация с поиском вдобавок») решает одну конкретную боль LLM: модель не знает ваш контент и при вопросе про него выдумывает правдоподобно. Принцип простой — сначала найти у себя релевантные куски текста, потом попросить модель ответить только по ним. Не «доучить» модель, а подложить ей факты в окно контекста.

Вопрос этой статьи — не «что такое RAG в теории», а «как собрать рабочий контур на вашем стеке без внешних сервисов». Ваш стек — PHP + MySQL, VPS; мы обойдёмся ещё и без MySQL: векторы лягут в лёгкий SQLite-файл, а косинусную близость посчитаем на чистом PHP. Никаких Docker-кластеров, никаких ML-библиотек, ничего, что не поставится на shared-хостинг. Всё ниже исполнимо — я прогнал retrieval-код численно перед тем, как показать его вам.

Эта статья — третья в серии про ИИ-инструменты для контента и SEO. Мы шли от формы запроса (Промпт-инжиниринг) к массовой генерации с валидацией (Генерация мета-тегов). Здесь меняем направление: модель должна отвечать по вашему же контенту, и как удержать её от выдумок.

Часть I. Что на самом деле делает RAG, на одной схеме

Забудьте на минуту про нейросети. RAG — это два шага, разнесённые во времени, и второй нельзя делать без первого:

  1. Индексация (один раз, офлайн). Берёте свой текст, режете на куски-чанки, каждый чанк превращаете в числовой вектор, вектор кладёте в хранилище вместе с самим текстом куска.
  2. Запрос (каждый раз, онлайн). Вопрос тоже превращаете в вектор, ищете по хранилищу ближайшие куски (топ-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
<?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
<?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
<?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
<?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
<?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…')), и сверьтесь с моей интерпретацией:

ИИ-объяснение кода
Гость
Токены: 0 / 50 000
Гостевой лимит — 50 000 токенов. Зарегистрируйтесь, для пользователей лимит + 200 000 токенов.

Чек-лист граблей

  • Режете по 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.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.