Проєкт: vmk-data-mcp — MCP-сервер для vector + metadata пошуку по PostgreSQL-базі vmk_data (оголошення про нерухомість). Сервер надає інструменти AI-агенту (Claude) для семантичного та структурованого пошуку оголошень.
Мова: база даних містить переважно український текст. Всі текстові запити до search_similar_listings та search_by_metadata повинні бути на українській мові. AI-агент самостійно перекладає запити користувача перед викликом інструментів.
FastMCP) — стабільна версіяstreamable-http (рекомендований у 2025 замість SSE)data_collector)asyncpg (async) + pgvector.asyncpg для типівpydantic-settings) з .envnomic-embed-text (768d) через Ollama API (ollama SDK або httpx)SET default_transaction_read_only = on)MAX_LIMIT = 50, QUERY_TIMEOUT = 10squery_text, сервер сам генерує embedding через внутрішній embeddervmk-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.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.py)asyncpg.create_pool(...) з min_size/max_size з конфігаregister_vector(conn) при ініціалізації з'єднанняpgvector типи: Vector(768)SET default_transaction_read_only = on при коннектіasyncio.wait_for або command_timeout в asyncpgsearch_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_idmin_price, max_pricemin_rooms, max_roomslisting_status (default=active)min_area, max_areahas_balcony, building_type, renovation_statusdistance_metric: enum — cosine (default), l2, inner_productЛогіка:
embedding[768] через Embedder.embed([query_text])WHERE з безпечного whitelist (parametrized)SELECT id, title, description, price, city, district, rooms_count,
embedding <=> $1 AS distance
FROM property_listings
WHERE ...
ORDER BY distance
LIMIT $n OFFSET $msimilarity_score (1 - distance для cosine)Сортування: за distance ASC (чим менше, тим релевантніше). Для cosine: similarity_score = 1 - distance.
search_by_metadataТекстовий/структурований пошук без векторів, з сортуванням та пагінацією. Використовує готову FTS-колонку search_vector з data_collector.
Вхідні параметри:
query: str | None — пошук по search_vector (FTS) або title/description/address_raw (ILIKE fallback)filters: ті ж, що в search_similar_listingssort_by: enum — relevance (default), price_asc, price_desc, date_desc, area_desclimit: int (default=10, max=50)offset: int (default=0)Логіка:
query задано:
WHERE search_vector @@ plainto_tsquery('ukrainian', $1) (використовує GIN-індекс ix_property_listings_search_vector_gin)WHERE (title ILIKE $1 OR description ILIKE $1 OR address_raw ILIKE $1)filters (whitelist)relevance: ts_rank_cd(search_vector, plainto_tsquery('ukrainian', $1)) DESC (використовує готову колонку)price_asc/price_desc: price ASC/DESCdate_desc: publish_date DESCarea_desc: total_area DESCLIMIT $n OFFSET $mtotal_count: окремий SELECT COUNT(*) з тими ж WHERE-умовамиВихід: список оголошень + total_count (для UI пагінації)
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).
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 млн грн",
"Новобудова з балконом біля метро",
"Будинок з ділянкою в передмісті"
]
}
# 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
pyproject.toml з залежностями: mcp[cli], asyncpg, pgvector, pydantic-settings, ollamaconfig.py — Pydantic Settings (DB + Ollama + server)db.py — asyncpg pool + register_vector + read-only guardembedder.py — OllamaEmbedder з валідацією 768d на стартіmain.py — FastMCP сервер з streamable-httpdescribe_schema — статичний опис схеми (хардкод з data_collector моделей)get_listing_by_id — SELECT + JOIN property_types, тільки користувацькі поляsearch_by_metadata — FTS через search_vector (ukrainian) + фільтри + sort_by + pagination + total_countsearch_similar_listings — query_text → embed → vector <=> + фільтри + paginationDockerfile (multi-stage, uv/uvicorn)docker-compose.yml (тільки MCP-сервер, Ollama окремо)README.md — підключення до Claude Desktop / Claude Code.env.exampleІнструмент hybrid_search (не в MVP):
search_similar_listings (vector) та search_by_metadata (text)Відкладено до наступних ітерацій:
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-агент перекладає запити |