Newer
Older
vmk-360_data_mcp / plan.md

MCP-сервер для пошуку по базі даних нерухомості

Контекст

Проєкт: vmk-data-mcp — MCP-сервер для vector + metadata пошуку по PostgreSQL-базі vmk_data (оголошення про нерухомість). Сервер надає інструменти AI-агенту (Claude) для семантичного та структурованого пошуку оголошень.

Мова: база даних містить переважно український текст. Всі текстові запити до search_similar_listings та search_by_metadata повинні бути на українській мові. AI-агент самостійно перекладає запити користувача перед викликом інструментів.

Архітектурні рішення

Стек

  • Python 3.11+
  • MCP Python SDK v1.x (FastMCP) — стабільна версія
  • Transport: streamable-http (рекомендований у 2025 замість SSE)
  • БД: PostgreSQL + pgvector (вже розгорнуті в data_collector)
  • Драйвер: asyncpg (async) + pgvector.asyncpg для типів
  • Конфіг: Pydantic Settings (pydantic-settings) з .env
  • Embeddings: nomic-embed-text (768d) через Ollama API (ollama SDK або httpx)

Принципи безпеки

  • Тільки SELECT: сервер відкриває read-only пул з'єднань (SET default_transaction_read_only = on)
  • Параметризовані запити: захист від SQL-ін'єкцій
  • Обмеження: MAX_LIMIT = 50, QUERY_TIMEOUT = 10s
  • Жодного DDL/DML: запити будуються через whitelist колонок
  • Embeddings isolation: клієнт передає query_text, сервер сам генерує embedding через внутрішній embedder

Структура проєкту

vmk-data-mcp/
├── src/
│   └── vmk_data_mcp/
│       ├── __init__.py
│       ├── main.py                 # точка входу, запуск FastMCP (streamable-http)
│       ├── config.py               # Pydantic Settings (DB, Ollama, server)
│       ├── db.py                   # asyncpg pool + register_vector + read-only guard
│       ├── embedder.py             # абстракція Embedder (Ollama nomic-embed-text)
│       ├── tools/
│       │   ├── __init__.py
│       │   ├── search.py           # векторний пошук: query_text → embedding → pgvector
│       │   ├── listings.py         # отримання оголошення за ID
│       │   ├── metadata_search.py  # текстовий/структурований пошук (FTS + фільтри)
│       │   └── schema_info.py      # статичний опис схеми, enums, фільтри, приклади
│       └── resources/              # відкладено до наступних ітерацій
│           └── __init__.py
├── pyproject.toml
├── .env.example
├── Dockerfile
└── README.md

Компоненти

Embedder (embedder.py)

class Embedder(Protocol):
    async def embed(self, texts: list[str]) -> list[list[float]]: ...

class OllamaEmbedder:
    """Ollama API для nomic-embed-text (768d)."""
    # Конфіг: OLLAMA_HOST, OLLAMA_MODEL=nomic-embed-text
    # Використовує ollama.embed() або httpx POST /api/embed
    # Кешування розмірності на старті (validate 768d)

DB (db.py)

  • asyncpg.create_pool(...) з min_size/max_size з конфіга
  • register_vector(conn) при ініціалізації з'єднання
  • pgvector типи: Vector(768)
  • Read-only guard: SET default_transaction_read_only = on при коннекті
  • Query timeout: asyncio.wait_for або command_timeout в asyncpg

Інструменти (Tools)

1. search_similar_listings

Семантичний векторний пошук. Клієнт передає текст українською, сервер сам генерує embedding і шукає найближчих сусідів.

Вхідні параметри:

  • query_text: str — текстовий запит українською (наприклад: "2-кімнатна квартира біля метро, новобудова")
  • limit: int (default=10, max=50)
  • offset: int (default=0)
  • filters: dict — опціональні фільтри (whitelist полів):
    • city, district, deal_type, property_type_id
    • min_price, max_price
    • min_rooms, max_rooms
    • listing_status (default=active)
    • min_area, max_area
    • has_balcony, building_type, renovation_status
  • distance_metric: enumcosine (default), l2, inner_product

Логіка:

  1. Генерувати embedding[768] через Embedder.embed([query_text])
  2. Побудувати WHERE з безпечного whitelist (parametrized)
  3. Виконати:
    SELECT id, title, description, price, city, district, rooms_count,
           embedding <=> $1 AS distance
    FROM property_listings
    WHERE ...
    ORDER BY distance
    LIMIT $n OFFSET $m
  4. Повернути JSON: список оголошень + similarity_score (1 - distance для cosine)

Сортування: за distance ASC (чим менше, тим релевантніше). Для cosine: similarity_score = 1 - distance.


2. search_by_metadata

Текстовий/структурований пошук без векторів, з сортуванням та пагінацією. Використовує готову FTS-колонку search_vector з data_collector.

Вхідні параметри:

  • query: str | None — пошук по search_vector (FTS) або title/description/address_raw (ILIKE fallback)
  • filters: ті ж, що в search_similar_listings
  • sort_by: enumrelevance (default), price_asc, price_desc, date_desc, area_desc
  • limit: int (default=10, max=50)
  • offset: int (default=0)

Логіка:

  1. Якщо query задано:
    • Primary: WHERE search_vector @@ plainto_tsquery('ukrainian', $1) (використовує GIN-індекс ix_property_listings_search_vector_gin)
    • Fallback: якщо FTS не дав результатів — WHERE (title ILIKE $1 OR description ILIKE $1 OR address_raw ILIKE $1)
  2. Застосувати filters (whitelist)
  3. Сортування:
    • relevance: ts_rank_cd(search_vector, plainto_tsquery('ukrainian', $1)) DESC (використовує готову колонку)
    • price_asc/price_desc: price ASC/DESC
    • date_desc: publish_date DESC
    • area_desc: total_area DESC
  4. LIMIT $n OFFSET $m
  5. total_count: окремий SELECT COUNT(*) з тими ж WHERE-умовами

Вихід: список оголошень + total_count (для UI пагінації)


3. get_listing_by_id

Повна картка оголошення.

Вхідні параметри:

  • listing_id: int

Вихід: всі "пользовательські" поля property_listings + property_types.name (JOIN). Поля включають: title, description, generated_description, price, currency, city, district, rooms_count, total_area, floor, building_type, metro_station, url_source, publish_date, images_count, contact_phone, contact_name, та інші. Без службових (raw_data_id, embedding, created_at, updated_at).


4. describe_schema

Статичний опис схеми. Дані захардкожені з data_collector моделей.

Вхідні параметри: відсутні

Вихід:

{
  "tables": [
    {
      "name": "property_listings",
      "description": "Оголошення про нерухомість",
      "columns": ["id", "title", "description", "price", "city", "rooms_count", ...],
      "indexes": [
        "ix_property_listings_embedding_hnsw (hnsw vector_cosine_ops)",
        "ix_property_listings_search_vector_gin (gin)",
        "ix_property_listings_city (btree)",
        "ix_property_listings_price (btree)"
      ]
    },
    {
      "name": "property_types",
      "description": "Типи нерухомості",
      "columns": ["id", "slug", "name", "description"]
    }
  ],
  "enums": {
    "deal_type": ["sale", "rent_long", "rent_short"],
    "listing_status": ["active", "sold", "rented", "removed", "archived"],
    "building_type": ["brick", "panel", "monolith", "gas_block", "wood"],
    "renovation_status": ["cosmetic", "euro", "designer", "none", "construction"],
    "bathroom_type": ["combined", "separate", "multiple"],
    "parking_type": ["ground", "underground", "none", "garage"],
    "heating_type": ["central", "autonomous", "floor", "none"],
    "layout_type": ["studio", "separate", "adjacent"],
    "window_view": ["yard", "street", "park", "water", "forest"],
    "metro_distance_type": ["walk", "transport"]
  },
  "available_filters": [
    "city", "district", "deal_type", "property_type_id",
    "min_price", "max_price", "min_rooms", "max_rooms",
    "listing_status", "min_area", "max_area",
    "has_balcony", "building_type", "renovation_status"
  ],
  "embedding_model": "nomic-embed-text (768d)",
  "language": "ukrainian",
  "sample_queries": [
    "2-кімнатна квартира в центрі до 2 млн грн",
    "Новобудова з балконом біля метро",
    "Будинок з ділянкою в передмісті"
  ]
}

Конфігурація (.env)

# Database (from data_collector docker-compose)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=vmk_data
DB_USER=postgres
DB_PASSWORD=postgres
DB_POOL_MIN=1
DB_POOL_MAX=10

# Ollama (embeddings) — already deployed on 192.168.1.75
OLLAMA_HOST=http://192.168.1.75:11434
OLLAMA_MODEL=nomic-embed-text
OLLAMA_TIMEOUT_SEC=30

# Server
MCP_SERVER_NAME=vmk-data-mcp
MCP_HOST=0.0.0.0
MCP_PORT=3000
MCP_TRANSPORT=streamable-http
MCP_STATELESS_HTTP=true

# Safety
MAX_LIMIT=50
QUERY_TIMEOUT_SEC=10

План реалізації

Етап 1: Каркас

  • pyproject.toml з залежностями: mcp[cli], asyncpg, pgvector, pydantic-settings, ollama
  • config.py — Pydantic Settings (DB + Ollama + server)
  • db.py — asyncpg pool + register_vector + read-only guard
  • embedder.pyOllamaEmbedder з валідацією 768d на старті
  • main.py — FastMCP сервер з streamable-http

Етап 2: Інструменти

  • describe_schema — статичний опис схеми (хардкод з data_collector моделей)
  • get_listing_by_id — SELECT + JOIN property_types, тільки користувацькі поля
  • search_by_metadata — FTS через search_vector (ukrainian) + фільтри + sort_by + pagination + total_count
  • search_similar_listings — query_text → embed → vector <=> + фільтри + pagination

Етап 3: Доробки

  • Обробка помилок: DB timeout, Ollama недоступний, embedding ≠ 768d
  • Логування: запити, latency, embedding time
  • Graceful shutdown: закриття DB pool

Етап 4: Інфраструктура

  • Dockerfile (multi-stage, uv/uvicorn)
  • docker-compose.yml (тільки MCP-сервер, Ollama окремо)
  • README.md — підключення до Claude Desktop / Claude Code
  • .env.example

Гібридний пошук (майбутнє)

Інструмент hybrid_search (не в MVP):

  1. Паралельно виконувати search_similar_listings (vector) та search_by_metadata (text)
  2. Об'єднати результати через Reciprocal Rank Fusion (RRF)
  3. Пересортувати та повернути top-N

Resources (майбутнє)

Відкладено до наступних ітерацій:

  • listing://{id} — JSON з повним оголошенням
  • schema://property_listings — опис схеми таблиці
  • stats://database — агрегати (total_listings, price_range, top_cities)

Рішення, що прийняті

Питання Рішення
Async vs Sync драйвер asyncpg (async)
FTS індекс Вже є в data_collector: колонка search_vector + GIN (ukrainian конфіг)
describe_schema Статика (хардкод з моделей data_collector)
Resources Відкладено (не в MVP)
get_listing_by_id Всі користувацькі поля, без службових (raw_data_id, embedding, created_at)
Деплой Docker-контейнер, Ollama вже є на 192.168.1.75
Мова Українська. AI-агент перекладає запити