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.