RAG без фреймворка: фрагменты, оценка, бюджет, промпт
RAG как обычный отбор токенов: короткий пример на Python с оценкой по пересечению слов, ограничением по бюджету токенов и тем, что на самом деле улучшают эмбеддинги и векторные хранилища.
RAG — это отбор токенов
Модель не знает ни вашу вики, ни ваш код, ни вчерашние тикеты поддержки. Её веса заморожены в момент обучения, а гигабайт текста никогда не влезет в окно на 128 тысяч токенов. Дообучение требует времени и денег, и для фактов, которые меняются каждую неделю, это просто не тот инструмент. Остаётся один вариант, и он почти неприлично прямолинейный: найти в документах несколько фрагментов, которые важны для этого вопроса, вставить их в промпт и попросить модель отвечать по ним.
Вот и весь RAG: сначала найти, потом сгенерировать. Это не новый тип модели, не память и не магия. Это способ выбрать, какие токены попадут в обычный цикл предсказания следующего токена. Модель по-прежнему предсказывает токены по тому контексту, который вы собрали, а RAG лишь улучшает сам этот контекст.
Любая RAG-система, игрушечная или боевая, сводится к четырём шагам:
- Разбить документы на фрагменты.
- Оценить каждый фрагмент относительно вопроса.
- Оставить несколько лучших, которые укладываются в бюджет токенов.
- Собрать промпт из этих заметок и вопроса.
Фреймворки отличаются тем, как они делают шаг оценки. Сама схема не меняется никогда.
Идея в простом коде
В примере ниже вместо настоящего поиска используется пересечение слов. Он намеренно примитивный: важна структура.
DOCS = [
"Tokens are subword chunks; a model "
"sees token IDs, never words.",
"The context window caps prompt plus "
"output, measured in tokens.",
"Temperature rescales the next-token "
"distribution before sampling.",
"Ollama serves local models behind an "
"OpenAI-style API on port 11434.",
]
def score(q: str, doc: str) -> int:
qw = set(q.lower().split())
return len(qw & set(doc.lower().split()))
def retrieve(q: str, k: int = 2) -> list:
ranked = sorted(
DOCS,
key=lambda d: score(q, d),
reverse=True,
)
return ranked[:k]
q = "What does the context window cap?"
notes = retrieve(q)
prompt = (
"Answer using only these notes:\n"
+ "\n".join(f"- {n}" for n in notes)
+ f"\n\nQuestion: {q}"
)
print(prompt)
Главное здесь — строка, которая собирается в самом конце. В ней инструкции, потом найденные заметки, потом вопрос, и эта строка и есть RAG. Передайте её любому чат-клиенту — и генерация с дополнением через поиск готова. Формулировка «используй только эти заметки» тоже важна. Это ваша основная защита от модели, которой проще уверенно ответить по своим обучающим данным, чем по вашим документам.
В реальных системах улучшают ровно одну функцию — score(). Пересечение слов не видит синонимов, так что «laptop» никогда не совпадёт с «notebook». В продакшене используют эмбеддинги — векторы, которые ранжируют по смыслу, — вместе с векторным хранилищем для поиска по ним в больших объёмах, а текст режут на фрагменты хитрее, чем по одному предложению на документ. Всё это улучшения шага оценки. Конвейер, который вы только что прочитали, — это вся архитектура.
Проверяем бюджет
Почему берут top-k фрагментов, а не все подходящие? Потому что контекстное окно — это бюджет, и поиск легко его превысит. Даже текст, который помещается, не бесплатен: каждый токен стоит денег, а перегруженный контекст делает внимание модели менее надёжным. Поэтому в настоящем RAG-коде токены считают до вызова модели.
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
def fits(prompt: str, window: int = 8_000,
reserve: int = 500) -> bool:
used = len(enc.encode(prompt))
print(f"prompt uses {used} tokens")
return used <= window - reserve
if fits(prompt):
pass # call the API (Parts 2-5)
else:
notes = retrieve(q, k=1) # shrink, retry
Именно этот if отличает демо от системы. Ошибки обрезки, контекст, который молча теряется, и ответы, игнорирующие ваши документы, чаще всего возникают из-за пропущенной проверки. И держите запас. В окно должны поместиться и промпт, и ответ, так что оставьте место для ответа, который вы просите.
Запускаем пример
pip install -r requirements.txt
python examples/part06_rag_idea.py
Скрипт прогоняет весь игрушечный пример. Он выводит оценки поиска для нескольких вопросов, включая один, где пересечение слов с треском проваливается (хорошая иллюстрация того, зачем вообще нужны эмбеддинги), а затем показывает сборку промпта и проверку бюджета. Всё работает офлайн, без ключа и без вызова API, потому что урок здесь в самой идее. Подключить собранный промпт к живой модели — это один вызов функции из любого простого чат-клиента, и скрипт показывает место, куда этот вызов встанет.
Что намеренно осталось за кадром
Эмбеддинги, векторные хранилища, стратегия нарезки и переранжирование — это механика, которая делает retrieve() хорошим. Она относится к полноценной реализации RAG, и именно там LangChain начинает оправдывать свою установку. Но если начинать с этой механики, есть обратная сторона. Инженеры, которые с неё начинают, видят в RAG продукт, который нужно настроить. А те, кто начал с идеи, видят десяток строк управляющей логики вокруг функции поиска и могут разобрать каждую деталь, когда качество поиска разочарует.
Если коротко
Если убрать всё лишнее, RAG выбирает, какие токены увидит модель. Разбейте документы, отранжируйте фрагменты относительно вопроса, вставьте лучшие в промпт с учётом бюджета — и привычный цикл предсказания следующего токена доделает остальное. Фреймворк для этого не нужен, а увидев это однажды, вы узнаете ту же схему внутри любого фреймворка. Следующий шаг — встроить RAG в реальный конвейер с паттернами ReAct и Plan-and-Execute и посмотреть, где здесь место LangChain и LangGraph.