Initial commit
This commit is contained in:
commit
fa620edcf8
12 changed files with 928 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
chroma_db/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.streamlit/
|
||||||
|
vector_store/
|
||||||
|
*.faiss.bin
|
||||||
374
.opencode/plans/saas-rag-plan.md
Normal file
374
.opencode/plans/saas-rag-plan.md
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
# План: 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 как опция и хранение в РФ.
|
||||||
172
README.md
Normal file
172
README.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# RAG-ассистент техподдержки
|
||||||
|
|
||||||
|
Система поиска решений по истории обращений в техподдержку. Основана на RAG (Retrieval-Augmented Generation) — находит похожие решённые тикеты и формирует ответ на их основе.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
tickets.json (792 тикета)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
prepare_data.py ← фильтрация, очистка, нарезка чанков
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
sentence-transformers ← векторизация (intfloat/multilingual-e5-small, GPU CUDA)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
chroma_db/ ← векторная база данных (416 чанков)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
app.py ← Streamlit-интерфейс
|
||||||
|
│
|
||||||
|
├── вопрос специалиста → embedding → поиск топ-5
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
└── контекст → LM Studio (qwen2.5-7b) → ответ
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
### 1. Подготовка данных (`prepare_data.py`)
|
||||||
|
|
||||||
|
Что делает скрипт:
|
||||||
|
|
||||||
|
- Читает все файлы `tickets*.json` в папке проекта (сейчас 6 файлов, ~3300 тикетов)
|
||||||
|
- **Чистит текст**: удаляет цитаты `[q]...[/q]`, ссылки, лишние пробелы
|
||||||
|
- **Фильтрует**: оставляет только тикеты с реальным решением. Отбрасываются:
|
||||||
|
- тикеты, где последнее сообщение support — просьба уточнить / прислать скрин
|
||||||
|
- тикеты без ответа / с закрытием "нет обратной связи"
|
||||||
|
- слишком короткие сообщения (< 100 символов)
|
||||||
|
- **Формирует чанки**: каждый тикет = один чанк вида:
|
||||||
|
```
|
||||||
|
Категория: ...
|
||||||
|
Проблема: <описание>
|
||||||
|
Решение: <все сообщения support>
|
||||||
|
```
|
||||||
|
- **Векторизует**: превращает текст в вектор (384 числа) через `intfloat/multilingual-e5-small` на GPU
|
||||||
|
- **Сохраняет** в ChromaDB (папка `chroma_db/`)
|
||||||
|
|
||||||
|
Результат: 416 чанков с реальными решениями (376 тикетов отфильтровано как бесполезные).
|
||||||
|
|
||||||
|
### 2. Поиск и генерация (`app.py`)
|
||||||
|
|
||||||
|
Специалист вводит проблему клиента. Приложение:
|
||||||
|
|
||||||
|
1. **Векторизует вопрос** той же моделью эмбеддингов
|
||||||
|
2. **Ищет 5 похожих чанков** в ChromaDB по косинусной близости
|
||||||
|
3. **Формирует контекст** из найденных тикетов
|
||||||
|
4. **Отправляет в LM Studio** (чат-модель Qwen2.5-7B) с промптом:
|
||||||
|
> "Ответь на основе ТОЛЬКО переданного контекста. Не придумывай, не отсылай в другую поддержку"
|
||||||
|
5. **Показывает ответ** и исходные тикеты для проверки
|
||||||
|
|
||||||
|
## Установка и запуск
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- NVIDIA GPU с 8+ GB VRAM (для эмбеддингов и LLM)
|
||||||
|
- [LM Studio](https://lmstudio.ai/) (для чат-модели)
|
||||||
|
|
||||||
|
### Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Установить PyTorch с CUDA (если ещё не):
|
||||||
|
```bash
|
||||||
|
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu124
|
||||||
|
```
|
||||||
|
|
||||||
|
### Подготовка базы знаний
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python prepare_data.py
|
||||||
|
```
|
||||||
|
Скрипт создаст папку `chroma_db/` с векторной базой.
|
||||||
|
|
||||||
|
### Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
streamlit run app.py --server.headless true
|
||||||
|
```
|
||||||
|
Открыть в браузере: http://localhost:8501
|
||||||
|
|
||||||
|
### Запуск чат-модели
|
||||||
|
|
||||||
|
Для генерации ответов нужна чат-модель в LM Studio:
|
||||||
|
1. Открыть LM Studio
|
||||||
|
2. Вкладка Developer → загрузить модель (например, `Qwen2.5-7B-Instruct`)
|
||||||
|
3. Нажать Start Server
|
||||||
|
4. API будет доступен на `http://localhost:1234/v1`
|
||||||
|
|
||||||
|
Без чат-модели приложение работает в режиме поиска — показывает похожие тикеты без генерации ответа.
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
support-bot/
|
||||||
|
├── tickets*.json # исходные данные (несколько файлов)
|
||||||
|
├── prepare_data.py # подготовка и векторизация
|
||||||
|
├── app.py # Streamlit-интерфейс + RAG
|
||||||
|
├── requirements.txt # зависимости
|
||||||
|
├── chroma_db/ # векторная БД (создаётся prepare_data.py)
|
||||||
|
└── .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
## Системные промпты
|
||||||
|
|
||||||
|
Главный системный промпт — в файле **`app.py:15`**, переменная `SYSTEM_PROMPT`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SYSTEM_PROMPT = """Ты — специалист техподдержки. Отвечай клиенту, используя ТОЛЬКО информацию из переданных тикетов (контекст).
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- Если контекст содержит подходящее решение — напиши ответ своими словами, адаптируя под вопрос
|
||||||
|
- Если контекст не относится к вопросу — напиши: «Недостаточно информации в истории обращений»
|
||||||
|
- НЕ придумывай ответы, НЕ используй общие знания
|
||||||
|
- НЕ говори «обратитесь в службу поддержки» — ты сам и есть поддержка
|
||||||
|
- Укажи в конце: «Основано на тикете №...»"""
|
||||||
|
```
|
||||||
|
|
||||||
|
Именно этот промпт управляет тем, **как LLM отвечает** на вопрос специалиста. Если нужно изменить стиль ответа, тон, правила или требования к формату — редактировать здесь.
|
||||||
|
|
||||||
|
Промпт отправляется в LM Studio как `system`-сообщение при каждом запросе генерации ответа (строка `app.py:120-127`).
|
||||||
|
|
||||||
|
### Фильтрация тикетов без решения
|
||||||
|
|
||||||
|
Паттерны для отбраковки пустых тикетов — в **`prepare_data.py:19-22`**, переменная `NON_SOLUTION`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
NON_SOLUTION = re.compile(
|
||||||
|
r"(уточнит|приложите скрин|какая ошибка|с какой проблемой"
|
||||||
|
r"|нет обратной связи|запрос завершу|открыть его снова"
|
||||||
|
r"|откройте новый|обратиться в службу поддержки"
|
||||||
|
r"|свяжитесь с технической"
|
||||||
|
r"|напишите нам|позвоните нам)", re.I
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Если в последнем сообщении support встречается одно из этих слов — тикет считается бесполезным и не попадает в базу знаний. Можно расширять или уточнять список.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
| Компонент | Технология |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Эмбеддинги | `intfloat/multilingual-e5-small` (384d, GPU) через `sentence-transformers` |
|
||||||
|
| Векторная БД | ChromaDB |
|
||||||
|
| Чат-модель | Qwen2.5-7B-Instruct через LM Studio (OpenAI-совместимый API) |
|
||||||
|
| Фреймворк | LangChain + Streamlit |
|
||||||
|
| Язык | Python 3.11 |
|
||||||
|
| GPU | CUDA 12.4+ |
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote add origin https://git.itoservice.ru/dosnav/support-bot.git
|
||||||
|
git push -u origin master
|
||||||
|
```
|
||||||
185
app.py
Normal file
185
app.py
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import faiss
|
||||||
|
import numpy as np
|
||||||
|
import streamlit as st
|
||||||
|
from langchain_core.embeddings import Embeddings
|
||||||
|
from openai import OpenAI
|
||||||
|
from sentence_transformers import SentenceTransformer, CrossEncoder
|
||||||
|
|
||||||
|
LM_STUDIO_URL = os.getenv("LM_STUDIO_URL", "http://localhost:1234")
|
||||||
|
EMBED_MODEL = "intfloat/multilingual-e5-large"
|
||||||
|
VECTOR_DIR = Path(__file__).parent / "vector_store"
|
||||||
|
VECTOR_INDEX = VECTOR_DIR / "index.faiss.bin"
|
||||||
|
VECTOR_META = VECTOR_DIR / "metadata.pkl"
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """Ты — специалист техподдержки. Отвечай клиенту, используя ТОЛЬКО информацию из переданных тикетов (контекст).
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- Если контекст содержит подходящее решение — напиши ответ своими словами, адаптируя под вопрос
|
||||||
|
- Если контекст не относится к вопросу — напиши: «Недостаточно информации в истории обращений»
|
||||||
|
- НЕ придумывай ответы, НЕ используй общие знания
|
||||||
|
- НЕ говори «обратитесь в службу поддержки» — ты сам и есть поддержка
|
||||||
|
- Укажи в конце: «Основано на тикете №...»"""
|
||||||
|
|
||||||
|
FETCH_K = 20 # сколько достаём из FAISS для реранжа
|
||||||
|
FINAL_K = 5 # сколько оставляем после реранжа
|
||||||
|
|
||||||
|
|
||||||
|
class LocalEmbeddings(Embeddings):
|
||||||
|
def __init__(self, model_name: str):
|
||||||
|
self.model = SentenceTransformer(model_name, device="cuda")
|
||||||
|
|
||||||
|
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||||
|
emb = self.model.encode(texts, normalize_embeddings=True)
|
||||||
|
return emb.tolist()
|
||||||
|
|
||||||
|
def embed_query(self, text: str) -> List[float]:
|
||||||
|
emb = self.model.encode([text], normalize_embeddings=True)
|
||||||
|
return emb[0].tolist()
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_resource
|
||||||
|
def load_embeddings():
|
||||||
|
return LocalEmbeddings(EMBED_MODEL)
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_resource
|
||||||
|
def load_reranker():
|
||||||
|
return CrossEncoder(
|
||||||
|
"cross-encoder/mmarco-mMiniLMv2-L12-H384-v1",
|
||||||
|
max_length=512,
|
||||||
|
device="cpu",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_faiss_index(path: Path):
|
||||||
|
return faiss.deserialize_index(np.frombuffer(path.read_bytes(), dtype=np.uint8))
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_client() -> OpenAI | None:
|
||||||
|
try:
|
||||||
|
OpenAI(base_url=f"{LM_STUDIO_URL}/v1", api_key="not-needed").models.list()
|
||||||
|
return OpenAI(base_url=f"{LM_STUDIO_URL}/v1", api_key="not-needed")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def search_similar(query: str, k: int = FETCH_K):
|
||||||
|
embeddings = load_embeddings()
|
||||||
|
qvec = np.array([embeddings.embed_query(query)], dtype=np.float32)
|
||||||
|
index = load_faiss_index(VECTOR_INDEX)
|
||||||
|
scores, indices = index.search(qvec, k)
|
||||||
|
with open(VECTOR_META, "rb") as f:
|
||||||
|
data = pickle.load(f)
|
||||||
|
metadatas = data["metadatas"]
|
||||||
|
results = []
|
||||||
|
for score, idx in zip(scores[0], indices[0]):
|
||||||
|
if idx < 0 or idx >= len(metadatas):
|
||||||
|
continue
|
||||||
|
results.append((metadatas[idx], float(score)))
|
||||||
|
results.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def rerank(query: str, results: list) -> list:
|
||||||
|
reranker = load_reranker()
|
||||||
|
pairs = [(query, r[0]["full_text"]) for r in results]
|
||||||
|
scores = reranker.predict(pairs)
|
||||||
|
scored = [(results[i][0], float(scores[i])) for i in range(len(results))]
|
||||||
|
scored.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
return scored[:FINAL_K]
|
||||||
|
|
||||||
|
|
||||||
|
def format_context(results) -> str:
|
||||||
|
parts = []
|
||||||
|
for meta, score in results:
|
||||||
|
parts.append(
|
||||||
|
f"Тикет №{meta['ticket_id']} ({meta['category']}, {meta['client']}):\n{meta['full_text']}"
|
||||||
|
)
|
||||||
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_answer(chat: OpenAI, query: str, context: str) -> str:
|
||||||
|
response = chat.chat.completions.create(
|
||||||
|
model="gpt-3.5-turbo",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": f"Контекст (история обращений):\n{context}\n\nВопрос клиента:\n{query}"},
|
||||||
|
],
|
||||||
|
temperature=0.1,
|
||||||
|
max_tokens=2000,
|
||||||
|
)
|
||||||
|
return response.choices[0].message.content
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
st.set_page_config(page_title="RAG техподдержки", layout="wide")
|
||||||
|
st.title("RAG-ассистент техподдержки")
|
||||||
|
st.markdown(
|
||||||
|
"Находит похожие обращения из истории и формирует ответ на их основе."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not VECTOR_INDEX.exists():
|
||||||
|
st.error("Векторный индекс не найден. Запусти: python prepare_data.py")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
chat = get_chat_client()
|
||||||
|
if chat is None:
|
||||||
|
st.warning(
|
||||||
|
"⚠️ Чат-модель не обнаружена. Загрузи модель в LM Studio."
|
||||||
|
)
|
||||||
|
|
||||||
|
with st.form("query_form"):
|
||||||
|
query = st.text_area(
|
||||||
|
"Опишите проблему клиента:",
|
||||||
|
placeholder="Например: клиент не может войти в ТГ-бот, пишет неверный логин",
|
||||||
|
height=100,
|
||||||
|
)
|
||||||
|
submitted = st.form_submit_button("Получить ответ", type="primary")
|
||||||
|
|
||||||
|
if submitted and query:
|
||||||
|
with st.spinner("Ищу похожие обращения..."):
|
||||||
|
results = search_similar(query)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
st.warning("Не найдено похожих обращений")
|
||||||
|
return
|
||||||
|
|
||||||
|
with st.spinner("Оцениваю релевантность..."):
|
||||||
|
results = rerank(query, results)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
st.warning("После проверки релевантности не осталось подходящих тикетов")
|
||||||
|
return
|
||||||
|
|
||||||
|
context = format_context(results)
|
||||||
|
|
||||||
|
if chat:
|
||||||
|
with st.spinner("Формирую ответ на основе найденных тикетов..."):
|
||||||
|
try:
|
||||||
|
answer = generate_answer(chat, query, context)
|
||||||
|
st.subheader("💬 Ответ ассистента")
|
||||||
|
st.markdown(answer)
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Ошибка LLM: {e}")
|
||||||
|
else:
|
||||||
|
st.info("Результаты поиска без генерации ответа:")
|
||||||
|
|
||||||
|
with st.expander("📄 Найденные похожие обращения", expanded=True):
|
||||||
|
for meta, score in results:
|
||||||
|
with st.container(border=True):
|
||||||
|
st.markdown(f"### Тикет №{meta['ticket_id']} (релевантность: {score:.3f})")
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
st.markdown(f"**Категория:** {meta['category']}")
|
||||||
|
with col2:
|
||||||
|
st.markdown(f"**Клиент:** {meta['client']}")
|
||||||
|
st.text(meta["full_text"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
171
prepare_data.py
Normal file
171
prepare_data.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import json
|
||||||
|
import pickle
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import faiss
|
||||||
|
import numpy as np
|
||||||
|
from langchain_core.embeddings import Embeddings
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
|
||||||
|
EMBED_MODEL = "intfloat/multilingual-e5-large"
|
||||||
|
DATA_DIR = Path(__file__).parent
|
||||||
|
TICKETS_PATTERN = "tickets*.json"
|
||||||
|
VECTOR_DIR = DATA_DIR / "vector_store"
|
||||||
|
VECTOR_INDEX = VECTOR_DIR / "index.faiss.bin"
|
||||||
|
VECTOR_META = VECTOR_DIR / "metadata.pkl"
|
||||||
|
|
||||||
|
NON_SOLUTION = re.compile(
|
||||||
|
r"(уточнит|приложите скрин|какая ошибка|с какой проблемой"
|
||||||
|
r"|нет обратной связи|запрос завершу|открыть его снова"
|
||||||
|
r"|откройте новый|обратиться в службу поддержки"
|
||||||
|
r"|свяжитесь с технической"
|
||||||
|
r"|напишите нам|позвоните нам)", re.I
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalEmbeddings(Embeddings):
|
||||||
|
def __init__(self, model_name: str):
|
||||||
|
self.model = SentenceTransformer(model_name, device="cuda")
|
||||||
|
|
||||||
|
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||||
|
emb = self.model.encode(texts, normalize_embeddings=True, show_progress_bar=True)
|
||||||
|
return emb.tolist()
|
||||||
|
|
||||||
|
def embed_query(self, text: str) -> List[float]:
|
||||||
|
emb = self.model.encode([text], normalize_embeddings=True)
|
||||||
|
return emb[0].tolist()
|
||||||
|
|
||||||
|
|
||||||
|
def clean_text(text: str) -> str:
|
||||||
|
text = re.sub(r'\[/?q\].*?\[/q\]', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
text = re.sub(r'https?://\S+', '', text)
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def has_real_solution(messages: list[dict]) -> str | None:
|
||||||
|
support_texts = []
|
||||||
|
for m in messages:
|
||||||
|
if m["role"] == "support":
|
||||||
|
cleaned = clean_text(m["text"])
|
||||||
|
if cleaned:
|
||||||
|
support_texts.append(cleaned)
|
||||||
|
if not support_texts:
|
||||||
|
return None
|
||||||
|
combined = "\n".join(support_texts)
|
||||||
|
if len(combined) < 100:
|
||||||
|
return None
|
||||||
|
last = support_texts[-1]
|
||||||
|
if NON_SOLUTION.search(last):
|
||||||
|
return None
|
||||||
|
if last.strip().endswith("?") and len(last) < 150:
|
||||||
|
return None
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_dialogue(messages: list[dict]) -> str:
|
||||||
|
texts = []
|
||||||
|
for m in messages:
|
||||||
|
if m["role"] == "client":
|
||||||
|
cleaned = clean_text(m["text"])
|
||||||
|
if cleaned:
|
||||||
|
texts.append(cleaned)
|
||||||
|
return "\n".join(texts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_chunk(ticket: dict) -> dict | None:
|
||||||
|
msgs = ticket.get("messages") or []
|
||||||
|
if not msgs:
|
||||||
|
return None
|
||||||
|
description = clean_text(ticket.get("description", ""))
|
||||||
|
solution = has_real_solution(msgs)
|
||||||
|
if not solution:
|
||||||
|
return None
|
||||||
|
|
||||||
|
client_msgs = get_client_dialogue(msgs)
|
||||||
|
|
||||||
|
# Текст для поиска — только описание + переписка клиента
|
||||||
|
search_parts = []
|
||||||
|
if description:
|
||||||
|
search_parts.append(description)
|
||||||
|
if client_msgs:
|
||||||
|
search_parts.append(client_msgs)
|
||||||
|
search_text = "\n".join(search_parts).strip()
|
||||||
|
if not search_text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Полный текст для показа и LLM
|
||||||
|
full_text = (
|
||||||
|
f"Категория: {ticket['category']}\n"
|
||||||
|
f"Проблема: {description}\n"
|
||||||
|
f"Решение: {solution}"
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"ticket_id": ticket["ticket_id"],
|
||||||
|
"client": ticket["client"],
|
||||||
|
"category": ticket["category"],
|
||||||
|
"full_text": full_text,
|
||||||
|
}
|
||||||
|
return {"search_text": search_text, "metadata": metadata}
|
||||||
|
|
||||||
|
|
||||||
|
def load_all_tickets() -> list:
|
||||||
|
all_tickets = []
|
||||||
|
for f in sorted(DATA_DIR.glob(TICKETS_PATTERN)):
|
||||||
|
tickets = json.loads(f.read_text(encoding="utf-8"))
|
||||||
|
print(f" {f.name}: {len(tickets)} тикетов")
|
||||||
|
all_tickets.extend(tickets)
|
||||||
|
return all_tickets
|
||||||
|
|
||||||
|
|
||||||
|
def save_faiss_index(index, path: Path):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(faiss.serialize_index(index))
|
||||||
|
|
||||||
|
|
||||||
|
def load_faiss_index(path: Path):
|
||||||
|
return faiss.deserialize_index(np.frombuffer(path.read_bytes(), dtype=np.uint8))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Загрузка файлов tickets*.json ...")
|
||||||
|
tickets = load_all_tickets()
|
||||||
|
print(f"Всего тикетов: {len(tickets)}")
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
skipped = 0
|
||||||
|
for t in tickets:
|
||||||
|
chunk = build_chunk(t)
|
||||||
|
if chunk is not None:
|
||||||
|
chunks.append(chunk)
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
print(f"Сформировано чанков: {len(chunks)}")
|
||||||
|
print(f"Пропущено (нет решения): {skipped}")
|
||||||
|
|
||||||
|
print(f"Загрузка модели эмбеддингов: {EMBED_MODEL} ...")
|
||||||
|
embeddings = LocalEmbeddings(EMBED_MODEL)
|
||||||
|
|
||||||
|
search_texts = [c["search_text"] for c in chunks]
|
||||||
|
metadatas = [c["metadata"] for c in chunks]
|
||||||
|
|
||||||
|
print("Создание эмбеддингов и FAISS индекса...")
|
||||||
|
vectors = embeddings.embed_documents(search_texts)
|
||||||
|
dim = len(vectors[0])
|
||||||
|
index = faiss.IndexFlatIP(dim)
|
||||||
|
index.add(np.array(vectors, dtype=np.float32))
|
||||||
|
|
||||||
|
save_faiss_index(index, VECTOR_INDEX)
|
||||||
|
with open(VECTOR_META, "wb") as f:
|
||||||
|
pickle.dump({"metadatas": metadatas}, f)
|
||||||
|
|
||||||
|
print(f"FAISS индекс сохранён: {VECTOR_DIR}")
|
||||||
|
print(f"Размерность: {dim}")
|
||||||
|
print(f"Векторов в индексе: {index.ntotal}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
langchain==0.2.15
|
||||||
|
langchain-community==0.2.12
|
||||||
|
langchain-chroma==0.1.4
|
||||||
|
langchain-core==0.2.43
|
||||||
|
chromadb==0.5.9
|
||||||
|
sentence-transformers==3.2.0
|
||||||
|
openai==1.54.3
|
||||||
|
streamlit==1.58.0
|
||||||
1
tickets-202511.json
Normal file
1
tickets-202511.json
Normal file
File diff suppressed because one or more lines are too long
1
tickets-202512.json
Normal file
1
tickets-202512.json
Normal file
File diff suppressed because one or more lines are too long
1
tickets-202601.json
Normal file
1
tickets-202601.json
Normal file
File diff suppressed because one or more lines are too long
1
tickets-202602.json
Normal file
1
tickets-202602.json
Normal file
File diff suppressed because one or more lines are too long
1
tickets-202603.json
Normal file
1
tickets-202603.json
Normal file
File diff suppressed because one or more lines are too long
1
tickets.json
Normal file
1
tickets.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue