RAG на эмбеддингах: как связать модель с базой знаний
Векторный поиск на bge-m3, POST /v1/embeddings и сборка контекста. Показываю рабочий пайплайн и места, где RAG обычно ломается.
Роман Дьяченко
RAG на эмбеддингах: как связать модель с базой знаний
RAG нужен, когда модель должна отвечать по вашим данным — документации, тикетам, договорам, — которых нет в её весах. Дообучать ради этого дорого и медленно. Дешевле находить релевантные куски текста и подкладывать их в промпт.
Пайплайн из четырёх шагов
- Режем документы на чанки.
- Считаем эмбеддинги через
POST /v1/embeddings. - Кладём векторы в хранилище (pgvector, Qdrant, даже numpy для прототипа).
- На запрос считаем эмбеддинг вопроса, ищем ближайшие чанки, отдаём их модели.
Эмбеддинги через bge-m3
bge-m3 — рабочая лошадка: мультиязычная (русский держит уверенно), размерность 1024, контекст до 8K токенов на вход. Для большинства баз знаний этого хватает.
from openai import OpenAIclient = OpenAI(api_key=KEY, base_url="https://api.yenisei.ru/v1")def embed(texts: list[str]) -> list[list[float]]: resp = client.embeddings.create(model="bge-m3", input=texts) return [d.embedding for d in resp.data]Отправляйте тексты пачками по 32–64 штуки — так меньше round-trip'ов. Эмбеддинги дешёвые (порядка 1–2 ₽ за 1M токенов), но за индексацию большой базы всё равно набегает, так что считайте заранее.
Чанкинг решает больше, чем модель
Главная ошибка новичков — резать по 512 символов «в лоб». Тогда предложение рвётся посередине и вектор получается бессмысленным. Что работает:
- Режьте по структуре: заголовки, абзацы, пункты списка.
- Держите чанк в 200–400 токенов с перекрытием 15–20%.
- Храните рядом метаданные: источник, заголовок раздела, дату. Их потом покажете пользователю как ссылку.
Поиск и сборка контекста
Косинусная близость в pgvector:
SELECT chunk, source, 1 - (embedding <=> %(q)s) AS scoreFROM kbORDER BY embedding <=> %(q)sLIMIT 8;Восемь чанков — разумный старт. Дальше отсекайте по порогу score (для bge-m3 обычно 0.5–0.6) и складывайте в промпт:
context = "\n\n".join(f"[{c['source']}] {c['chunk']}" for c in hits)messages = [ {"role": "system", "content": "Отвечай ТОЛЬКО по контексту ниже. Если ответа нет — скажи, что не знаешь."}, {"role": "user", "content": f"Контекст:\n{context}\n\nВопрос: {question}"},]resp = client.chat.completions.create(model="qwen-flash", messages=messages)Где RAG ломается
- Модель игнорирует контекст и отвечает из головы. Лечится жёстким system-промптом и явным требованием цитировать источник.
- Похожие, но не те чанки. Чистый вектор путает синонимичные разделы. Добавьте гибридный поиск: BM25 по ключевым словам плюс вектор, и объедините ранги (reciprocal rank fusion).
- Слишком длинный контекст. Не пихайте 50 чанков «на всякий случай» — модель тонет и растёт счёт. Лучше 6–8 точных.
- Устаревшая база. Эмбеддинги не обновляются сами. Пересчитывайте изменённые документы по хуку на сохранение.
Какую модель ставить на генерацию
Ответ по готовому контексту — задача несложная, тяжёлый reasoning тут не нужен. Начните с быстрой и дешёвой модели (qwen-flash, deepseek-flash). Если ответы получаются поверхностными на сложных вопросах — поднимайте до deepseek-pro. Эмбеддинги при этом остаются на bge-m3: качество поиска от модели-генератора не зависит.
Соберите сначала минимальную версию на numpy и десяти документах, убедитесь, что поиск находит нужное, и только потом тащите Qdrant и гибридный поиск. Большинство проблем RAG — это проблемы чанкинга, а не инфраструктуры.