docs: полная документация ассистента (архитектура, API, поиск, безопасность, деплой, тесты, эмбеддинги v2)
This commit is contained in:
@@ -0,0 +1,285 @@
|
|||||||
|
# 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-совместимый интерфейс).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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. Пайплайн по шагам
|
||||||
|
|
||||||
|
1. **Валидация** (`validateInput`): Content-Type, Content-Length ≤ 2 КБ, потоковый
|
||||||
|
счётчик байт, JSON, непустой `question`.
|
||||||
|
2. **Rate-limit**: скользящее окно по IP, лимит делится на число реплик.
|
||||||
|
3. **Кэш**: ключ `stand + "\u0000" + normalizeQuery(question)`.
|
||||||
|
4. **Дедуп**: одинаковый запрос в работе — ждёт результат первого (счётчики
|
||||||
|
«ожидающие» / «держащие»), обрыв одного клиента не отменяет общий вызов.
|
||||||
|
5. **Семафор**: `ASSIST_MAX_CONCURRENT`; сверх — 503 + `Retry-After`.
|
||||||
|
6. **Этап 1** (`stage1.js`): LLM определяет `resource_type` из списка реальных
|
||||||
|
`nubes_*` (список из индекса). Не из списка → отбрасывается. Пустой `content`
|
||||||
|
→ повтор с x2 `max_tokens`.
|
||||||
|
7. **Поиск** (`search.js`): BM25-подобный, токены + транслит + буст страницам
|
||||||
|
ресурсов. Объединение (П.8): поиск по исходному запросу + по `resource_type`,
|
||||||
|
повторы по `location`, оценка = max, страница ресурса всегда первой.
|
||||||
|
8. **Этап 2** (`stage2.js`): LLM генерирует ответ по строгому шаблону
|
||||||
|
«Роль/Задача/Контекст/Требования/Ограничения». Нет заголовков → повтор с x2
|
||||||
|
`max_tokens` (до 4096). Ошибка осталась → 502.
|
||||||
|
9. **Ответ**: `answer`, `sources` (только валидные URL `/<stand>/<location>`),
|
||||||
|
`meta` (время этапов, токены).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Поиск — детали (v1, лексический)
|
||||||
|
|
||||||
|
- **Токенизация**: lowercase, удаление HTML, разбиение на токены `[a-zа-яё0-9_]+`,
|
||||||
|
отбрасывание стоп-слов (рус. + англ.).
|
||||||
|
- **Транслит** (`TRANSLIT` в `search.js`): «постгрес»→`postgres`, «бакет»→`bucket`,
|
||||||
|
«топик»→`topic` и т.д.
|
||||||
|
- **Буст по типу страницы**: страница ресурса ×2.0, руководство (`_manual`) ×0.6,
|
||||||
|
служебная (`curated`/`30_registry`) ×0.4.
|
||||||
|
- **Подресурс → родитель**: `nubes_postgres_database` при поиске дополняется
|
||||||
|
токеном `postgres` (`resourceBaseSegment`).
|
||||||
|
- **Предтокенизация**: токены всех 823 документов считаются один раз при загрузке
|
||||||
|
индекса (поиск ~6 мс вместо ~1.2 с).
|
||||||
|
- **Списки**: `listResourceTypes()` (56 `nubes_*`) и `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)
|
||||||
|
1. `git push` в `terraform/tf_docs`.
|
||||||
|
2. Redeploy экземпляра в ЛК **или** `node llm_project/tests/redeploy.js`
|
||||||
|
(Bearer из `tf_provider/secrets/test.token`, CFS-параметр `startupConfiguration`).
|
||||||
|
|
||||||
|
### `tf_provider` (виджет + сайт)
|
||||||
|
1. `git push` в `terraform/tf_provider`.
|
||||||
|
2. Сборка и публикация:
|
||||||
|
`DOCS_SKIP_LINT=1 TOOLS/scripts/04_build_and_publish_docs.sh --profile TOOLS/config/test`
|
||||||
|
→ S3 `terraform-registry/docs/nubes-test/nubes/`.
|
||||||
|
3. Зеркало на ВМ 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 дока).
|
||||||
|
|
||||||
|
### Как реализовать (механика)
|
||||||
|
1. `POST /v1/embeddings` (модель `user-bge-m3`, батчем) для каждого документа при
|
||||||
|
загрузке индекса → `Map`.
|
||||||
|
2. На запрос: эмбеддинг вопроса → косинусное сходство по всем докам → top-K.
|
||||||
|
3. Объединить с лексическим поиском по max (как уже сделано в `mergeSearchResults`).
|
||||||
|
4. Опционально: top-20 кандидатов → `bge-reranker-v2-m3` → точный порядок.
|
||||||
|
|
||||||
|
### Сложность и цена
|
||||||
|
- **~50-100 строк** нового модуля `vectorSearch.js`, без новых npm-зависимостей
|
||||||
|
(fetch уже есть). API проверен (`user-bge-m3` 1024 dims, `bge-reranker-v2-m3`).
|
||||||
|
- **Цена**: разовые 823 эмбеддинга при старте + 1 эмбеддинг на вопрос (+реранкер ~0.3-1 с).
|
||||||
|
- **Риск**: низкий (параллельно лексическому, объединение по max).
|
||||||
|
- **Оценка трудозатрат**: ~полдня.
|
||||||
|
|
||||||
|
### Когда делать
|
||||||
|
**Не сейчас.** Реальные пользователи ещё не тестировали — неизвестны реальные
|
||||||
|
промахи. Рекомендация: выкатить v1 юзерам, собрать реальные вопросы, и уже по ним
|
||||||
|
решать о внедрении эмбеддингов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Известные ограничения и точки роста
|
||||||
|
|
||||||
|
1. Поиск лексический (нет семантики) — см. раздел 12.
|
||||||
|
2. `resource_type` определяется ~47/62; реальный промах — выбор подресурса
|
||||||
|
(например `nubes_mariadb_database` вместо `nubes_mariadb`). Лечится few-shot
|
||||||
|
примерами в промпте этапа 1.
|
||||||
|
3. Ответ жёстко по 5 заголовкам — безопасно, но не гибко (осознанный trade-off).
|
||||||
|
4. Кэш ответов без TTL/размерного лимита (in-memory Map). При долгой работе
|
||||||
|
стоит добавить LRU + TTL.
|
||||||
|
5. `remoteIsInCidr` упрощён до точного совпадения/`/32` — если появится настоящий
|
||||||
|
proxy-CIDR, доработать.
|
||||||
|
6. Один под (replicas=1) — лимиты/кэш не шарятся между репликами (принято в брифе 7.4).
|
||||||
|
7. Задержка доминируется LLM; ускоряется стримингом (SSE) или параллелизацией
|
||||||
|
этапа 1 с поиском (v2).
|
||||||
Reference in New Issue
Block a user