diff --git a/README.md b/README.md index 69d558e..5f5e387 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,347 @@ # VMK Data MCP Server -MCP-сервер для поиска по базе данных недвижимости (`vmk_data`). -Предоставляет AI-агенту (Claude) инструменты для: -- **семантического (векторного) поиска** через pgvector + HNSW -- **полнотекстового поиска** через готовую FTS-колонку (украинский конфиг) -- **фильтрации по метаданным** (цена, район, комнаты, метро и т.д.) -- **пагинации и сортировки** по релевантности +MCP-сервер (Model Context Protocol) для интеллектуального поиска по базе данных +недвижимости **`vmk_data`**. Предоставляет AI-агентам (Claude, GPT и др.) безопасный +набор инструментов для семантического и полнотекстового поиска объявлений с +фильтрацией, пагинацией и сортировкой по релевантности. -## Транспорт +--- -**Streamable HTTP** (MCP 2025 spec) на порту **8080**, endpoint `/mcp`. -Поддерживает как GET (SSE-стрим), так и POST (отправка сообщений). +## 📋 Содержание -## Инструменты +- [Возможности](#возможности) +- [Архитектура](#архитектура) +- [Инструменты MCP](#инструменты-mcp) +- [Модель данных](#модель-данных) +- [Быстрый старт](#быстрый-старт) +- [Конфигурация](#конфигурация) +- [Развёртывание](#развёртывание) +- [Безопасность](#безопасность) +- [Разработка](#разработка) +- [Решение проблем](#решение-проблем) -| Инструмент | Описание | -|------------|----------| -| `search_similar_listings` | Векторный поиск по смыслу запроса + фильтры | -| `search_by_metadata` | Полнотекстовый поиск (FTS) + фильтры | -| `get_listing_by_id` | Получить объявление по ID | -| `describe_schema` | Описание схемы БД для AI-агента | +--- -## Язык +## Возможности -База данных содержит украинский текст. **Все запросы должны быть на украинском** — -AI-агент сам переводит запрос пользователя перед вызовом инструмента. +| Функция | Технология | Описание | +|---------|-----------|----------| +| **Семантический поиск** | `pgvector` + HNSW + Ollama | Поиск объявлений «по смыслу» через векторную близость (cosine distance) | +| **Полнотекстовый поиск** | PostgreSQL FTS (украинский конфиг) | Поиск по ключевым словам с ранжированием по релевантности (`ts_rank_cd`) | +| **Фильтрация метаданных** | SQL `WHERE` с параметрами | Цена, район, комнаты, метро, тип сделки, статус и др. | +| **Пагинация** | `LIMIT` / `OFFSET` | Настраиваемый размер страницы (1–100) | +| **Read-only безопасность** | `default_transaction_read_only = on` + валидация SQL | Гарантированная защита от записи/изменения данных | +| **Потоковый HTTP** | MCP Streamable HTTP | Поддержка SSE-стрима + POST на порту 8080 | -## Запуск +--- -### Локально (для разработки) +## Архитектура + +``` +┌─────────────────┐ HTTP (SSE+POST) ┌──────────────────────────────┐ +│ AI Агент │ ───────────────────────> │ VMK Data MCP Server │ +│ (Claude/GPT) │ port 8080 /mcp │ (FastMCP + Starlette) │ +└─────────────────┘ └──────────────────────────────┘ + │ + ┌────────────────────────────┼────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ PostgreSQL │ │ Ollama API │ │ Логирование │ + │ vmk_data │ │ nomic-embed- │ │ (structlog) │ + │ + pgvector │ │ text 768d │ │ │ + └─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### Поток данных при семантическом поиске + +1. Пользовательский запрос (на любом языке) переводится AI-агентом на **украинский**. +2. MCP-сервер отправляет украинский текст в **Ollama** (`/api/embed`). +3. Полученный вектор (768 float) передаётся в PostgreSQL как `vector`. +4. pgvector выполняет поиск по HNSW-индексу: `embedding <=> $1::vector <= $2`. +5. Результаты сериализуются в JSON и возвращаются агенту. + +--- + +## Инструменты MCP + +Сервер регистрирует 4 инструмента, доступных через MCP-протокол: + +### `search_similar_listings` +**Векторный (семантический) поиск** — находит объявления, близкие по смыслу к запросу. + +**Входные параметры:** +- `query` *(string, обязательный)* — текст на **украинском** для эмбеддинга +- `filters` *(object)* — фильтры метаданных (см. [Фильтры](#фильтры)) +- `pagination` *(object)* — `limit` (1–100, по умолч. 20), `offset` (≥ 0) +- `min_similarity` *(float)* — порог косинусной близости (0.0–1.0, по умолч. 0.7) + +**Выход:** [`SearchResult`](#searchresult) — список объявлений с полем `similarity_score`. + +> ⚠️ Параметр `min_similarity` преобразуется в максимальное косинусное расстояние: +> `max_distance = 2.0 × (1.0 − min_similarity)`, потому что оператор `<=>` в pgvector +> возвращает расстояние в диапазоне **[0, 2]**. + +--- + +### `search_by_metadata` +**Полнотекстовый поиск (FTS)** — ищет по ключевым словам в `search_vector`. + +**Входные параметры:** +- `query` *(string, обязательный)* — текстовый запрос на украинском +- `filters` *(object)* — те же фильтры, что и для векторного поиска +- `pagination` *(object)* — пагинация + +**Выход:** [`SearchResult`](#searchresult) — список с полем `rank_score` (релевантность FTS). + +--- + +### `get_listing_by_id` +**Получение объявления по ID** — точечная выборка одной записи. + +**Входные параметры:** +- `listing_id` *(integer, обязательный)* — `id` объявления + +**Выход:** [`ListingResult`](#listingresult) или `{"error": "..."}` если не найдено. + +--- + +### `describe_schema` +**Описание схемы БД** — возвращает структуру таблицы `property_listings` +(колонки, типы, индексы) для подсказок AI-агенту при формировании запросов. + +**Входных параметров нет.** + +**Выход:** JSON-описание схемы. + +--- + +## Модель данных + +### Таблица `property_listings` + +Ключевые колонки, доступные для чтения (белый список `USER_COLUMNS`): + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | `bigint` | Первичный ключ | +| `title` | `text` | Заголовок объявления | +| `description` | `text` | Описание | +| `generated_description` | `text` | AI-сгенерированное описание | +| `price` | `numeric` | Цена | +| `currency` | `varchar(3)` | Валюта: `USD`, `EUR`, `UAH` | +| `deal_type` | `varchar` | Тип сделки: `sale`, `rent_long`, `rent_short` | +| `city` | `varchar` | Город (украинский) | +| `district` | `varchar` | Район (украинский) | +| `rooms_count` | `int` | Количество комнат | +| `total_area` | `float` | Общая площадь, м² | +| `living_area` | `float` | Жилая площадь, м² | +| `kitchen_area` | `float` | Площадь кухни, м² | +| `floor` | `int` | Этаж | +| `floors_count` | `int` | Этажность дома | +| `building_type` | `varchar` | `brick`, `panel`, `monolith`, `gas_block`, `wood` | +| `building_year` | `int` | Год постройки | +| `renovation_status` | `varchar` | Статус ремонта | +| `balcony_count` | `int` | Количество балконов | +| `bathroom_type` | `varchar` | Тип санузла | +| `parking_type` | `varchar` | Тип парковки | +| `heating_type` | `varchar` | Тип отопления | +| `layout_type` | `varchar` | Тип планировки | +| `window_view` | `varchar` | Вид из окон | +| `metro_station` | `varchar` | Станция метро | +| `metro_distance_type` | `varchar` | `walking`, `transport` | +| `metro_distance_meters` | `int` | Расстояние до метро, м | +| `url_source` | `text` | Ссылка на источник | +| `publish_date` | `date` | Дата публикации | +| `images_count` | `int` | Количество фото | +| `contact_phone` | `varchar` | Телефон контакта | +| `listing_status` | `varchar` | `active`, `sold`, `rented`, `removed`, `archived` | +| `archived_at` | `date` | Дата архивации | +| `created_at` / `updated_at` | `date` | Служебные таймстампы | +| `embedding` | `vector(768)` | Вектор эмбеддинга (pgvector) | +| `search_vector` | `tsvector` | Полнотекстовый индекс (украинский конфиг) | + +### Индексы базы данных + +- `property_listings_embedding_idx` — **HNSW** на `embedding` (`vector_cosine_ops`) +- `property_listings_search_vector_idx` — **GIN** на `search_vector` + +--- + +## Быстрый старт + +### 1. Клонирование и установка ```bash -# .env скопирован из .env.example и отредактирован +git clone +cd data_mcp +python -m venv .venv +source .venv/bin/activate # Linux/macOS +# .venv\Scripts\activate # Windows pip install -e "." -python -m vmk_data_mcp.main ``` -### Docker +### 2. Настройка окружения ```bash -docker-compose up --build +cp .env.example .env +# Отредактируйте .env — укажите DATABASE_URL и OLLAMA_BASE_URL ``` -## Зависимости +### 3. Запуск -- Python 3.11+ -- PostgreSQL 15+ с `pgvector` extension -- Ollama (`nomic-embed-text`, 768d) на `192.168.1.75:11434` +```bash +python -m vmk_data_mcp.main +# или +uvicorn vmk_data_mcp.main:app --host 0.0.0.0 --port 8080 +``` + +Сервер стартует на `http://localhost:8080/mcp`. + +--- + +## Конфигурация + +Все параметры задаются через `.env` или переменные окружения: + +| Переменная | По умолчанию | Описание | +|------------|-------------|----------| +| `DATABASE_URL` | `postgresql://postgres:postgres@localhost:5432/vmk_data` | DSN для asyncpg | +| `DB_POOL_MIN_SIZE` | `2` | Минимум соединений в пуле | +| `DB_POOL_MAX_SIZE` | `10` | Максимум соединений в пуле | +| `DB_QUERY_TIMEOUT` | `30` | Таймаут SQL-запросов, сек | +| `OLLAMA_BASE_URL` | `http://192.168.1.75:11434` | URL Ollama API | +| `OLLAMA_EMBED_MODEL` | `nomic-embed-text` | Модель эмбеддинга | +| `OLLAMA_EMBED_DIMENSIONS` | `768` | Размерность вектора | +| `OLLAMA_REQUEST_TIMEOUT` | `60.0` | Таймаут запроса к Ollama, сек | +| `MCP_SERVER_NAME` | `vmk-data-mcp` | Имя сервера в MCP | +| `MCP_PORT` | `8080` | Порт HTTP-транспорта | + +--- + +## Развёртывание + +### Docker Compose (рекомендуется) + +```bash +docker-compose up --build -d +``` + +`docker-compose.yml` использует `host.docker.internal` для доступа к +PostgreSQL и Ollama, запущенным на хост-машине. + +> **Linux:** `host.docker.internal` требует `extra_hosts: ["host.docker.internal:host-gateway"]` +> (уже прописано в `docker-compose.yml`). + +### Требования к инфраструктуре + +- **PostgreSQL 15+** с расширениями: + ```sql + CREATE EXTENSION IF NOT EXISTS vector; + CREATE EXTENSION IF NOT EXISTS pg_trgm; -- опционально + ``` +- **Ollama** с моделью `nomic-embed-text` (768 dimensions): + ```bash + ollama pull nomic-embed-text + ``` +- Для `search_vector` требуется украинская конфигурация FTS (либо `simple`, + если украинский конфиг отсутствует — проверьте `pg_ts_config`). + +--- ## Безопасность -- **Read-only** пул соединений (only SELECT / WITH / VALUES / EXPLAIN) -- Параметризованные запросы (SQL-инъекции невозможны) -- Белый список колонок (`USER_COLUMNS`) -- Таймаут запросов (30 сек) +### Read-only гарантия + +1. **На уровне соединения:** каждое новое соединение получает + `SET default_transaction_read_only = on`. +2. **На уровне строки:** входящий SQL проверяется через `_is_safe_query`: + - только префиксы `select`, `with`, `values`, `explain` + - запрещён символ `;` внутри строки (блокировка multi-statement атак) +3. **На уровне колонок:** только `USER_COLUMNS` участвуют в `SELECT`; +попытка запросить другую колонку вызывает ошибку. + +### SQL-инъекции + +Все пользовательские данные передаются через **параметризованные запросы** (`$1, $2 …`). +Ни один пользовательский параметр не интерполируется в строку SQL. + +--- + +## Разработка + +### Тесты + +```bash +pytest -q +``` + +8 тестов покрывают: +- границы пагинации (`limit` / `offset`) +- валидацию фильтров +- сериализацию моделей + +### Линтинг + +```bash +ruff check src tests +ruff format src tests +``` + +### Структура проекта + +``` +data_mcp/ +├── src/vmk_data_mcp/ +│ ├── __init__.py +│ ├── main.py # FastMCP сервер + HTTP transport +│ ├── tools.py # Реализация 4 инструментов +│ ├── models.py # Pydantic-модели входных/выходных данных +│ ├── db.py # asyncpg пул, read-only защита, USER_COLUMNS +│ ├── embedder.py # Ollama HTTP клиент для эмбеддингов +│ └── config.py # Настройки из .env (pydantic-settings) +├── tests/ +│ └── test_models.py # Pytest +├── Dockerfile +├── docker-compose.yml +├── pyproject.toml +├── .env.example +└── README.md +``` + +--- + +## Решение проблем + +### Ollama недоступна + +``` +Сервис эмбеддингов недоступен: ConnectError(...) +``` +- Проверьте `OLLAMA_BASE_URL` +- Убедитесь, что Ollama слушает на `0.0.0.0` (не только `127.0.0.1`) +- Проверьте firewall / Docker network + +### Нет результатов при семантическом поиске + +- Убедитесь, что запрос переведён на **украинский** перед вызовом инструмента. +- Проверьте порог `min_similarity` — слишком высокий (0.95+) может + исключить все записи. +- Убедитесь, что в БД заполнена колонка `embedding` (не NULL). + +### PostgreSQL: отсутствует `vector` + +```sql +CREATE EXTENSION vector; +``` + +### Неправильная размерность эмбеддинга + +Сервер ожидает `OLLAMA_EMBED_DIMENSIONS=768` (модель `nomic-embed-text`). +Если используется другая модель — обновите переменную окружения. + +--- + +## Лицензия + +MIT diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..9245a27 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,332 @@ +# API Reference — VMK Data MCP Server + +Детальное описание JSON-интерфейса каждого MCP-инструмента. + +--- + +## Соглашения + +- **Все текстовые запросы (`query`)** должны быть на **украинском языке**. + AI-агент выполняет перевод перед вызовом инструмента. +- Все ответы сервера — **UTF-8 JSON**. +- Ошибки возвращаются как объект `{"error": "<сообщение>"}`. + +--- + +## Общие типы + +### `PaginationParams` + +```json +{ + "limit": 20, + "offset": 0 +} +``` + +| Поле | Тип | По умолч. | Ограничения | +|------|-----|-----------|-------------| +| `limit` | `integer` | `20` | `1 ≤ limit ≤ 100` | +| `offset` | `integer` | `0` | `offset ≥ 0` | + +### `MetadataFilters` + +```json +{ + "deal_type": "sale", + "city": "Київ", + "district": "Печерський", + "rooms_count": 2, + "min_price": 50000, + "max_price": 150000, + "currency": "USD", + "min_total_area": 50, + "max_total_area": 100, + "building_type": "monolith", + "floor": 5, + "listing_status": "active", + "metro_station": "Арсенальна" +} +``` + +Все поля — **опциональные**. Указанные фильтры объединяются через `AND`. + +| Поле | Тип | Допустимые значения | +|------|-----|---------------------| +| `deal_type` | `string` | `"sale"`, `"rent_long"`, `"rent_short"` | +| `city` | `string` | Любой (украинский) | +| `district` | `string` | Любой (украинский) | +| `rooms_count` | `integer` | `≥ 0` | +| `min_price` | `number` | `≥ 0` | +| `max_price` | `number` | `≥ 0` | +| `currency` | `string` | `"USD"`, `"EUR"`, `"UAH"` | +| `min_total_area` | `number` | `≥ 0` (м²) | +| `max_total_area` | `number` | `≥ 0` (м²) | +| `building_type` | `string` | `"brick"`, `"panel"`, `"monolith"`, `"gas_block"`, `"wood"` | +| `floor` | `integer` | `≥ 0` | +| `listing_status` | `string` | `"active"`, `"sold"`, `"rented"`, `"removed"`, `"archived"` | +| `metro_station` | `string` | Любой (украинский) | + +### `ListingResult` + +```json +{ + "id": 12345, + "title": "2-кімнатна квартира в центрі Києва", + "description": "...", + "generated_description": "...", + "price": 125000, + "currency": "USD", + "deal_type": "sale", + "city": "Київ", + "district": "Печерський", + "rooms_count": 2, + "total_area": 78.5, + "living_area": 45.0, + "kitchen_area": 12.0, + "floor": 5, + "floors_count": 12, + "building_type": "monolith", + "building_year": 2015, + "renovation_status": "euro_repair", + "balcony_count": 1, + "bathroom_type": "combined", + "parking_type": "underground", + "heating_type": "central", + "layout_type": "standard", + "window_view": "courtyard", + "metro_station": "Арсенальна", + "metro_distance_type": "walking", + "metro_distance_meters": 450, + "url_source": "https://...", + "publish_date": "2024-03-15", + "images_count": 18, + "contact_phone": "+380...", + "listing_status": "active", + "archived_at": null, + "created_at": "2024-03-15", + "updated_at": "2024-03-20", + "similarity_score": 0.842, + "rank_score": 0.123 +} +``` + +| Поле | Тип | Примечание | +|------|-----|------------| +| `similarity_score` | `number \| null` | Только для `search_similar_listings` (cosine similarity ≈ 1 − distance/2) | +| `rank_score` | `number \| null` | Только для `search_by_metadata` (ts_rank_cd) | + +### `SearchResult` + +```json +{ + "total": 137, + "limit": 20, + "offset": 0, + "listings": [ /* массив ListingResult */ ] +} +``` + +| Поле | Тип | Описание | +|------|-----|----------| +| `total` | `integer` | Общее количество записей, соответствующих фильтру (без `LIMIT`) | +| `limit` | `integer` | Фактический `limit` | +| `offset` | `integer` | Фактический `offset` | +| `listings` | `ListingResult[]` | Список объявлений | + +--- + +## Инструменты + +### `search_similar_listings` + +Векторный поиск по смыслу запроса. Использует `pgvector` + HNSW индекс. + +**MCP-запрос (пример):** + +```json +{ + "name": "search_similar_listings", + "arguments": { + "query": "сучасна 2-кімнатна квартира біля метро з ремонтом", + "filters": { + "city": "Київ", + "deal_type": "sale", + "min_price": 100000, + "max_price": 200000, + "currency": "USD" + }, + "pagination": { + "limit": 10, + "offset": 0 + }, + "min_similarity": 0.75 + } +} +``` + +**Логика:** +1. Запрос отправляется в Ollama → получаем `embedding vector(768)`. +2. SQL: + ```sql + SELECT , + 1 - (embedding <=> $1::vector) / 2.0 AS similarity_score + FROM property_listings + WHERE city = $2 AND deal_type = $3 AND price BETWEEN $4 AND $5 + AND embedding <=> $1::vector <= $6 + ORDER BY embedding <=> $1::vector + LIMIT $7 OFFSET $8; + ``` +3. Дополнительный `COUNT(*)`-запрос для поля `total`. + +**Ошибки:** +- `Сервис эмбеддингов недоступен` — Ollama недоступна +- `Ошибка при поиске` — проблема с БД или валидацией +- `Неожиданная ошибка` — внутренняя ошибка сервера + +--- + +### `search_by_metadata` + +Полнотекстовый поиск через `search_vector` (украинский FTS). + +**MCP-запрос (пример):** + +```json +{ + "name": "search_by_metadata", + "arguments": { + "query": "квартира Печерський район метро", + "filters": { + "listing_status": "active", + "rooms_count": 3 + }, + "pagination": { + "limit": 20, + "offset": 0 + } + } +} +``` + +**Логика:** +1. Запрос обрабатывается `plainto_tsquery('ukrainian', $1)`. +2. SQL: + ```sql + SELECT , + ts_rank_cd(search_vector, query) AS rank_score + FROM property_listings, + plainto_tsquery('ukrainian', $1) query + WHERE search_vector @@ query + AND listing_status = $2 AND rooms_count = $3 + ORDER BY rank_score DESC + LIMIT $4 OFFSET $5; + ``` +3. Дополнительный `COUNT(*)`. + +**Ошибки:** те же, что и для `search_similar_listings`, но без ошибок Ollama. + +--- + +### `get_listing_by_id` + +Получение одного объявления по `id`. + +**MCP-запрос (пример):** + +```json +{ + "name": "get_listing_by_id", + "arguments": { + "listing_id": 12345 + } +} +``` + +**Логика:** +```sql +SELECT +FROM property_listings +WHERE id = $1; +``` + +**Ошибки:** +- `{"error": "Объявление не найдено"}` — если `id` отсутствует + +--- + +### `describe_schema` + +Описание схемы таблицы для подсказок AI-агенту. + +**MCP-запрос (пример):** + +```json +{ + "name": "describe_schema", + "arguments": {} +} +``` + +**Возвращает** JSON-объект со списком колонок, их типами, индексами и +ограничениями, которые агент может использовать для формирования запросов. + +--- + +## Коды ошибок HTTP транспорта + +При работе через Streamable HTTP: + +| Статус | Причина | +|--------|---------| +| `200` | Успешный SSE-стрим | +| `202` | Успешный POST (accept message) | +| `400` | Невалидный JSON или параметры | +| `404` | Endpoint не найден | +| `405` | Неподдерживаемый HTTP-метод | +| `500` | Внутренняя ошибка сервера | + +--- + +## Примеры сценариев + +### Сценарий 1: "Найди 3-комнатную квартиру в Киеве для покупки" + +1. Агент переводит на украинский: `"3-кімнатна квартира Київ продаж"`. +2. Агент выбирает **векторный поиск** для семантической близости. +3. MCP-вызов: + ```json + { + "name": "search_similar_listings", + "arguments": { + "query": "3-кімнатна квартира Київ продаж", + "filters": { "city": "Київ", "deal_type": "sale", "rooms_count": 3 }, + "pagination": { "limit": 10, "offset": 0 }, + "min_similarity": 0.7 + } + } + ``` + +### Сценарий 2: "Есть ли квартиры у метро Арсенальная?" + +1. Перевод: `"квартира біля метро Арсенальна"`. +2. **FTS-поиск** для точного ключевого слова "Арсенальна": + ```json + { + "name": "search_by_metadata", + "arguments": { + "query": "квартира біля метро Арсенальна", + "filters": { "metro_station": "Арсенальна", "listing_status": "active" }, + "pagination": { "limit": 20, "offset": 0 } + } + } + ``` + +### Сценарий 3: "Покажи объявление № 12345" + +```json +{ + "name": "get_listing_by_id", + "arguments": { "listing_id": 12345 } +} +``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e0fd980 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,245 @@ +# Архитектура VMK Data MCP Server + +--- + +## Обзор системы + +Сервер реализован как **MCP (Model Context Protocol) Server** на базе +`FastMCP` (Python SDK v1.x) с транспортом **Streamable HTTP**. +Он выступает безопасным посредником между AI-агентом и PostgreSQL-базой +недвижимости, предоставляя агенту инструменты поиска без прямого доступа к БД. + +--- + +## Компоненты + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ AI Агент (Claude / GPT) │ +│ MCP Client │ +└───────────────────────────────┬─────────────────────────────────────────────┘ + │ HTTP SSE + POST + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ VMK Data MCP Server │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ main.py │ │ tools.py │ │ models.py │ │ embedder.py │ │ +│ │ FastMCP app │ │ 4 tools │ │ Pydantic │ │ httpx → Ollama │ │ +│ │ HTTP transport│ │ SQL builders│ │ validation │ │ /api/embed │ │ +│ └──────┬──────┘ └──────┬──────┘ └─────────────┘ └─────────────────────┘ │ +│ │ │ │ +│ └────────────────┼────────────────────────────────────────────────────┘ +│ │ +│ ┌───────────────────────┴───────────────────────┐ +│ │ db.py │ +│ │ asyncpg pool + read-only safety │ +│ │ USER_COLUMNS whitelist │ +│ └───────────────────────┬───────────────────────┘ +│ │ asyncpg +└──────────────────────────┼───────────────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ PostgreSQL (vmk_data) │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────────┐ │ +│ │ property_ │ │ embedding │ │ search_vector │ │ +│ │ listings │ │ vector(768) │ │ tsvector (ukrainian) │ │ +│ │ │ │ HNSW index │ │ GIN index │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 1. `main.py` — Entrypoint и FastMCP + +- Создаёт экземпляр `FastMCP(name=settings.mcp_server_name)`. +- Регистрирует 4 инструмента через декоратор `@mcp.tool()`. +- Определяет `lifespan` — асинхронный контекстный менеджер для инициализации + пула БД и закрытия ресурсов. +- Запускает `mcp.run(transport="streamable-http", port=settings.mcp_port)`. + +**Streamable HTTP transport:** +- `GET /mcp` — SSE-стрим для сервер→клиент сообщений. +- `POST /mcp` — приём сообщений клиент→сервер. + +### 2. `tools.py` — SQL-конструкторы и бизнес-логика + +Каждый инструмент — async-функция, которая: +1. Принимает Pydantic-модель (`SearchSimilarInput`, `SearchMetadataInput` …). +2. Строит SQL через `_build_where_clause()` (параметризованный). +3. Для векторного поиска — вызывает `embedder.get_embedding(query)`. +4. Выполняет 2 запроса: `COUNT(*)` (для `total`) и `SELECT … LIMIT/OFFSET`. +5. Сериализует результат в `SearchResult` → JSON. + +**Ключевая функция `_build_where_clause(filters)`: ** +- Проверяет, что значение фильтра не `None`. +- Использует **strictly allowed columns** — только известные имена колонок. +- Генерирует SQL-условия с нумерованными параметрами `$N`. + +### 3. `models.py` — Pydantic v2 + +- **Входные модели**: валидация типов, границ, enum-значений. +- **Выходные модели**: `ListingResult`, `SearchResult` — сериализация `asyncpg.Record`. +- Все модели наследуют `BaseModel` с чёткими `Field(description=…)`. + +### 4. `db.py` — Безопасный доступ к БД + +**Пул asyncpg:** +- `create_pool(..., init=_init_conn)` — каждое новое соединение получает + `SET default_transaction_read_only = on`. +- Мин/макс размер пула и таймаут запросов из `.env`. + +**Read-only защита `_is_safe_query(sql)`:** +- Разрешённые префиксы: `select`, `with`, `values`, `explain`. +- **Запрет `;` внутри строки** — блокировка multi-statement инъекций (`DROP TABLE;`). + +**`USER_COLUMNS`:** +- Белый список колонок `property_listings`. +- `build_select_columns()` — динамически строит `SELECT col1, col2 …`. + +### 5. `embedder.py` — Ollama клиент + +- Singleton `httpx.AsyncClient` с `base_url` и таймаутом. +- `get_embedding(text)` → `list[float]` длины `OLLAMA_EMBED_DIMENSIONS`. +- Валидация размерности ответа. + +### 6. `config.py` — Настройки + +`pydantic-settings` загружает `.env` с `extra="ignore"`. +Все секреты и URL вынесены в переменные окружения. + +--- + +## Потоки данных + +### Векторный поиск (`search_similar_listings`) + +``` +┌─────────┐ украинский текст ┌───────────┐ +│ Agent │ ───────────────────> │ embedder │ +└─────────┘ └─────┬─────┘ + │ HTTP POST /api/embed + ▼ + ┌──────────────┐ + │ Ollama │ + │ 768d vector │ + └──────┬───────┘ + │ + ▼ +┌─────────┐ SQL + $1..$N ┌──────────┐ vector(768), max_distance +│ Agent │ <─────────────────── │ db.py │ <──────────────────────────────┐ +└─────────┘ JSON result └────┬─────┘ │ + │ asyncpg │ + ▼ │ + ┌──────────────┐ │ + │ PostgreSQL │ │ + │ HNSW scan │ │ + │ embedding │ │ + │ <=> vector │ │ + └──────────────┘ │ + │ + COSINE DISTANCE [0, 2] │ + max_distance = 2*(1 - similarity) │ +``` + +### Полнотекстовый поиск (`search_by_metadata`) + +``` +┌─────────┐ украинский текст ┌──────────┐ +│ Agent │ ───────────────────> │ tools │ +└─────────┘ └────┬─────┘ + │ + ▼ + ┌──────────────┐ + │ PostgreSQL │ + │ plainto_ts- │ + │ query('uk..')│ + │ @@ search_ │ + │ vector │ + └──────────────┘ +``` + +--- + +## Индексы базы данных + +| Индекс | Тип | Колонка | Оператор / Конфиг | +|--------|-----|---------|-------------------| +| `property_listings_embedding_idx` | **HNSW** | `embedding vector(768)` | `vector_cosine_ops` | +| `property_listings_search_vector_idx` | **GIN** | `search_vector` | `tsvector` (ukrainian) | + +### Почему HNSW, а не IVFFlat? + +- HNSW обеспечивает **лучшую точность** при высокой селективности фильтров. +- Не требует перестроения при добавлении данных (`ef_construction`, `m` настраиваются + при создании). +- Рекомендуется для production-нагрузки с pgvector ≥ 0.5.0. + +### Почему `vector_cosine_ops`? + +- Модель `nomic-embed-text` генерирует **нормализованные** векторы. +- Cosine distance (`<=>`) эквивалентен Euclidean distance для нормализованных векторов, + но явно выражает семантическую близость. + +--- + +## Безопасность (Defense in Depth) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Уровень 1: Сеть │ +│ — MCP Server доступен только внутри сети / через reverse │ +│ proxy с аутентификацией (не входит в скоуп проекта) │ +├─────────────────────────────────────────────────────────────┤ +│ Уровень 2: PostgreSQL роли │ +│ — Отдельный READ-ONLY пользователь БД (рекомендуется) │ +├─────────────────────────────────────────────────────────────┤ +│ Уровень 3: default_transaction_read_only = on │ +│ — Каждая транзакция принудительно read-only │ +├─────────────────────────────────────────────────────────────┤ +│ Уровень 4: Валидация SQL (_is_safe_query) │ +│ — Только SELECT / WITH / VALUES / EXPLAIN │ +│ — Блокировка `;` (multi-statement) │ +├─────────────────────────────────────────────────────────────┤ +│ Уровень 5: Параметризованные запросы │ +│ — Никакого f-string / format в SQL │ +├─────────────────────────────────────────────────────────────┤ +│ Уровень 6: USER_COLUMNS whitelist │ +│ — SELECT только разрешённые колонки │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Масштабирование и производительность + +| Параметр | Значение | Почему | +|----------|----------|--------| +| `DB_POOL_MAX_SIZE` | `10` | Оптимально для ~50–100 параллельных MCP-запросов | +| `DB_QUERY_TIMEOUT` | `30` | Защита от тяжёлых запросов без `LIMIT` | +| `LIMIT` | `1–100` | Жёсткий потолок на размер ответа | +| `Ollama timeout` | `60` | Эмбеддинг одного запроса ~300–800 мс | + +### Потенциальные bottleneck'и + +1. **Ollama** — если запросов много, стоит поднять Ollama на GPU или + добавить кеширование эмбеддингов (Redis / in-memory LRU для популярных запросов). +2. **PostgreSQL CPU** — `COUNT(*)` с фильтрами на больших таблицах может + требовать `pg_stat_statements` анализа и частичных индексов. +3. **HNSW `ef_search`** — при недостаточной точности можно увеличить + `SET hnsw.ef_search = 100` (по умолчанию обычно 40). + +--- + +## Логирование + +Сервер использует стандартный `logging` из stdlib. + +| Уровень | Что логируется | +|---------|----------------| +| `WARNING` | Ошибки Ollama, БД, валидации (кратко) | +| `ERROR` / `EXCEPTION` | Неожиданные ошибки в инструментах (с трейсбеком) | +| `INFO` | Старт/остановка сервера, lifespan события | + +Пример: +``` +WARNING vmk_data_mcp.tools: Ollama error: ConnectError(...) +```