support-bot-saas/.opencode/plans/saas-rag-plan.md
2026-06-20 16:07:21 +03:00

13 KiB
Raw Permalink Blame History

План: RAG SaaS для техподдержки

Статус

  • Этап 0 — Форк и структура проекта
  • Этап 1 — Бэкенд: регистрация, авторизация, коллекции
  • Этап 2 — Бэкенд: приём тикетов (UI + API)
  • Этап 3 — Бэкенд: поиск и генерация ответа
  • Этап 4 — Фронтенд: SPA
  • Этап 5 — Инфраструктура: K8s + CI/CD
  • Этап 6 — Мониторинг, биллинг, доки

Этап 0 — Форк и структура

Сделать форк текущего коммита 41b84e6. Новая структура:

support-bot-saas/
├── backend/
│   ├── app/
│   │   ├── main.py              # FastAPI приложение
│   │   ├── config.py            # настройки (env)
│   │   ├── auth/                # JWT, регистрация
│   │   ├── models/              # Pydantic схемы
│   │   ├── routers/
│   │   │   ├── auth.py          # /api/register, /api/login
│   │   │   ├── collections.py   # /api/collections
│   │   │   ├── tickets.py       # /api/collections/:id/tickets
│   │   │   └── search.py        # /api/search
│   │   ├── services/
│   │   │   ├── embedding.py     # E5-large (общий воркер)
│   │   │   ├── vector_store.py  # Qdrant client
│   │   │   ├── chunking.py      # подготовка текста
│   │   │   └── llm.py           # внешний LLM
│   │   └── workers/
│   │       └── process_file.py  # Celery — обработка файлов
│   ├── Dockerfile
│   └── requirements.txt
├── frontend/
│   ├── src/
│   └── Dockerfile
├── helm/                        # K8s чарты
├── docker-compose.yml           # для локальной разработки
└── .github/workflows/           # CI/CD

Что берём из текущего кода:

  • chunking.py — build_chunk, has_real_solution, clean_text
  • cross-encoder реранжировщик (на Django-side или отдельный микросервис)

Этап 1 — Бэкенд: регистрация, коллекции, Qdrant

1.1 FastAPI + PostgreSQL

# models/user.py
class User(Base):
    id: uuid
    email: str
    password_hash: str
    api_key: str        # для API-интеграции
    created_at: datetime
    plan: str           # free, pro, enterprise

# models/collection.py
class Collection(Base):
    id: uuid
    user_id: uuid       # FK → user
    name: str
    ticket_count: int
    created_at: datetime

1.2 Qdrant multi-tenancy

Одна коллекция Qdrant tickets, tenant_id в payload:

# services/vector_store.py
async def create_tenant_index(user_id: str):
    # Qdrant: одна общая коллекция + фильтр по tenant_id
    # Индекс создаётся один раз глобально
    pass

async def upsert_chunks(user_id: str, chunks: list[dict]):
    client.upsert(
        collection_name="tickets",
        points=[
            PointStruct(
                id=chunk["id"],
                vector=chunk["vector"],
                payload={
                    "tenant_id": user_id,
                    "ticket_id": chunk["ticket_id"],
                    "category": chunk["category"],
                    "search_text": chunk["search_text"],
                    "full_text": chunk["full_text"],
                }
            )
        ]
    )

async def search(user_id: str, query_vector: list, k: int = 20):
    return client.query_points(
        collection_name="tickets",
        query=query_vector,
        query_filter=Filter(
            must=[FieldCondition(key="tenant_id", match={"value": user_id})]
        ),
        limit=k,
    )

Почему одна коллекция, а не на пользователя:

  • Qdrant сам рекомендует такую схему для SaaS
  • Тысячи коллекций жрут память (каждая коллекция = свой HNSW-граф в RAM)
  • Фильтр по tenant_id индексируется, поиск не замедляется

Регистрация пользователя:

  1. Создать запись в PostgreSQL
  2. Сгенерировать api_key
  3. Qdrant ничего не делать — фильтрация по tenant_id будет работать автоматически

API endpoints этапа 1

POST /api/register        # email + пароль → JWT + api_key
POST /api/login           # email + пароль → JWT
GET  /api/me              # профиль
POST /api/collections     # создать базу знаний
GET  /api/collections     # список баз
DELETE /api/collections/:id  # удалить базу (не рвать Qdrant)

Этап 2 — Приём тикетов

2.1 Через UI (загрузка файла)

  • Поддерживаемые форматы: JSON, CSV, XLSX
  • После загрузки → Celery task:
    1. Парсинг в единую схему
    2. apply has_real_solution filter
    3. build_chunk → search_text + full_text
    4. E5-large → вектор (через API embedding-воркера)
    5. Upsert в Qdrant
    6. Обновить ticket_count в Collection

2.2 Через API

POST /api/collections/:id/tickets
Authorization: Bearer <api_key>
Content-Type: application/json

{
  "tickets": [
    {
      "ticket_id": "ext-001",
      "category": "Сбой",
      "description": "Не могу войти",
      "messages": [
        {"role": "client", "text": "Пишет ошибку"},
        {"role": "support", "text": "Проверьте логин"}
      ]
    }
  ]
}

Ответ:

{
  "processed": 1,
  "skipped": 0,
  "collection_ticket_count": 142
}

2.3 Embedding — через OpenRouter API

GPU в K8s не будет, embedding тоже через OpenRouter. OpenRouter поддерживает несколько embedding-моделей: text-embedding-3-small, text-embedding-3-large и др. Выбор уточнить при старте — нужно тестировать качество на русском языке.

# services/embedding.py
class OpenRouterEmbeddings:
    def __init__(self, api_key: str, model: str = "text-embedding-3-small"):
        self.client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=api_key)
        self.model = model

    async def embed(self, texts: list[str]) -> list[list[float]]:
        response = await self.client.embeddings.create(
            model=self.model,
            input=texts
        )
        return [item.embedding for item in response.data]

Альтернатива: если качество OpenRouter embedding не устроит — вынести E5-large в отдельный микросервис с CPU (batch inference, ~5-10 текстов/сек) или использовать другой API (Jina, Voyage, Cohere).


Этап 3 — Поиск и генерация

POST /api/search
Authorization: Bearer <api_key>
{
  "collection_id": "uuid",
  "query": "не могу загрузить фото в приложение",
  "generate_answer": true
}

Ответ:

{
  "answer": "Проверьте настройки камеры...",
  "sources": [
    {
      "ticket_id": "ext-001",
      "score": 3.51,
      "category": "Сбой"
    }
  ]
}
  1. E5-large: query → вектор
  2. Qdrant: search по tenant_id + collection_id → 20 кандидатов
  3. Cross-encoder (CPU): rerank query vs full_text всех 20
  4. Взять топ-5
  5. Если generate_answer=true → LLM API (OpenAI/GigaChat) с системным промптом
  6. Вернуть ответ + источники

LLM провайдер — OpenRouter

На этапе разработки — бесплатные модели OpenRouter (deepseek, qwen, gemini-free). Потом платные.

Клиент — стандартный OpenAI SDK с base_url="https://openrouter.ai/api/v1".

# services/llm.py
class OpenRouterLLM:
    def __init__(self, api_key: str, model: str = "deepseek/deepseek-chat"):
        self.client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=api_key)
        self.model = model

    async def generate(self, system_prompt: str, context: str, query: str) -> str:
        response = await self.client.chat.completions.create(
            model=self.model,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": f"Контекст:\n{context}\n\nВопрос:\n{query}"}
            ]
        )
        return response.choices[0].message.content

Модель по умолчанию: уточнить после тестов. Варианты: deepseek/deepseek-chat (дёшево, хорошо для RAG), qwen/qwen-2.5-7b-instruct (бесплатно), google/gemini-2.0-flash-free (бесплатно).

Системный промпт — тот же, что сейчас в app.py.


Этап 4 — Фронтенд

SPA на React (или Vue, решать тебе):

Страницы:

  • /login, /register
  • /dashboard — список коллекций, статистика
  • /collections/:id — просмотр тикетов, загрузка файла, поиск
  • /settings — API ключ, профиль

Чат-интерфейс поиска:

┌─────────────────────┐
│ Вопрос:             │
│ [________________] │
│ [Найти]            │
├─────────────────────┤
│ Ответ ассистента    │
│ ─────────────────── │
│ Текст...            │
│                     │
│ 📄 Найденные        │
│   Тикет №123        │
│   Тикет №456        │
└─────────────────────┘

Технологии: React + Vite + Tailwind или просто Jinja2 + HTMX для MVP.


Этап 5 — Инфраструктура

docker-compose (локальная разработка)

services:
  postgres:
  redis:
  qdrant:
  backend:
    build: ./backend
    env:
      - OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
      - DATABASE_URL=postgresql://...
  frontend:
    build: ./frontend
    env: ...
  worker:          # Celery — парсинг, векторизация, upsert
    build: ./backend
    command: celery -A app.workers worker

K8s (Helm-чарт helm/support-bot-saas/)

Deployments:
├── backend         — FastAPI (2-3 реплики)
├── worker          — Celery (1-2 реплики)
├── frontend        — Nginx + SPA
└── cronjob         — очистка старых данных

StatefulSets:
├── postgres
├── redis
└── qdrant

Ingress: api.domain.com, app.domain.com

CI/CD (GitHub Actions)

push → lint + test → build Docker → push to registry → deploy to k8s

Этап 6 — После запуска

  1. Мониторинг: Prometheus + Grafana (latency поиска, ошибки LLM, кол-во тикетов)
  2. Биллинг: Stripe / YooKassa — free (1 коллекция, 1000 тикетов), pro (10 коллекций, 100k тикетов)
  3. Документация: OpenAPI (Swagger у FastAPI из коробки), README по интеграции
  4. Rate limiting: slowapi — для free-тарифа 10 запросов/мин, для pro — без лимита

Принятые решения

Вопрос Решение
LLM провайдер OpenRouter (бесплатные → платные)
Embedding OpenRouter API (если качество не устроит — отдельный E5-large микросервис на CPU)
Фронтенд React SPA
Очередь задач Celery + Redis
K8s GPU Нет, всё через API
Тарифы Free / Pro / Enterprise

Открытые вопросы

  1. Модель для embedding через OpenRouter — нужно протестировать text-embedding-3-small/large на русском, сравнить с E5-large.
  2. LLM модель по умолчанию — deepseek/qwen/gemini-free, решить после тестов.
  3. Регионы / 152-ФЗ — если целевая аудитория в РФ, нужен GigaChat как опция и хранение в РФ.