19 KiB
AI-ассистент документации Nubes — полная документация
Дата: 2026-10-07
Статус: работает на проде (https://tf-docs.nodejsk8s.dev.nubes.ru/nubes-test/).
Репозитории: tf_docs (backend), tf_provider (виджет + сборка сайта).
1. Что это
Помощник на сайте документации: пользователь описывает задачу своими словами, получает ответ строго по шаблону со ссылками на страницы документации.
Это не свободный чат, а RAG-пайплайн: сначала находится релевантный фрагмент документации, затем LLM пересказывает его в фиксированном формате.
Модель: gpt-oss-120b (через api.aillm.ru, OpenAI-совместимый интерфейс).
1.1 Стенды
Ассистент stand-aware: stand берётся из первого сегмента пути, индекс —
из VM_DOCS_BASE_URL/<stand>/search/search_index.json. Включён на всех трёх стендах:
| Стенд | URL | Виджет | Эндпоинт |
|---|---|---|---|
nubes-test |
/nubes-test/ |
✅ | ✅ |
nubes-dev |
/nubes-dev/ |
✅ | ✅ |
nubes (prod) |
/nubes/ |
✅ | ✅ |
Каждый стенд отвечает по своему индексу. Кэш индекса/ответов и лимиты ключуются по стенду.
2. Архитектура
Браузер (виджет assistant.js)
│ POST /<stand>/_api/assist {"question": "..."}
▼
tf_docs (Node.js, кластер nodejsk8s) [orchestrator.js]
├─ validateInput (415/413/400) [assist.js]
├─ rate-limit по IP (429 + Retry-After) [assist.js]
├─ кэш ответов (ключ = stand+нормализованный вопрос)
├─ дедуп одинаковых запросов (inflight)
├─ семафор (ASSIST_MAX_CONCURRENT=4, иначе 503)
├─ общий дедлайн 40 с (AbortController)
├─ Этап 1: LLM → resource_type [stage1.js]
├─ Поиск: search_index.json → top-K [search.js]
├─ Объединение результатов (П.8) [stage2.js]
└─ Этап 2: LLM → ответ по шаблону [stage2.js]
▼
Ответ: {answer, sources[], meta}
Индекс берётся с VM_DOCS_BASE_URL/<stand>/search/search_index.json
(ВМ 213 раздаёт статику MkDocs).
3. Файлы и ответственность
tf_docs (backend)
| Файл | Что делает |
|---|---|
server.js |
HTTP-сервер: прокси GET/HEAD на ВМ + ветка POST /<stand>/_api/assist до проверки 405. Грузит orchestrator.js в try/catch (не роняет статику). |
assist.js |
Каркас: validateInput, createRateLimiter, createSemaphore, llmChat, jsonError, normalizeQuery. Читает env-переменные. |
search.js |
Лексический поиск: токенизация, HTML-очистка, стоп-слова, транслит, буст страницам ресурсов, предтокенизация, списки nubes_*. |
stage1.js |
Этап 1: извлечение resource_type через LLM (response_format: json_object), сверка со списком реальных ресурсов. |
stage2.js |
Объединение результатов поиска (П.8) + этап 2: генерация ответа по строгому шаблону с повторами. |
orchestrator.js |
Связка пайплайна: кэш индекса, дедлайн, логирование, сбор токенов, ответ с meta. |
Dockerfile |
Сборка образа (node:18-alpine), копирует все модули. |
llm_project/tests/ |
Тест-прогон, redeploy-скрипт, разбор результатов. |
tf_provider (виджет + сайт)
| Файл | Что делает |
|---|---|
docs/30_registry/javascripts/assistant.js |
Виджет (FAB «LLM», панель, вопрос/ответ, таймер, источники). ES5, IIFE, без зависимостей, вывод только через textContent. |
docs/30_registry/assets/extra.css |
Стили виджета. |
mkdocs.yml |
Подключение assistant.js в extra_javascript. |
4. Контракт API
POST /<stand>/_api/assist
Запрос: {"question": "строка"} (Content-Type: application/json, тело ≤ 2 КБ).
Ответ 200:
{
"answer": "ответ по шаблону Роль/Задача/Контекст/Требования/Ограничения",
"sources": [{"title": "...", "url": "/<stand>/..."}],
"meta": {
"model": "gpt-oss-120b",
"resource_type": "nubes_... или null",
"stage1_ok": true,
"elapsed_ms": 12000,
"index_ms": 86, "stage1_ms": 1200, "search_ms": 6, "stage2_ms": 8300,
"tokens": 2868, "prompt_tokens": 2105, "completion_tokens": 763
}
}
Ошибки (JSON {"error": code, "message": ...}):
| Код | HTTP | Смысл |
|---|---|---|
| 400 | 400 | невалидный JSON / пустой вопрос |
| 413 | 413 | тело > 2 КБ |
| 415 | 415 | Content-Type не application/json |
rate_limited |
429 | превышен лимит (заголовок Retry-After) |
busy |
503 | семафор исчерпан (Retry-After: 5) |
bad_llm_output |
502 | LLM не выдал обязательные заголовки |
index_unavailable |
503 | не удалось загрузить индекс |
5. Переменные окружения (jsonEnv в ЛК)
| Имя | По умолчанию | Назначение |
|---|---|---|
VM_DOCS_BASE_URL |
— (обязательна) | http://5.172.178.213 (источник статики и индекса) |
LLM_BASE_URL |
— | https://api.aillm.ru |
LLM_API_KEY |
— | ключ ЛЛМ (Secret, не логировать) |
LLM_MODEL |
gpt-oss-120b |
модель |
ASSIST_MAX_CONCURRENT |
4 |
семафор одновременных вызовов ЛЛМ |
ASSIST_TOTAL_DEADLINE_MS |
40000 |
общий бюджет запроса |
ASSIST_LLM_TIMEOUT_MS |
25000 |
таймаут одного вызова ЛЛМ |
ASSIST_RATE_PER_WINDOW |
6 |
лимит запросов на IP |
ASSIST_RATE_WINDOW_MS |
60000 |
окно rate-limit |
ASSIST_REPLICAS |
1 |
делитель лимита на реплики |
ASSIST_MAX_BODY |
2048 |
макс. размер тела |
ASSIST_INDEX_TTL_MS |
600000 |
TTL кэша индекса (10 мин) |
ASSIST_TRUSTED_PROXY_CIDR |
— | CIDR доверенного proxy для X-Forwarded-For |
6. Пайплайн по шагам
- Валидация (
validateInput): Content-Type, Content-Length ≤ 2 КБ, потоковый счётчик байт, JSON, непустойquestion. - Rate-limit: скользящее окно по IP, лимит делится на число реплик.
- Кэш: ключ
stand + "\u0000" + normalizeQuery(question). - Дедуп: одинаковый запрос в работе — ждёт результат первого (счётчики «ожидающие» / «держащие»), обрыв одного клиента не отменяет общий вызов.
- Семафор:
ASSIST_MAX_CONCURRENT; сверх — 503 +Retry-After. - Этап 1 (
stage1.js): LLM определяетresource_typeиз списка реальныхnubes_*(список из индекса). Не из списка → отбрасывается. Пустойcontent→ повтор с x2max_tokens. - Поиск (
search.js): BM25-подобный, токены + транслит + буст страницам ресурсов. Объединение (П.8): поиск по исходному запросу + поresource_type, повторы поlocation, оценка = max, страница ресурса всегда первой. - Этап 2 (
stage2.js): LLM генерирует ответ по строгому шаблону «Роль/Задача/Контекст/Требования/Ограничения». Нет заголовков → повтор с x2max_tokens(до 4096). Ошибка осталась → 502. - Ответ:
answer,sources(только валидные URL/<stand>/<location>),meta(время этапов, токены).
7. Поиск — детали (v1, лексический)
- Токенизация: lowercase, удаление HTML, разбиение на токены
[a-zа-яё0-9_]+, отбрасывание стоп-слов (рус. + англ.). - Транслит (
TRANSLITвsearch.js): «постгрес»→postgres, «бакет»→bucket, «топик»→topicи т.д. - Буст по типу страницы (приоритет — «примеры в первую очередь»):
curated/*(готовые примеры, проверенные на реальных деплоях) — ×2.4;- страница ресурса — ×2.0;
- руководство (
_manual) — ×0.6; - служебная (
30_registry) — ×0.4.
- Подресурс → родитель:
nubes_postgres_databaseпри поиске дополняется токеномpostgres(resourceBaseSegment). - Предтокенизация: токены всех 823 документов считаются один раз при загрузке индекса (поиск ~6 мс вместо ~1.2 с).
- Списки:
listResourceTypes()(56nubes_*) иresourcePages()(35 страниц) считаются один раз.
8. Надёжность
- Повторы этапа 2: до 2 попыток, при обрезке ответа и транзиентных ошибках ЛЛМ (кроме отмены клиентом) — с удвоением бюджета.
- Дедлайн 40 с:
AbortControllerпрерывает вызовы ЛЛМ. - Отмена при закрытии соединения:
req.on('close')→ abort (если нет других держащих). - Кэш индекса: TTL 10 мин; при сбое загрузки — работаем на свежем кэше.
- Кэш ответов: in-memory Map (без TTL сейчас, чистится только перезапуском).
- Логирование: однострочный JSON в stdout —
status, stand, question_len, total_ms, index_ms, stage1_ms, search_ms, stage2_ms, tokens. Текст вопросов НЕ логируется.
9. Безопасность
- Prompt injection: ответ строго по шаблону; вопросы вне документации → шаблонный отказ в разделе «Ограничения».
- Свободная ЛЛМ: жёсткий
max_tokens, 502 при нарушении шаблона. sources: только из индекса, отбрасываются URL с//,:,...- XSS: вывод в виджете только через
textContent, ссылки — только относительные. - Ключ: не логируется, не попадает в ответ, ошибки маскируют его.
- SSRF: URL индекса строится только из
VM_DOCS_BASE_URL+ проверенногоstand.
10. Деплой
tf_docs (backend)
git pushвterraform/tf_docs.- Redeploy экземпляра в ЛК или
node llm_project/tests/redeploy.js(Bearer изtf_provider/secrets/test.token, CFS-параметрstartupConfiguration).
tf_provider (виджет + сайт)
git pushвterraform/tf_provider.- Сборка и публикация:
DOCS_SKIP_LINT=1 TOOLS/scripts/04_build_and_publish_docs.sh --profile TOOLS/config/test→ S3terraform-registry/docs/nubes-test/nubes/. - Зеркало на ВМ 213:
ssh naeel@5.172.178.213 "mc mirror --overwrite --remove nubes/terraform-registry/docs/nubes-test/nubes/ /var/www/tf-docs/nubes-test/"
11. Тестирование
llm_project/tests/run_e2e_live.js — 62 вопроса по живому эндпоинту (учёт 429).
Итог по прогонам (62 вопроса):
| Прогон | 200 OK | Отказов | Причина и фикс |
|---|---|---|---|
| 1 | 56/62 | 6 | обрезка max_tokens: 1024 → 2048 + повтор; баг \b с кириллицей |
| 2 | 60/62 | 2 | + повтор при транзиентных ошибках |
| 3 | 61/62 | 1 | + шаблон для вопросов вне документации |
| 4 | 62/62 | 0 | — |
| 5 | 61/62 | 1 | клиентский сбой сети (не бэкенд) |
Задержки (5-й прогон): p50 7.7 с, p95 23 с, max 40.8 с — доминирует LLM
(gpt-oss-120b под нагрузкой), наш код ≈ 6 мс поиск + кэш.
Разбор: llm_project/tests/ANALYSIS_bad_llm_output.md.
12. Эмбеддинги + реранкер (v2) — детальный план (НЕ реализовано)
Что это
Семантический поиск: текст превращается в вектор из 1024 чисел моделью
user-bge-m3; похожие по смыслу тексты дают близкие векторы. Сравнение —
косинусное сходство. Ранжирование кандидатов — моделью bge-reranker-v2-m3.
Что даст
- Точное попадание на перефразах («поднять ВМ» = «создать виртуальную машину»).
- Устойчивость к синонимам и русской морфологии, где лексический поиск промахивается.
- Главный качественный скачок из оставшихся возможных улучшений.
Как хранить результат
- Один документ = 1024 float32 = 4 КБ. 823 документа ≈ 3.4 МБ.
- Хранить в памяти процесса (
Map: location → Float32Array), рядом с кэшем индекса. - Отдельной БД/файла не нужно (диск эфемерный, БД противоречит «без зависимостей»).
- Пересчитывается при перезапуске пода (разово ~30-60 с на 823 дока).
Как реализовать (механика)
POST /v1/embeddings(модельuser-bge-m3, батчем) для каждого документа при загрузке индекса →Map.- На запрос: эмбеддинг вопроса → косинусное сходство по всем докам → top-K.
- Объединить с лексическим поиском по max (как уже сделано в
mergeSearchResults). - Опционально: top-20 кандидатов →
bge-reranker-v2-m3→ точный порядок.
Сложность и цена
- ~50-100 строк нового модуля
vectorSearch.js, без новых npm-зависимостей (fetch уже есть). API проверен (user-bge-m31024 dims,bge-reranker-v2-m3). - Цена: разовые 823 эмбеддинга при старте + 1 эмбеддинг на вопрос (+реранкер ~0.3-1 с).
- Риск: низкий (параллельно лексическому, объединение по max).
- Оценка трудозатрат: ~полдня.
Когда делать
Не сейчас. Реальные пользователи ещё не тестировали — неизвестны реальные промахи. Рекомендация: выкатить v1 юзерам, собрать реальные вопросы, и уже по ним решать о внедрении эмбеддингов.
13. Известные ограничения и точки роста
- Поиск лексический (нет семантики) — см. раздел 12.
resource_typeопределяется ~47/62; реальный промах — выбор подресурса (напримерnubes_mariadb_databaseвместоnubes_mariadb). Лечится few-shot примерами в промпте этапа 1.- Ответ жёстко по 5 заголовкам — безопасно, но не гибко (осознанный trade-off).
- Кэш ответов без TTL/размерного лимита (in-memory Map). При долгой работе стоит добавить LRU + TTL.
remoteIsInCidrупрощён до точного совпадения//32— если появится настоящий proxy-CIDR, доработать.- Один под (replicas=1) — лимиты/кэш не шарятся между репликами (принято в брифе 7.4).
- Задержка доминируется LLM; ускоряется стримингом (SSE) или параллелизацией этапа 1 с поиском (v2).
14. Исправленный инцидент: сборка nubes-dev
При публикации dev-документации сборка падала на YAML-ошибке nav:
- [DEPRECATED] Публичные IP адреса: ← невалидный YAML
Причина: генератор TOOLS/docs-generator/internal/writers/writers.go
(WriteNavFragment) писал ServiceDisplayName без кавычек. У vcexternalip в
dev имя = [DEPRECATED] Публичные IP адреса → ломал парсер.
Фикс (корень): в генератор добавлен yamlScalar() — заголовки с спецсимволами
([ ] : # & * и др.) кавычатся. Затронул все стенды на будущее.
Разовый обход: для немедленной публикации dev строка в
generated/dev/docs/_nav_fragment.yml закавычена вручную (файл генерируемый,
перезапишется корректно после фикса генератора).
Коммит фикса: tf_provider 322235c.