Создание умного поиска по сайту с помощью векторных эмбеддингов и RAG

Обычный поиск по сайту работает просто: он ищет точное совпадение слов. Если вы ищете «настройка SSL», а в статье написано «установка сертификата Let’s Encrypt», поиск ничего не найдёт. Это раздражает пользователей и снижает ценность вашего контента.

Векторные эмбеддинги и RAG (Retrieval Augmented Generation) решают эту проблему. Они позволяют искать по смыслу, а не по буквам. А если добавить к этому языковую модель, можно получить умного ассистента, который отвечает на вопросы пользователей на основе вашего контента.

В этой статье мы подробно разберём:

  • Что такое векторные эмбеддинги и как они работают.
  • Что такое RAG и зачем он нужен.
  • Архитектуру системы умного поиска.
  • Пошаговую реализацию: от разбивки контента до поиска по смыслу.
  • Выбор инструментов: локальные и облачные эмбеддинги, векторные базы.
  • Интеграцию с AI-ассистентом для ответов на вопросы.
  • Практические примеры на PHP и Python.
  • Оптимизацию и частые ошибки.

1. Проблема обычного поиска

Представьте, что у вас есть сайт с 500 статьями. Пользователь хочет найти информацию о том, как защитить сервер. Он вводит запрос «безопасность сервера».

Что делает обычный поиск (LIKE ‘%безопасность%’):

  • Ищет точное вхождение слова «безопасность» в заголовках и тексте.
  • Не найдёт статью «Защита SSH от брутфорса», потому что там нет слова «безопасность».
  • Не найдёт статью «Настройка фаервола», хотя она прямо отвечает на запрос.

Результат: пользователь уходит, не найдя ответ.

Что делает умный поиск (векторный):

  • Понимает, что «безопасность сервера», «защита SSH» и «настройка фаервола» — это близкие по смыслу понятия.
  • Находит все три статьи и сортирует их по релевантности.
  • Пользователь получает нужную информацию.

Разница — в понимании смысла.

2. Что такое векторные эмбеддинги

Векторный эмбеддинг — это числовое представление текста в виде массива чисел (вектора). Например, предложение «Защита сервера» может быть представлено как [0.12, -0.45, 0.78, ...] — массив из 768 или 1536 чисел.

Главная идея: если два текста близки по смыслу, их векторы будут близки в многомерном пространстве. Если далеки по смыслу — векторы будут далеко.

Как это работает:

  1. Модель эмбеддингов (например, text-embedding-3-small от OpenAI или nomic-embed-text от Ollama) обучена на огромном количестве текстов.
  2. Она преобразует текст в вектор так, что семантически близкие тексты получают близкие векторы.
  3. Мы можем сравнивать векторы с помощью косинусного сходства (cosine similarity) — чем ближе значение к 1, тем более похожи тексты по смыслу.

Пример:

ТекстВектор (упрощённо)
«Защита сервера»[0.8, 0.2, -0.1]
«Безопасность SSH»[0.7, 0.3, -0.2]
«Рецепт борща»[-0.9, 0.1, 0.5]

Косинусное сходство между первыми двумя — высокое (например, 0.95). Между первым и третьим — низкое (например, 0.1).

Что это даёт:

  • Поиск по смыслу, а не по словам.
  • Работа с синонимами, перефразированиями, опечатками.
  • Возможность искать на разных языках (если модель многоязычная).

3. Что такое RAG

RAG (Retrieval Augmented Generation) — это подход, при котором языковая модель (LLM) генерирует ответ, опираясь на найденные в базе знаний документы.

Зачем это нужно:

  • LLM обучена на общих данных и не знает специфики вашего сайта.
  • Если просто спросить LLM «Как настроить SSL на Nginx?», она ответит в общем виде, но не учтёт ваши статьи.
  • RAG позволяет: сначала найти релевантные документы в вашей базе, затем передать их LLM вместе с вопросом, и LLM сгенерирует ответ на основе вашего контента.

Схема RAG:

Что это даёт:

  • Ответы на основе вашего контента, а не общих знаний модели.
  • Актуальность (вы обновляете статьи — обновляется и база знаний).
  • Ссылки на источники (можно указать, из какой статьи взят ответ).
  • Снижение галлюцинаций (модель опирается на реальные документы).

4. Архитектура системы умного поиска

Система состоит из нескольких компонентов:

  1. Контент — статьи, страницы, документация на вашем сайте.
  2. Чанкинг — разбивка контента на небольшие фрагменты (чанки).
  3. Модель эмбеддингов — преобразует текст в векторы.
  4. Векторная база — хранилище векторов с возможностью быстрого поиска.
  5. Поисковый запрос — преобразуется в вектор той же моделью.
  6. Поиск — находит ближайшие векторы (по косинусному сходству).
  7. LLM — генерирует ответ на основе найденных чанков.
  8. Интерфейс — форма поиска или чат на сайте.

Этапы работы:

Индексация (один раз или при обновлении контента):

  1. Взять все статьи.
  2. Разбить на чанки (например, по 500–1000 символов).
  3. Сгенерировать эмбеддинги для каждого чанка.
  4. Сохранить в векторную базу.

Поиск (при каждом запросе):

  1. Пользователь вводит вопрос.
  2. Сгенерировать эмбеддинг для вопроса.
  3. Найти топ-N ближайших чанков в векторной базе.
  4. Вернуть их пользователю (обычный поиск) или передать LLM для генерации ответа (RAG).

5. Выбор инструментов

5.1 Модели эмбеддингов

МодельТипРазмерностьОсобенности
OpenAI text-embedding-3-smallОблачная1536Высокое качество, платно
OpenAI text-embedding-3-largeОблачная3072Максимальное качество
Cohere EmbedОблачная1024Хорошо для мультиязычности
nomic-embed-textЛокальная768Бесплатно, через Ollama
all-MiniLM-L6-v2Локальная384Лёгкая, быстрая
multilingual-e5-largeЛокальная1024Хорошо для русского

Для русского языка: рекомендуется использовать многоязычные модели, например multilingual-e5-large или nomic-embed-text (поддерживает русский). ВАЖНО! Обратите внимание, что multilingual-e5-large — тяжёлая модель (около 2 ГБ), и при первом запуске она будет долго скачиваться. Для быстрого старта можно использовать nomic-embed-text (через Ollama, легче) или all-MiniLM-L6-v2 (если качество не критично).

Для простоты: можно начать с облачных эмбеддингов (OpenAI, Cohere), если бюджет позволяет. Для приватности — локальные через Ollama.

5.2 Векторные базы

БазаТипОсобенности
ChromaDBЛокальнаяПростая, встраиваемая, Python
QdrantЛокальная/облачнаяМощная, быстрая, поддержка фильтров
WeaviateЛокальная/облачнаяГибкая, с гибридным поиском
MilvusЛокальная/облачнаяДля больших объёмов
PineconeОблачнаяУправляемая, платная
pgvectorРасширение PostgreSQLЕсли уже используете PostgreSQL
Redis VectorРасширение RedisЕсли уже используете Redis

Для начала: ChromaDB — самый простой вариант. Работает локально, не требует установки сервера, легко интегрируется с Python.

Для PHP-проектов: можно использовать Qdrant или pgvector (если есть PostgreSQL).

5.3 LLM для генерации ответов

  • Облачные: OpenAI GPT-4, Claude, YandexGPT.
  • Локальные: Ollama с моделями Mistral, Llama 3, DeepSeek.

Для RAG важно, чтобы модель хорошо следовала инструкциям и не галлюцинировала. Подойдут как облачные, так и локальные модели.

6. Пошаговая реализация

Рассмотрим пример на Python (для индексации) и PHP (для поиска на сайте).

6.1 Шаг 1. Установка зависимостей

Python (для индексации):

Bash
pip install chromadb sentence-transformers

PHP (для поиска):

Bash
composer require guzzlehttp/guzzle

6.2 Шаг 2. Разбивка контента на чанки

chunking.py:

Python
import os
import re

def split_into_chunks(text, chunk_size=800, overlap=100):
    """
    Разбивает текст на чанки по chunk_size символов с перекрытием overlap.
    """
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end]
        chunks.append(chunk)
        start = end - overlap
    return chunks

def load_articles(directory):
    """
    Загружает все .md или .txt файлы из директории.
    """
    articles = []
    for filename in os.listdir(directory):
        if filename.endswith('.md') or filename.endswith('.txt'):
            with open(os.path.join(directory, filename), 'r', encoding='utf-8') as f:
                content = f.read()
                articles.append({
                    'title': filename,
                    'content': content
                })
    return articles

if __name__ == '__main__':
    articles = load_articles('/var/www/codestack/articles')
    all_chunks = []
    for article in articles:
        chunks = split_into_chunks(article['content'])
        for i, chunk in enumerate(chunks):
            all_chunks.append({
                'article': article['title'],
                'chunk_index': i,
                'text': chunk
            })
    print(f"Всего чанков: {len(all_chunks)}")

6.3 Шаг 3. Генерация эмбеддингов и сохранение в ChromaDB

index.py:

Python
import chromadb
from chromadb.utils import embedding_functions
from chunking import load_articles, split_into_chunks

# Инициализация ChromaDB (локальная, сохраняется в папку ./chroma_db)
client = chromadb.PersistentClient(path="./chroma_db")

# Используем модель эмбеддингов по умолчанию (all-MiniLM-L6-v2)
# Для русского языка лучше указать multilingual модель
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="intfloat/multilingual-e5-large"
)

# Создание коллекции
collection = client.get_or_create_collection(
    name="articles",
    embedding_function=embedding_fn
)

# Загрузка статей и разбивка на чанки
articles = load_articles('/var/www/codestack/articles')

# Добавление чанков в коллекцию
for article in articles:
    chunks = split_into_chunks(article['content'])
    for i, chunk in enumerate(chunks):
        collection.add(
            documents=[chunk],
            metadatas=[{
                'article': article['title'],
                'chunk_index': i
            }],
            ids=[f"{article['title']}_{i}"]
        )

print(f"Проиндексировано {collection.count()} чанков")

Запуск индексации:

Bash
python index.py

6.4 Шаг 4. Поиск по смыслу

search.py:

Python
import chromadb
from chromadb.utils import embedding_functions

client = chromadb.PersistentClient(path="./chroma_db")
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="intfloat/multilingual-e5-large"
)

collection = client.get_or_create_collection(
    name="articles",
    embedding_function=embedding_fn,
    metadata={"hnsw:space": "cosine"}
)

def search(query, n_results=5):
    results = collection.query(
        query_texts=[query],
        n_results=n_results
    )
    return results

if __name__ == '__main__':
    query = "как защитить сервер от взлома"
    results = search(query)
    
    for i, (doc, meta, dist) in enumerate(zip(
        results['documents'][0],
        results['metadatas'][0],
        results['distances'][0]
    )):
        print(f"--- Результат {i+1} ---")
        print(f"Статья: {meta['article']}")
        print(f"Релевантность: {1 - dist:.4f}")
        print(f"Текст: {doc[:200]}...")
        print()

Пример вывода:

6.5 Шаг 5. Интеграция с PHP

Для PHP-сайта можно создать API-эндпоинт, который вызывает Python-скрипт или обращается к векторной базе напрямую.

Вариант 1: PHP вызывает Python через shell_exec

PHP
<?php
function searchArticles($query, $n = 5) {
    $queryEscaped = escapeshellarg($query);
    $output = shell_exec("python3 /path/to/search.py $queryEscaped 2>&1");
    return json_decode($output, true);
}

$results = searchArticles("как защитить сервер");
foreach ($results as $result) {
    echo "<h3>{$result['article']}</h3>";
    echo "<p>{$result['text']}</p>";
}
?>

ВАЖНО! Это псевдокод и для реального использования нужен API-эндпоинт (FastAPI/Flask).

Вариант 2: Использование Qdrant через HTTP API

Qdrant предоставляет REST API, который можно вызывать из PHP.

PHP
<?php
function searchQdrant($query, $n = 5) {
    $client = new GuzzleHttp\Client();
    
    // Сначала получаем эмбеддинг для запроса (через API эмбеддингов)
    $embeddingResponse = $client->post('http://localhost:11434/api/embeddings', [
        'json' => [
            'model' => 'nomic-embed-text',
            'prompt' => $query
        ]
    ]);
    $embedding = json_decode($embeddingResponse->getBody(), true)['embedding'];
    
    // Затем ищем в Qdrant
    $response = $client->post('http://localhost:6333/collections/articles/points/search', [
        'json' => [
            'vector' => $embedding,
            'limit' => $n,
            'with_payload' => true
        ]
    ]);
    
    return json_decode($response->getBody(), true)['result'];
}
?>

ВАЖНО! Эндпоинт /points/search устарел в новых версиях Qdrant — заменён на /points/query или /search. Проверьте версию Qdrant в документации!

6.6 Шаг 6. RAG: генерация ответов с помощью LLM

Теперь добавим LLM, чтобы генерировать ответы на основе найденных чанков.

rag.py:

Python
import chromadb
from chromadb.utils import embedding_functions
import requests

client = chromadb.PersistentClient(path="./chroma_db")
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="intfloat/multilingual-e5-large"
)
collection = client.get_collection(name="articles", embedding_function=embedding_fn)

def ask_llm(prompt, context):
    """Отправляет запрос к локальной LLM через Ollama."""
    full_prompt = f"""Ответь на вопрос пользователя, используя только предоставленный контекст.
Если в контексте нет ответа, скажи "Я не знаю".

Контекст:
{context}

Вопрос: {prompt}

Ответ:"""
    
    response = requests.post('http://localhost:11434/api/generate', json={
        'model': 'mistral',
        'prompt': full_prompt,
        'stream': False
    })
    return response.json()['response']

def rag_query(question, n_results=3):
    # 1. Поиск релевантных чанков
    results = collection.query(query_texts=[question], n_results=n_results)
    
    # 2. Формирование контекста
    context = "\n\n".join(results['documents'][0])
    sources = [meta['article'] for meta in results['metadatas'][0]]
    
    # 3. Генерация ответа
    answer = ask_llm(question, context)
    
    return {
        'answer': answer,
        'sources': sources
    }

if __name__ == '__main__':
    result = rag_query("Как защитить сервер от взлома?")
    print("Ответ:", result['answer'])
    print("Источники:", result['sources'])

Пример ответа:

7. Оптимизация и лучшие практики

7.1 Чанкинг

  • Размер чанка: 500–1000 символов. Слишком маленькие — теряется контекст, слишком большие — снижается точность поиска.
  • Перекрытие: 10–20% (100–200 символов), чтобы не терять смысл на границах.
  • Разбивка по абзацам: лучше сохранять логические блоки, а не резать по символам.
  • Метаданные: сохраняйте заголовок статьи, URL, дату — это поможет в фильтрации и отображении.

7.2 Выбор модели эмбеддингов

  • Для русского языка используйте многоязычные модели (multilingual-e5, nomic-embed-text).
  • Для английского подойдут all-MiniLM-L6-v2 (быстрая, лёгкая) или text-embedding-3-small (качественная).
  • Тестируйте на своих данных: одна модель может давать лучшие результаты, чем другая.

7.3 Гибридный поиск

Комбинируйте векторный поиск с обычным (BM25). Это повышает точность: векторный ищет по смыслу, BM25 — по точным словам.

Пример с Qdrant: поддерживает гибридный поиск «из коробки».

7.4 Кеширование

  • Кешируйте эмбеддинги для часто задаваемых вопросов.
  • Кешируйте ответы LLM (если вопрос повторяется).
  • Используйте Redis или Memcached.

7.5 Обновление индекса

  • При добавлении новой статьи — генерируйте эмбеддинги и добавляйте в базу.
  • При обновлении статьи — удаляйте старые чанки и добавляйте новые.
  • Автоматизируйте через cron или webhook.

7.6 Оценка качества

  • Собирайте обратную связь от пользователей (полезно/не полезно).
  • Логируйте запросы и результаты.
  • Периодически проверяйте, находит ли поиск то, что нужно.

8. Частые ошибки

ОшибкаПоследствияРешение
Слишком маленькие чанкиПотеря контекстаУвеличьте размер до 500–1000 символов.
Слишком большие чанкиСнижение точностиУменьшите или используйте перекрытие.
Одна модель для всегоПлохое качество на русскомИспользуйте многоязычные модели.
Нет метаданныхНельзя фильтровать и показывать источникиСохраняйте заголовок, URL, дату.
Индекс не обновляетсяПоиск не находит новые статьиАвтоматизируйте обновление.
LLM галлюцинируетОтветы не соответствуют контентуИспользуйте строгий промпт и RAG.
Нет кешированияМедленно и дорогоКешируйте эмбеддинги и ответы.
Игнорирование обратной связиКачество не улучшаетсяСобирайте фидбек и анализируйте.

9. Заключение

Умный поиск по сайту с использованием векторных эмбеддингов и RAG — это мощный инструмент, который:

  • Понимает смысл запросов, а не только точные слова.
  • Находит релевантные статьи, даже если они не совпадают по формулировкам.
  • Позволяет создать AI-ассистента, который отвечает на вопросы на основе вашего контента.
  • Повышает вовлечённость пользователей и снижает нагрузку на поддержку.