Free-LLMs

RAG на своих документах: как устроен ассистент с базой знаний

Пошагово: чанки, эмбеддинги, поиск, цитаты и рабочий код на бесплатном стеке.

Guide
RAG на своих документах
🤖
Мониторинг бесплатных моделей в Telegram Бот @freellms_bot присылает новые бесплатные модели и AI-акции.
→

RAG (retrieval-augmented generation) — это схема, в которой модель отвечает не по памяти, а по фрагментам ваших документов, найденным в момент вопроса: система ищет подходящие куски текста, кладёт их в промпт и просит модель ответить только по ним, с указанием источника. Идея описана в статье Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks (Lewis и др., NeurIPS 2020): параметрическая память модели дополняется внешней, обновляемой памятью. Ниже — как это устроено по шагам и рабочий пример на Python в сотню строк, который можно целиком запустить на бесплатных компонентах.

Из чего состоит ассистент с базой знаний

У любой RAG-системы две фазы: подготовка индекса (делается один раз и при обновлении документов) и ответ на вопрос (на каждый запрос).

  1. Загрузка и разбиение. Документы превращаются в текст и режутся на фрагменты (чанки) примерно в абзац-два.
  2. Эмбеддинги. Каждый чанк превращается в вектор; близкие по смыслу тексты оказываются близко в векторном пространстве.
  3. Индекс. Векторы и исходные тексты хранятся вместе с метаданными: файл, страница, раздел.
  4. Поиск. Вопрос тоже превращается в вектор, из индекса берутся k ближайших чанков.
  5. Сборка контекста. Найденные чанки нумеруются и вставляются в промпт вместе с инструкцией отвечать только по ним.
  6. Генерация и цитирование. Модель отвечает и ставит ссылки вида [1], [2]; код проверяет, что такие номера действительно были в контексте.

Шаги 1-3 подробно разобраны в статье про подготовку документов для RAG, а что делать, когда поиск промахивается, — в материале про hybrid search и reranking. Здесь — сквозная картина.

Бесплатный и недорогой стек

Для эмбеддингов и генерации не нужна платная подписка. Варианты, от самого простого к более гибкому:

КомпонентЛокальноОблако с бесплатным уровнемЧто учесть
ЭмбеддингиOllama: ollama pull embeddinggemma, эндпоинт /api/embedGemini API: модели gemini-embedding-001 и gemini-embedding-2, размерность 128-3072Модель эмбеддингов в индексе и в запросе должна быть одной и той же
ГенерацияЛюбая модель в Ollama, LM Studio или llama.cpp с OpenAI-совместимым APIБесплатные модели через OpenRouter, Groq, NVIDIAУ облачных вариантов есть лимиты и политика данных: не отправляйте им закрытые документы
ХранилищеМассив numpy и JSON-файл; для тысяч и десятков тысяч чанков этого достаточноУправляемые базы с бесплатными планамиВыбор базы разобран в статье pgvector или векторная база

Документация Ollama называет типичную длину вектора 384-1024 и показывает вызов POST /api/embed с полем input, принимающим строку или список строк (Ollama: Embeddings). Для Gemini размерность вектора настраивается в диапазоне 128-3072, рекомендованы 768, 1536 и 3072 (Gemini API: Embeddings). Условия бесплатного уровня и лимиты у облачных провайдеров меняются, поэтому смотрите актуальные на страницах тарифов и в разборе лимитов OpenRouter. Если вы только выбираете, где запускать модель, начните с статьи про локальный запуск.

Минимальный пример на Python

Нужны Python 3.10+, пакеты numpy и requests и запущенный Ollama. Скачайте модели:

pip install numpy requests
ollama pull embeddinggemma
ollama pull llama3.1:8b

Положите несколько текстовых или markdown-файлов в папку docs/ и сохраните скрипт как rag.py:

import os, re, sys, json, glob
import numpy as np
import requests

OLLAMA = "http://localhost:11434"
EMBED_MODEL = "embeddinggemma"
LLM_BASE = os.getenv("LLM_BASE_URL", "http://localhost:11434/v1")
LLM_KEY = os.getenv("LLM_API_KEY", "ollama")
LLM_MODEL = os.getenv("LLM_MODEL", "llama3.1:8b")


def embed(texts):
    r = requests.post(f"{OLLAMA}/api/embed",
                      json={"model": EMBED_MODEL, "input": texts}, timeout=300)
    r.raise_for_status()
    v = np.array(r.json()["embeddings"], dtype="float32")
    return v / np.linalg.norm(v, axis=1, keepdims=True)  # нормируем: cosine = скалярное произведение


def chunk(text, size=900, overlap=150):
    paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
    chunks, cur = [], ""
    for p in paras:
        if cur and len(cur) + len(p) > size:
            chunks.append(cur)
            cur = cur[-overlap:] + "\n" + p
        else:
            cur = f"{cur}\n\n{p}" if cur else p
    if cur:
        chunks.append(cur)
    return chunks


def build_index(folder="docs"):
    meta, texts = [], []
    for path in sorted(glob.glob(f"{folder}/**/*.*", recursive=True)):
        if not path.endswith((".txt", ".md")):
            continue
        with open(path, encoding="utf-8") as f:
            for i, c in enumerate(chunk(f.read())):
                meta.append({"source": os.path.basename(path), "n": i})
                texts.append(c)
    vecs = np.vstack([embed(texts[i:i + 32]) for i in range(0, len(texts), 32)])
    np.save("index.npy", vecs)
    json.dump({"meta": meta, "texts": texts}, open("index.json", "w", encoding="utf-8"),
              ensure_ascii=False)
    print(f"Проиндексировано чанков: {len(texts)}")


def search(question, k=4):
    vecs = np.load("index.npy")
    data = json.load(open("index.json", encoding="utf-8"))
    scores = vecs @ embed([question])[0]
    top = np.argsort(-scores)[:k]
    return [(float(scores[i]), data["meta"][i], data["texts"][i]) for i in top]


PROMPT = """Ответь на вопрос, используя только фрагменты ниже.
Ссылайся на фрагменты в квадратных скобках, например [1].
Если в фрагментах нет ответа, напиши: "В документах этого нет".

Фрагменты:
{context}

Вопрос: {question}"""


def ask(question):
    hits = search(question)
    context = "\n\n".join(f"[{i + 1}] ({m['source']}) {t}" for i, (_, m, t) in enumerate(hits))
    r = requests.post(f"{LLM_BASE}/chat/completions",
                      headers={"Authorization": f"Bearer {LLM_KEY}"},
                      json={"model": LLM_MODEL, "temperature": 0,
                            "messages": [{"role": "user",
                                          "content": PROMPT.format(context=context, question=question)}]},
                      timeout=300)
    r.raise_for_status()
    answer = r.json()["choices"][0]["message"]["content"]
    cited = {int(n) for n in re.findall(r"\[(\d+)\]", answer)}
    bad = [n for n in cited if not 1 <= n <= len(hits)]
    print(answer)
    print("\nИсточники:")
    for n in sorted(cited - set(bad)):
        print(f"[{n}] {hits[n - 1][1]['source']}, чанк {hits[n - 1][1]['n']}")
    if bad:
        print("Внимание: модель сослалась на несуществующие номера:", bad)


if __name__ == "__main__":
    if sys.argv[1] == "index":
        build_index()
    else:
        ask(" ".join(sys.argv[2:]))

Запуск:

python rag.py index
python rag.py ask "Как оформить возврат товара?"

Чтобы заменить локальную модель на облачную с OpenAI-совместимым API, достаточно переменных окружения: LLM_BASE_URL, LLM_API_KEY и LLM_MODEL с идентификатором модели из каталога. Эмбеддинги при этом остаются локальными, а значит, тексты документов покидают машину только в виде нескольких найденных чанков.

Что здесь сделано осознанно

  • Нормировка векторов. После деления на длину косинусная близость равна скалярному произведению, поэтому весь поиск — одна строка vecs @ query. На десятках тысяч чанков такой полный перебор работает без отдельной базы.
  • Перекрытие чанков. Хвост предыдущего фрагмента (150 символов) переходит в следующий, чтобы мысль на границе не потерялась. Абзац длиннее size в этом примере остаётся одним чанком: для длинных абзацев нужен отдельный сплиттер.
  • Температура 0 и жёсткая инструкция. Модели явно разрешено сказать, что ответа в документах нет, — без этого она склонна заполнять пробелы своими знаниями.
  • Проверка цитат кодом. Скрипт сверяет номера в ответе с реально переданными фрагментами. Это ловит выдуманные ссылки, но не доказывает, что утверждение действительно следует из процитированного текста.

Цитирование: что оно гарантирует и что нет

Ссылка вида [2] показывает, на какой фрагмент модель опиралась, и позволяет человеку быстро проверить ответ — ради этого её и делают. Но формат цитаты не делает ответ верным: модель может поставить корректный номер к неверному пересказу, смешать два фрагмента или проигнорировать нужный. Для критичных сценариев показывайте пользователю не только номер, но и сам процитированный текст с именем файла и страницей, а для ответов с высокой ценой ошибки добавляйте отдельный проход проверки: второй запрос к модели с вопросом, подтверждается ли каждое утверждение приведёнными фрагментами.

Почему RAG не гарантирует отсутствие галлюцинаций

RAG снижает риск выдуманных фактов, потому что у модели появляется опора, но не устраняет его. Ошибки возникают на разных этапах:

  • Поиск не нашёл нужное: вопрос сформулирован иначе, чем в документе, или нужный фрагмент разрезан при чанкинге. Модель получает нерелевантный контекст и либо отказывается, либо додумывает.
  • Нужный фрагмент найден, но потерян в длинном контексте. В работе Lost in the Middle показано, что качество ответов заметно падает, когда релевантная информация находится в середине длинного входа, а лучше всего модели используют начало и конец.
  • Документы противоречат друг другу или устарели: модель добросовестно процитирует старую версию.
  • Модель смешивает контекст со своими знаниями и выдаёт непроверяемую добавку рядом с цитатой.

Поэтому ассистент с базой знаний нужно измерять, а не просто запускать. Соберите 30-50 реальных вопросов с известными правильными фрагментами и смотрите отдельно, находит ли поиск нужное, и отдельно, как отвечает модель. Как это считать, показано в статьях про подготовку документов и улучшение поиска; сравнить несколько моделей на своих вопросах поможет материал про сравнение LLM на своих задачах.

Итого

  • Минимальный RAG — это чанки, эмбеддинги, косинусный поиск, промпт с нумерованным контекстом и проверка ссылок; всё это работает на бесплатных локальных компонентах.
  • Модель эмбеддингов фиксируйте вместе с индексом: смена модели означает полную переиндексацию.
  • Главные точки отказа — чанкинг и поиск, а не генерация; чините их по метрикам, а не на глаз.
  • RAG снижает, но не исключает галлюцинации: показывайте источники пользователю и разрешайте модели отвечать, что в документах ответа нет.
  • Если вопрос в том, нужен ли вам RAG вообще, сравните его с промптингом и дообучением в статье промпт, RAG или fine-tuning.
FL
Редакция Free-LLMsКоманда каталога бесплатных LLM API. Проверяем лимиты провайдеров и тестируем модели и инструменты руками.

Читайте также