374 lines
13 KiB
Markdown
374 lines
13 KiB
Markdown
# План: 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
|
||
|
||
```python
|
||
# 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:**
|
||
|
||
```python
|
||
# 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": "Проверьте логин"}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Ответ:
|
||
```json
|
||
{
|
||
"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` и др. Выбор уточнить при старте — нужно тестировать качество на русском языке.
|
||
|
||
```python
|
||
# 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
|
||
}
|
||
```
|
||
|
||
Ответ:
|
||
```json
|
||
{
|
||
"answer": "Проверьте настройки камеры...",
|
||
"sources": [
|
||
{
|
||
"ticket_id": "ext-001",
|
||
"score": 3.51,
|
||
"category": "Сбой"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Внутренняя логика search:
|
||
|
||
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"`.
|
||
|
||
```python
|
||
# 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 (локальная разработка)
|
||
|
||
```yaml
|
||
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 как опция и хранение в РФ.
|