На практике хотел понять где заканчивается простой вызов локальной LLM и начинается backend система: с API контрактом, логированием, request_id, источниками, индексом документов, диагностикой и честными ограничениями.

Сначала проект выглядел просто: frontend отправляет вопрос, FastAPI принимает POST /ask, backend вызывает локальную модель через Ollama и возвращает ответ. Это уже работало, но стало понятно такой вариант ещё нельзя назвать системой по документации. Модель отвечает, но непонятно на что она опирается, откуда взяла ответ, сколько времени занял каждый этап и что делать если документы изменились.

В статье описисоваю не "как вообще устроен RAG" а как постепенно превращал простой вызов локальной LLM в небольшой backend/RAG проект. Без претензии на production ready, но с инженерными акцентами: API, наблюдаемость, источники, индекс, ошибки и ограничения.

1. Зачем делал локального AI помощника

Идея проекта была простой, сделать локального помощника по документации. Документы лежат в папке documents/, пользователь задаёт вопрос, backend ищет релевантные фрагменты и передаёт их в локальную LLM через Ollama.

Стек получился таким:

  • frontend на HTML/CSS/JS;

  • backend на FastAPI;

  • Ollama для локального запуска моделей;

  • gpt-oss:20b для генерации ответа;

  • embeddinggemma для embeddings;

  • Markdown/TXT документы в локальной папке;

  • in memory vector store для первого варианта RAG.

Начал с backend: явного API, валидации запроса, request_id, логирования и измерения времени. Frontend нужен чтобы удобно отправлять вопросы и смотреть результат, но основная инженерная часть находится на стороне backend.

Первая схема была такой:

Пользователь задаёт вопрос
          |
          v
Frontend отправляет POST /ask
          |
          v
FastAPI принимает запрос
          |
          v
Создаётся request_id
          |
          v
Запрос логируется
          |
          v
Backend вызывает локальную LLM через Ollama
          |
          v
Ответ возвращается во frontend
          |
          v
Результат логируется

На этом этапе помощник уже отвечал, но по сути это всё ещё был обычный "чат с моделью". Документы в процессе не участвовали.

2. Почему простой вызов LLM это ещё не система

Простой вызов LLM выглядит заманчиво отправил prompt, получил answer. Но для помощника по документации этого недостаточно.

Проблемы быстро становятся очевидными:

  • модель не знает локальные документы, если не передать их в контекст;

  • ответ нельзя проверить по источникам;

  • непонятно, что попало в prompt;

  • сложно связать ответ пользователя с логами backend;

  • если ответ медленный, непонятно где именно тратится время;

  • если документ изменился, непонятно обновился ли индекс;

  • если модели нет ответа, она всё равно может попытаться ответить.

Поэтому решил двигаться не сразу к "полноценному RAG" а небольшими шагами. Сначала документы -> потом чанки -> потом embeddings -> потом vector store -> потом поиск -> потом prompt с найденным контекстом -> потом sources -> потом диагностика и тесты.

Это оказалось полезнее чем сразу прикручивать всё в /ask. Когда каждый слой можно проверить отдельным endpoint, проще искать ошибки.

3. Архитектура проекта

В итоге проект стал состоять из нескольких слоёв:

Frontend
    |
    v
FastAPI backend
    |
    v
Document loader
    |
    v
Chunking layer
    |
    v
Embedding service
    |
    v
In memory vector store
    |
    v
Retriever
    |
    v
Prompt assembly
    |
    v
Ollama generation
    |
    v
Answer + sources + timings + request_id

Документы лежат в папке documents/. Поддерживаются .md и .txt. При rebuild index backend перечитывает документы, режет их на чанки, считает embeddings и кладёт результат в in memory vector store.

Основные endpoint:

POST /ask
GET  /documents
GET  /documents/chunks
GET  /documents/embeddings
POST /documents/index/rebuild
GET  /documents/index/status
POST /documents/search
POST /rag/debug

/ask главный endpoint для пользователя. Остальные endpoint появились не для красоты а для диагностики. Было важно видеть, что backend действительно читает документы, режет их на чанки, строит embeddings и ищет похожие фрагменты.

4. Как backend обрабатывает /ask

У /ask задача не просто "передать вопрос в модель", он управляет всем pipeline.

Упрощённо обработка выглядит так:

принять вопрос
      |
      v
создать request_id
      |
      v
записать начало запроса в лог
      |
      v
найти релевантные чанки
      |
      v
отфильтровать sources по score
      |
      v
проверить exact terms
      |
      v
собрать prompt
      |
      v
вызвать Ollama
      |
      v
вернуть answer, sources, timings, request_id
      |
      v
записать результат в лог

Для ответа использую явную модель. В статье не буду приводить весь код проекта, но смысл контракта такой:

class Source(BaseModel):
    document: str
    path: str
    chunk_index: int
    score: float
    chunk: str


class TimingBreakdown(BaseModel):
    retrieval_ms: int = 0
    filtering_ms: int = 0
    prompt_ms: int = 0
    generation_ms: int = 0
    total_ms: int = 0


class AskResponse(BaseModel):
    request_id: str
    answer: str
    sources: list[Source]
    duration_ms: int
    timings: TimingBreakdown

Мне было важно чтобы ответ API был не просто строкой answer нужен пользователю а sources, score, chunk_index, duration_ms и request_id нужны для проверки и диагностики.

Пример ответа:

{
  "request_id": "8b7a8be9-4bfd-4b89-ac9c-5a26f01de229",
  "answer": "Rebuild index нужно выполнять после добавления, изменения или удаления документов...",
  "duration_ms": 60440,
  "timings": {
    "retrieval_ms": 2163,
    "filtering_ms": 0,
    "prompt_ms": 0,
    "generation_ms": 58276,
    "total_ms": 60440
  },
  "sources": [
    {
      "document": "operations.md",
      "path": "documents/operations.md",
      "chunk_index": 5,
      "score": 0.6547,
      "chunk": "...Пятый случай смена embedding модели..."
    }
  ]
}

Такой JSON уже похож не на игрушечный чат а на backend API: у него есть контракт, диагностика и связь с логами.

5. Как появился RAG: документы -> чанки -> embeddings -> vector store

Не стал сразу менять /ask. Сначала добавил папку documents/, загрузку .md и .txt, потом отдельный endpoint который показывает какие документы backend видит.

Следующим шагом появилась нарезка документов на чанки. Это ещё не RAG, но уже подготовка корпуса. Документ целиком редко удобно передавать в prompt, лучше работать с небольшими фрагментами.

После чанкинга добавил embeddings через Ollama и модель embeddinggemma, потом появился простой in memory vector store, список чанков с embeddings и метаданными.

На этом этапе схема стала такой:

documents/
    |
    v
document_loader
    |
    v
document_chunker
    |
    v
embedding service
    |
    v
in memory vector store
    |
    v
search_similar_chunks()

Поиск похожих фрагментов делается через cosine similarity: embedding вопроса сравнивается с embeddings чанков, после чего выбираются top-k результатов.

Важно что на каждом этапе был отдельный способ проверки:

  • /documents backend видит файлы;

  • /documents/chunks документы режутся на чанки;

  • /documents/embeddings embeddings создаются;

  • /documents/search semantic search возвращает похожие chunks;

  • /documents/index/status видно состояние индекса.

Так проект проще отлаживать. Если /ask ответил плохо, то можно проверить не только prompt, но и весь путь до него.

6. Sources, score и request_id

sources стали одним из главных отличий RAG от обычного вызова LLM.

Без sources пользователь видит только сгенерированный текст, с sources он может проверить на каких фрагментах был построен ответ. Для backend разработчика sources ещё важнее, они показывают качество retrieval.

Если вопрос был:

"Когда нужно выполнять rebuild index?"

то хорошим источником должен быть operations.md, а не случайный обзорный документ. В тестах так и получилось: главным source стал operations.md, chunk_index: 5, score: 0.6547.

Кроме sources сразу добавил request_id. Это маленькая деталь, но в отладке она быстро окупилась. Он создаётся на каждый запрос, возвращается во frontend и пишется в логи. Если пользователь говорит "ответ был странный" можно взять request_id и найти всю цепочку в логах.

Пример логической цепочки:

request_started
rag_context_filtered
request_finished

С request_id можно связать вопрос, найденные chunks, scores, timings и ошибку если она была. Даже в локальном проекте это сильно упрощает диагностику.

7. Manual rebuild index и stale index

Когда появился vector store стала заметна новая сущность индекс документов как отдельное состояние системы.

Документ можно изменить на диске, но in memory vector store сам об этом не узнает. Embeddings уже посчитаны по старому тексту поэтому добавил ручной rebuild index из frontend.

Сценарий такой:

добавил или изменил документ
          |
          v
нажал rebuild index
          |
          v
backend перечитал documents/
          |
          v
создал новые chunks
          |
          v
пересчитал embeddings
          |
          v
заменил in memory vector store

Для проверки добавил три документа разного размера:

  1. overview.md короткий обзор проекта.

  2. architecture.md средний документ про архитектуру и поток /ask.

  3. operations.md большой документ про эксплуатацию, rebuild index, request_id, duration_ms, логи, ошибки и диагностику RAG.

После rebuild получилось:

Документов: 3
Чанков: 63
Embedding model: embeddinggemma
Embedding dimensions: 768

Здесь хорошо видно, что chunks_count зависит не от количества файлов а от объёма текста и настроек нарезки.

Потом проверил stale index, в конец operations.md добавил контрольную строку:

CONTROL_EXACT_TERM_12345

Если не делать rebuild а новый текст уже есть на диске, но его ещё нет в vector store, то RAG не читает документы магически на каждый вопрос, он работает с тем состоянием индекса которое было построено.

После rebuild вопрос про CONTROL_EXACT_TERM_12345 начал находить operations.md, поздний chunk_index и вызывать generation. Это подтвердило что индекс действительно обновился.

8. Timings: где реально тратится время

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

Добавил breakdown:

timings = TimingBreakdown()

retrieval_started_at = perf_counter()
search_results = search_similar_chunks(
    question=request.question,
    top_k=RAG_TOP_K,
)
timings.retrieval_ms = int((perf_counter() - retrieval_started_at) * 1000)

# filtering_ms, prompt_ms, generation_ms считаются отдельно

timings.total_ms = int((perf_counter() - started_at) * 1000)

Результаты тестов показали интересную картину. Retrieval обычно занимал около 2.1-2.2 секунды. Основное время уходило на generation.

Несколько примеров из локального запуска:

Вопрос

Total

Retrieval

Generation

Вывод

Когда нужно выполнять rebuild index?

60440 мс

2163 мс

58276 мс

Основное время в генерации

Опиши поток обработки /ask

74810 мс

2184 мс

72624 мс

Длинный ответ, долгая генерация

Можно ли использовать проект в production?

37499 мс

2173 мс

35325 мс

Ответ короче, быстрее

Какая лицензия у проекта?

2179 мс

2179 мс

0 мс

LLM не вызывалась

Самый важный результат negative test по лицензии. В документах нет информации о лицензии поэтому backend не стал вызывать LLM. generation_ms = 0.

Думаю это хороший подход: если retrieval не нашёл достаточный контекст, то не надо надеяться что prompt заставит модель быть честной. Лучше вообще не вызывать generation.

Проект запускался с такими настройками:

OLLAMA_OPTIONS = {
    "num_ctx": 32768,
    "num_gpu": 20,
    "num_thread": 8,
    "num_batch": 512,
    "temperature": 0.2,
    "top_p": 0.9,
    "num_predict": 1024,
}

RAG_TOP_K = 5
MAX_CONTEXT_CHARS = 4000

Эти параметры важны для интерпретации timings: num_ctx, MAX_CONTEXT_CHARS и num_predict влияют на размер контекста и потенциальную длину ответа, а RAG_TOP_K на количество найденных фрагментов которые backend рассматривает перед сборкой prompt.

Проект проверял на конфигурации:

Компонент

Значение

Ноутбук

ASUS Vivobook S 16 M3607HA

CPU

AMD Ryzen 7 260, 8 ядер / 16 потоков

GPU

AMD Radeon 780M Graphics, встроенная графика, shared memory

RAM

32 GB DDR5 5600

Доступная память Windows

31,3 GB

SSD

NVMe 512 GB

OS

Windows 11

Режим питания Windows

Оптимальная производительность

Питание

От сети

Отдельно смотрел нагрузку на систему во время генерации ответов. В среднем картина была примерно такой:

CPU: около 67%
RAM: около 23.2 / 31.3 ГБ
Disk: около 1%
GPU: около 6%

Это не бенчмарк, а просто наблюдение из моего локального запуска. Оно хорошо показывает ограничение локальной LLM: данные остаются на машине, зато скорость сильно зависит от железа, модели, параметров Ollama, размера prompt и насколько вычисления реально уходят на GPU.

Перед exact-term guard добавил ещё один слой двухуровневую фильтрацию retrieval.

Vector search почти всегда может вернуть какие то chunks. Но "самый похожий" не всегда означает "достаточный для ответа", поэтому разделил результаты на strong и borderline.

В текущей версии пороги выглядят так:

RAG_MIN_SCORE = 0.32
RAG_BORDERLINE_SCORE = 0.25
RAG_MIN_CONTEXT_SOURCES = 2

strong это source с score >= RAG_MIN_SCORE, borderline это source со score между RAG_BORDERLINE_SCORE и RAG_MIN_SCORE.

Логика получилась такой:

  • если нет ни одного strong source, то не отвечаем и не вызываем LLM;

  • если strong source есть, можно добавить немного borderline контекста;

  • borderline фрагменты не должны сами по себе запускать generation.

Это помогло не подгонять threshold под один удачный вопрос. Например, вопрос про production должен получить осторожный ответ если в документах есть подходящий контекст. Но вопрос "Какая лицензия у проекта?" не должен доходить до LLM если лицензия в документах не описана.

Для меня это был важный момент: решение "можно ли отвечать" принимает не только prompt а сам backend до вызова модели.

9. Exact-term guard для технических токенов

После настройки retrieval появилась интересная проблема.

Semantic search хорошо ищет смысловые совпадения. Но для технических идентификаторов этого недостаточно. Если пользователь спрашивает про конкретную строку, endpoint, имя файла, класс или ошибку, важно найти именно буквальное совпадение.

Столкнулся с этим на stale index тесте. Вопрос был про контрольную строку которой ещё не было в индексе. Semantic search находил похожие chunks про rebuild index, потому что вопрос содержал слова INDEX и REBUILD. Модель получала нерелевантный контекст и тратила время на generation хотя точного токена в sources не было.

Попытка просто снизить retrieval threshold решила один пограничный вопрос, но ухудшила negative tests. Вопрос про production начал проходить, но вопрос про лицензию тоже получил нерелевантный source и дошёл до LLM. Модель честно ответила что информации нет, но backend уже потратил время на generation и передал в prompt неподходящий контекст.

Вывод: пороги нельзя подбирать только под один успешный кейс. Нужно проверять и positive и negative tests.

Поэтому добавил exact-term guard и exact-match boost.

Идея такая:

def extract_exact_terms(text: str) -> list[str]:
    # endpoint, имена файлов, CamelCase классы,
    # CONSTANT_CASE и строки вида CONTROL_EXACT_TERM_12345
    ...


def find_missing_exact_terms(terms: list[str], texts: list[str]) -> list[str]:
    joined_text = "\n".join(texts).lower()

    return [
        term
        for term in terms
        if term.lower() not in joined_text
    ]

Если вопрос содержит технический токен, backend дополнительно проверяет буквальное совпадение в найденных chunks. Если токена нет в контексте LLM не вызывается. Если токен есть в индексе соответствующий chunk добавляется в sources как exact-match результат со score = 1.0.

Это позволило не снижать общий retrieval threshold не ломать negative tests и при этом отвечать на вопросы про точные технические токены.

Проверка после изменения:

  • вопрос "Опиши поток обработки запроса /ask". снова работает, sources идут из architecture.md, generation_ms > 0;

  • вопрос "Какая лицензия у проекта?" возвращает no context, sources пустые, generation_ms = 0;

  • вопрос "Что такое CONTROL_EXACT_TERM_12345?" после rebuild находит operations.md, поздний chunk_index, generation_ms > 0.

После этого проверял систему не только на happy path.

Минимальный набор тестов получился таким:

  • релевантный вопрос должен поднимать ожидаемый документ;

  • вопрос про /ask должен находить архитектурный документ;

  • вопрос про production должен давать осторожный ответ, а не "да, можно";

  • вопрос про лицензию должен возвращать "в документах нет достаточной информации" и не вызывать LLM;

  • stale index сценарий должен показывать, что новый текст на диске не появляется в RAG, пока не выполнен rebuild index;

  • вопрос с точным техническим токеном должен проверяться не только semantic search, но и exact-match логикой.

Эти тесты оказались полезнее чем просто смотреть на красивый ответ во frontend. Они показывают где RAG действительно работает а где backend должен остановить pipeline и честно сказать, что контекста недостаточно.

10. Ограничения текущей версии

Текущая версия проекта это локальный прототип а не production ready система. Для меня было важно не скрывать ограничения, потому что именно они показывают где заканчивается демо и начинается engineering.

In memory vector store

Индекс хранится в памяти процесса backend. Это удобно для обучения и демонстрации, не нужно поднимать отдельную vector database. Но после перезапуска backend индекс исчезает и rebuild нужно выполнять заново.

Для production лучше использовать persistent vector store: Qdrant, Chroma, pgvector или другое хранилище.

Ручной rebuild index

Сейчас backend не отслеживает изменения файлов автоматически. Если документ изменился нужно вручную нажать rebuild index. Хотел что бы было видно, что индекс отдельное состояние. Для реальной эксплуатации лучше добавить автоматическое отслеживание изменений, хэши документов, scheduled rebuild или фоновую индексацию.

Простая нарезка на чанки

Чанки создаются простым способом: по размеру текста и overlap. Это понятно но не идеально. Chunk может начинаться в середине раздела, заголовок может отделиться от текста а один фрагмент может смешивать несколько тем.

Следующий уровень markdown aware chunking: учитывать заголовки, списки, абзацы и смысловые блоки.

Качество embeddings

Retrieval зависит от embedding модели. Semantic search может найти тематически похожий chunk, но не тот который нужен для ответа. Особенно это заметно на технических токенах. Exact-term guard частично решает проблему, но не заменяет качественную retrieval стратегию.

Скорость локальной LLM

Локальная генерация может быть медленной. В тестах retrieval был относительно стабильным а основное время занимал generation. Это нормально для локальной модели, но важно показывать пользователю duration_ms и timings.

Ограниченный prompt context

В prompt нельзя добавлять все документы. Нужно выбирать top-k sources и ограничивать общий размер контекста это компромисс между полнотой ответа, скоростью и качеством.

Sources не гарантируют идеальный ответ

sources, score и chunk_index делают ответ проверяемым, но не гарантируют абсолютную правильность. Модель всё ещё может ответить слишком общо или добавить разумное, но не подтверждённое источниками обобщение. Поэтому sources это диагностический инструмент а не магическая гарантия.

11. Итоги

Начал с простого вызова локальной LLM через FastAPI а в конце получился небольшой backend/RAG пайплайн:

API контракт
request_id
логирование
documents/
chunking
embeddings
in memory vector store
retrieval
sources
score
chunk_index
manual rebuild index
timings breakdown
negative tests
stale index tests
exact-term guard

Главный вывод для меня: LLM приложение становится системой не в момент когда модель начинает отвечать, оно становится системой когда вокруг модели появляются API, источники, диагностика, управляемый индекс, понятные ошибки и честные ограничения.

RAG это не один prompt это pipeline. И качество ответа зависит не только от генеративной модели, но и от документов, чанкинга, embeddings, retrieval thresholds, exact-match логики, prompt assembly, скорости локальной машины и наблюдаемости.

Понравился именно backend взгляд на задачу. Не "давайте прикрутим AI", а "Давайте построим понятный сервис вокруг LLM". Пусть локальный, учебный и не production ready, но с теми инженерными деталями, которые потом можно развивать.

Поэтому ценность проекта не в том, что он отвечает на вопросы по документам. Ценность в том, что у ответа появляется объяснимая цепочка: какие документы были загружены, какие chunks найдены, какие sources попали в prompt, сколько занял каждый этап и почему backend решил вызывать или не вызывать LLM.

Исходный код проекта доступен на GitHub: local-rag-assistant. В репозитории есть backend на FastAPI, простой frontend, RAG pipeline, ручной rebuild index, timings breakdown и exact-term guard для технических токенов.

Что можно улучшить дальше

Не стал добавлять всё сразу, но направления понятны:

  • заменить in memory vector store на persistent хранилище;

  • сделать markdown aware chunking;

  • добавить автоматический rebuild index по изменению документов;

  • улучшить /rag/debug чтобы явно показывать strong, borderline, rejected и exact-match sources;

  • добавить hybrid search: semantic + keyword;

  • добавить тесты качества retrieval;

  • сократить prompt и подобрать параметры генерации;

  • добавить мониторинг и отдельные метрики latency;

  • аккуратно вынести конфигурацию моделей и thresholds.

Но это уже следующий этап. Для проекта мне было важно пройти путь от простого вызова локальной LLM до понятного backend/RAG приложения и увидеть какие инженерные вопросы появляются по дороге.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Что для вас самое сложное в локальном RAG?
57.14%Качество поиска по документам4
57.14%Скорость локальной LLM4
0%Подбор chunking и embeddings0
0%Диагностика: sources, score, request_id0
14.29%Поддержка индекса в актуальном состоянии1
28.57%Пока не пробовал локальный RAG2
Проголосовали 7 пользователей. Воздержался 1 пользователь.