Головна / Статті / RAG без фреймворку: фрагменти, оцінка, бюджет, промпт

RAG без фреймворку: фрагменти, оцінка, бюджет, промпт

RAG як звичайний відбір токенів: короткий приклад на Python з оцінкою за перетином слів, обмеженням за бюджетом токенів і тим, що насправді покращують ембедінги й векторні сховища.

1050 слів

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.