LangChain RAG: рабочий пример с моделью через шлюз

Строим простой RAG на LangChain: документы, чанки, векторный поиск и ответ модели через шлюз. Считаем, из чего складывается цена вопроса, и режем лишние токены.

5 мин · · разработка · экономия

LangChain RAG на Python решает типичную задачу: у вас есть свои документы, а модель о них не знает. Вместо того чтобы вставлять в запрос всё содержимое базы, мы находим несколько релевантных фрагментов и отправляем только их. Ниже собираем минимальный рабочий пример: загрузка, разбиение, индекс, поиск и ответ модели через OpenAI-совместимый шлюз. Заодно считаем, где тратятся токены. Код следует документации LangChain (разделы Retrieval и семантический поиск); версии пакетов сверяйте с ней.

LangChain RAG: из чего он состоит

Документация LangChain делит индексацию на четыре шага: загрузить данные в объекты Document, разбить на фрагменты, превратить фрагменты в векторы (embeddings) и сохранить в векторное хранилище. При запросе вопрос тоже превращается в вектор, ближайшие фрагменты отправляются модели как контекст. Разбиение нужно по двум причинам: большие куски хуже ищутся и тратят лишние токены.

Одно ограничение важно сразу. В справочнике API Zukko нет эндпоинта эмбеддингов (есть чат, Messages, Responses, список моделей и другие). Поэтому векторизацию мы делаем локально, а облачную модель через шлюз используем только для ответа. Так вы и платите шлюзу только за вопросы, а не за индексацию.

Установка

bash
pip install langchain langchain-openai langchain-text-splitters langchain-huggingface sentence-transformers
export ZUKKO_API_KEY="fetch_ваш_ключ"

Положите несколько текстовых или Markdown-файлов в папку knowledge/.

Индекс: загрузка, разбиение, векторы

bash
from pathlib import Path

from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

docs = [
    Document(page_content=p.read_text(encoding="utf-8"), metadata={"source": p.name})
    for p in Path("knowledge").glob("*.md")
]

splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=100)
chunks = splitter.split_documents(docs)

embeddings = HuggingFaceEmbeddings(
    model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)
store = InMemoryVectorStore(embedding=embeddings)
store.add_documents(chunks)
retriever = store.as_retriever(search_kwargs={"k": 4})

Что здесь важно:

  • RecursiveCharacterTextSplitter по документации LangChain считается разумным выбором по умолчанию: он старается сохранить абзацы, потом предложения. chunk_size здесь в символах, chunk_overlap это перекрытие соседних фрагментов. Размер фрагмента подбирайте на своих вопросах: слишком мелкие фрагменты теряют контекст, слишком крупные тратят токены.
  • Модель эмбеддингов для русского текста должна быть мультиязычной. Мы взяли мультиязычную модель из семейства sentence-transformers как пример; для своих текстов сравните несколько.
  • InMemoryVectorStore хранит всё в памяти и подходит для примера. Для постоянной базы выберите хранилище из списка интеграций LangChain.
  • Если не хочется скачивать модель эмбеддингов, есть вариант полегче: BM25Retriever.from_documents(chunks, k=4) из langchain_community.retrievers (нужен пакет rank_bm25). Он ищет по словам без учёта морфологии, поэтому для русского текста пропускает формы слов, и качество проверяйте на своих вопросах.

Ответ модели через шлюз

Облачная модель подключается как ChatOpenAI с адресом шлюза. База для OpenAI-клиентов заканчивается на /v1:

bash
import os
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    model="gpt-6.1-sol",
    base_url="https://api.zukko.pro/v1",
    api_key=os.environ["ZUKKO_API_KEY"],
    max_tokens=600,
)

SYSTEM = (
    "Отвечай только по приведённому контексту. "
    "Если ответа в контексте нет, скажи «не знаю». "
    "В конце укажи номера источников в квадратных скобках."

Запрос «отвечай только по контексту» и разрешение сказать «не знаю» снижают риск выдуманных ответов, но не убирают его: проверяйте на вопросах, ответа на которые в базе нет. Подробный разбор схемы без лишних компонентов есть в статье RAG-пайплайн своими руками. Подключение шлюза к LangChain описано здесь: LangChain и base_url.

Где тратятся токены

За токены платите только на последнем шаге. Один вопрос состоит из:

  • системной инструкции (десятки токенов);
  • найденного контекста: k фрагментов по chunk_size символов, обычно самая тяжёлая часть;
  • самого вопроса;
  • ответа модели (ограничен max_tokens).

Пусть на вопрос уходит 1 800 входных токенов (контекст около 1 500, остальное инструкция и вопрос; точное число измерьте на своих текстах) и 300 выходных. Формула: вход × цена входа / 1M + выход × цена выхода / 1M. Цены на 18 октября 2026:

МодельВход / выход, $ за 1MРасчёт, миллионные доли $Один вопрос10 000 вопросов
gpt-6.1-sol0.27 / 1.321800 × 0.27 + 300 × 1.32 = 486 + 396$0.000882$8.82
claude-sonnet-5-50.4 / 2720 + 600$0.00132$13.20
deepseek-v4.1-flash0.013 / 0.03823.4 + 11.4$0.0000348$0.348

Для сравнения вспомним подход «положить весь документ в промпт». При документе в 200 000 токенов на gpt-6.1-sol один вопрос обойдётся в 200 000 × 0.27 / 1M = $0.054, а 10 000 вопросов в $540. RAG оправдан именно тогда, когда документов много или вопросов много; для разовой работы с одним файлом длинный контекст может быть проще. Для Claude в OpenAI-формате весь вход идёт по цене входа.

Как сократить расход

  1. Уменьшите k и chunk_size. Каждый лишний фрагмент оплачивается при каждом вопросе. Начните с k=3 и проверьте качество на 20–30 реальных вопросах.
  2. Уберите дубли. Из-за перекрытия соседние фрагменты повторяют текст; не вставляйте подряд оба, если они о том же.
  3. Ограничьте ответ. max_tokens и просьба отвечать кратко режут выходные токены, которые стоят дороже входных.
  4. Берите дешёвую модель для простых вопросов. Если контекст уже содержит ответ, часто хватает небольшой модели. Проверьте на своих данных, а схему «сначала дешёвая, сложное эскалируем» описывает статья про каскад моделей.
  5. Не тащите историю целиком. В чате передавайте последние реплики, а не весь диалог.
  6. Кэш. Если у вас длинный повторяющийся префикс (инструкции, примеры), то у GPT-моделей шлюз видит чтение кэша в обоих форматах, и эта часть оплачивается по цене чтения. Для Claude скидку за кэш даёт только Anthropic-формат. Подробнее: кэш и лимиты ключа.

Частые вопросы

Нужен ли для RAG отдельный сервис эмбеддингов?

Шлюз Zukko эндпоинта эмбеддингов не предоставляет, поэтому в примере они считаются локально. Если у вас уже есть сервис эмбеддингов, подключите его через соответствующий класс LangChain.

Какую модель взять для ответа?

Ориентируйтесь на цену, контекст и индекс силы: у gpt-6.1-sol он 52, у claude-sonnet-5-5 48, у deepseek-v4.1-flash 39. Хватит ли дешёвой модели, покажет проверка на ваших вопросах.

Как понять, что поиск работает?

Печатайте найденные фрагменты рядом с ответом и проверяйте, лежит ли в них ответ. Если нет, дело в разбиении или эмбеддингах, а не в модели.

Можно ли заменить InMemoryVectorStore?

Да, as_retriever есть у любого векторного хранилища LangChain, остальной код не меняется.

Создайте ключ в кабинете, поставьте ему дневной лимит и запустите пример.

zukko

Платите в 25×
меньше

за Claude Code & ChatGPT

Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.