Сервер MCP с нулевой настройкой для фильтрованного векторного поиска в Neo4j
Автоматически обнаруживаются векторные индексы Neo4j, приводятся примеры типов свойств, и генерируются инструменты поиска типа MCP, использующие фильтрацию в режиме in-index с Cypher 25, без необходимости вручную настраивать что-либо.
Источники: Automagic Metadata Filtering MCP in Neo4j
Представьте себе граф знаний, заполненный новостными статьями, и агента, который получил задание: показать положительные материалы о возобновляемой энергетике от Reuters за прошлую неделю. В этом предложении содержатся три составляющих: семантический поиск по запросу «возобновляемая энергетика», фильтрация по метаданным источника (Reuters), тональности (положительная) и даты (прошлая неделя).
Обычный векторный поиск обрабатывает семантическую часть. Однако без фильтрации он с удовольствием вернет негативные статьи 2019 года с различных блогов, и большая языковая модель не сможет понять их бесполезность, пока не прочитает все они. Это и есть проблема векторного поиска с использованием фильтрации метаданных, и Neo4j уже давно предлагает два способа её решения.
- Сначала провести предварительную фильтрацию с помощью Cypher, чтобы уменьшить количество кандидатов, а затем выполнить векторный поиск среди оставшихся. Результаты будут точными, но процесс может замедлиться, если количество кандидатов всё ещё велико.
У обоих подходов были сильные стороны, и у обоих оставался простор для улучшений.
Что изменилось в Neo4j 2026.02
В Neo4j 2026.02 (см. примечания к выпуску) векторный поиск с фильтрами стал общедоступным в рамках Cypher 25. Теперь предикаты можно применять непосредственно внутри векторного индекса во время запроса, благодаря чему алгоритм HNSW пропускает несоответствующие векторы до проведения ранжирования. Задержка остается низкой, точность — высокой, и не требуется загружать лишнюю информацию. Также появилась новая встроенная конструкция SEARCH, которая заменяет старую процедуру db.index.vector.queryNodes() и делает векторный поиск полноценной частью языка запросов.
Это поднимает вопрос: может ли сервер MCP самостоятельно обнаруживать векторные индексы, изучать их метаданные и генерировать инструменты фильтрации без ручной настройки?
Универсальный сервер векторного поиска без настройки
Каждый, кто создавал инструменты MCP для векторного поиска, знает стандартную процедуру: пишется код на языке Cypher, определяются параметры, настраивается модель вкладкирования, описываются фильтры. Для одного известного индекса это работает нормально. Однако более привлекательным является сервер, который может использоваться с любой базой данных Neo4j — достаточно просто указать на её экземпляр, и сервер сам всё остальное решит.
Именно это и делает данный сервер. При запуске он подключается к Neo4j, находит все векторные индексы, берет примеры узлов из каждого для определения типов свойств и создает по одному полностью типизированному инструменту MCP на каждый индекс, включая параметры фильтрации и документацию, которую может прочитать большая языковая модель.
Одну вещь невозможно обнаружить автоматически: модель эмбеддингов. Neo4j пока не хранит её в метаданных индекса, и для вызова модели всё равно потребуется API-ключ. Поэтому поставщик эмбеддингов и его учетные данные — единственная конфигурация, которую необходимо указать.
Шаг 1: поиск векторных индексов
Сначала сервер запрашивает у Neo4j все векторные индексы:
records, _, _ = driver.execute_query(
"""
SHOW VECTOR INDEXES
""",
database_=NEO4J_DATABASE,
)
Результат содержит название каждого индекса, метку узла, которую он охватывает, а также индексируемые свойства. Если векторных индексов нет, сервер завершает работу с ошибкой, поскольку нет ничего для предоставления.
Шаг 2: типы примеров и примерные значения
Для каждого индекса серверу необходимо знать, какое свойство содержит эмбеддинг и какой тип имеют все остальные свойства. Содержит ли published_date простую дату или полное время метки? Является ли source текстом? Является ли оценка настроения десятичным числом? Специальный вспомогательный элемент _sample_property отвечает на эти вопросы, анализируя по одному узлу для каждого свойства:
def _sample_property(driver, label: str, prop: str) -> tuple[str, Any]:
"""Return (type, sample_value) for a single property."""
cypher = (
"MATCH (n:$($label)) WHERE n[$prop] IS NOT NULL "
"WITH n LIMIT 1 RETURN n[$prop] AS val"
)
result, _, _ = driver.execute_query(
cypher, label=label, prop=prop, database_=NEO4J_DATABASE
)
if not result:
return "string", None
val = result[0]["val"]
return _detect_type(val), val
Он возвращает обнаруженный тип и фактическое значение примера. Основная функция собирает их в два словаря: один для типов, а другой — для примеров:
def _sample_property_types(
driver, label: str, properties: list[str]
) -> tuple[str | None, dict[str, str], dict[str, Any]]:
embedding_prop: str | None = None
meta_types: dict[str, str] = {}
meta_examples: dict[str, Any] = {}
for p in properties:
t, sample = _sample_property(driver, label, p)
if t == "vector":
embedding_prop = p
else:
meta_types[p] = t
meta_examples[p] = sample
return embedding_prop, meta_types, meta_examples
Функция _detect_type поочередно проверяет наличие списка (вектора), значения типа bool, int, float, datetime и date; в случае отсутствия подходящего типа используется строка. datetime и date считаются разными типами. Свойство, содержащее временные компоненты, имеет тип datetime и подвергается преобразованию с использованием функции datetime() Neo4j, а не date(). Список значений типа float представляет собой вектор признаков, а все остальные свойства становятся полями метаданных, подлежащими фильтрации. В примере с новостями результат будет выглядеть следующим образом:
embedding -> vector
published_date -> date (or datetime, if timestamps are present)
source -> string
sentiment -> float
Словарь meta_examples хранит реальные значения примеров, такие как «Reuters» для источника или 0.72 для оценки настроения, и они обогащают генерируемые документационные строки на следующем этапе.
Шаг 3: генерация инструментов поиска
Теперь все соединяется воедино. Для каждого индекса замыкание сохраняет его метаданные и создает функцию поиска:
def _make_search_fn(idx: dict[str, Any]):
"""Factory that closes over a single index's metadata."""
index_name = idx["name"]
label = idx["label"]
embed_prop = idx["embed_prop"]
prop_types = idx["prop_types"]
prop_examples = idx["prop_examples"]
# Build the filter-parameter docstring dynamically
filter_lines = []
for prop, t in prop_types.items():
safe = prop.replace(" ", "_").replace("-", "_")
ex = _format_example(prop_examples.get(prop), t)
if t in ("float", "int"):
filter_lines.append(
f" {safe}: Optional dict with 'min' and/or 'max' (numeric range). Example value: {ex}"
)
elif t == "datetime":
filter_lines.append(
f" {safe}: Optional dict with 'min' and/or 'max' (ISO datetime strings, uses datetime()). Example value: {ex}"
)
elif t == "date":
filter_lines.append(
f" {safe}: Optional dict with 'min' and/or 'max' (ISO date strings, uses date()). Example value: {ex}"
)
elif t == "bool":
filter_lines.append(
f" {safe}: Optional bool. Example value: {ex}"
)
else:
filter_lines.append(
f" {safe}: Optional str (exact match). Example value: {ex}"
)
filters_doc = "\n".join(filter_lines) if filter_lines else " (none)"
async def search(
query: str,
top_k: int = 10,
filters: Optional[dict[str, Any]] = None,
) -> str:
query_vec = embedder.embed_query(query)
where_clause, cypher_params = _build_where(prop_types, filters or {})
where_part = f"\n WHERE {where_clause}" if where_clause else ""
cypher = (
f"CYPHER 25\n"
f"MATCH (n:$($label))\n"
f" SEARCH n IN (\n"
f" VECTOR INDEX {index_name}\n"
f" FOR $query_vec{where_part}\n"
f" LIMIT $top_k\n"
f" ) SCORE AS score\n"
f"RETURN n {{ .*, `{embed_prop}`: null }} AS doc, score\n"
f"ORDER BY score DESC"
)
cypher_params["query_vec"] = query_vec
cypher_params["top_k"] = top_k
rows, _, _ = driver.execute_query(
cypher, cypher_params, database_=NEO4J_DATABASE
)
results = [{"doc": dict(r["doc"]), "score": r["score"]} for r in rows]
return json.dumps(results, indent=2, default=str)
search.__name__ = f"search_{index_name}"
search.__qualname__ = f"search_{index_name}"
search.__doc__ = (
f"Semantic vector search over :{label} nodes using the "
f"'{index_name}' index.\n\n"
f"Args:\n"
f" query: Natural-language search text (will be embedded).\n"
f" top_k: Number of results to return (default 10).\n"
f" filters: Optional dict of metadata filters:\n"
f"{filters_doc}\n"
)
return search
Некоторые детали заслуживают внимания:
- Префикс
CYPHER 25включает более современную синтаксис, необходимую для оператора SEARCH и фильтрации внутри индекса. - Оператор
WHEREнаходится внутри блока SEARCH. Это и есть механизм фильтрации внутри индекса версии 2026.02. Сам алгоритм HNSW учитывает эти условия, поэтому результаты приблизительного поиска ближайших соседей возвращаются уже отфильтрованными, без потери точности после дополнительной фильтрации. - Атрибут embedding в возвращаемых строках устанавливается в значение null (
{embed_prop}: null), чтобы большие векторы не возвращались к LLM, где они бы только тратили токены. - Документация генерируется динамически на основе выбранных типов и содержит конкретные примеры значений для каждого фильтра. Таким образом агент может узнать, какие фильтры существуют, какие типы они ожидают и как выглядят реалистичные значения.
Создание оператора WHERE
Функция _build_where преобразует словарь фильтров на Python в предикат Cypher, выбирая форму в зависимости от объявленного типа каждого свойства:
- Числа с плавающей точкой и целые числа преобразуются в фильтры диапазона с помощью
{"min": ..., "max": ...}, что приводит к сравнениям>=и<=. - Даты и времена используют ту же логику диапазона, но с применением функции
datetime(). - Дата также обрабатываются с использованием функции
date(). - Логические значения применяют сравнение строгой равности.
- Строки, что является значением по умолчанию, также используют сравнение строгой равности.
Все предикаты соединяются с помощью оператора AND, что соответствует простым сравнениям свойств, поддерживаемым Neo4j в фильтрации по векторным индексам.
def _build_where(
prop_types: dict[str, str], filters: dict[str, Any]
) -> tuple[str, dict[str, Any]]:
clauses: list[str] = []
params: dict[str, Any] = {}
for prop, t in prop_types.items():
safe = prop.replace(" ", "_").replace("-", "_")
val = filters.get(safe)
if val is None:
continue
if t in ("float", "int"):
if isinstance(val, dict):
if val.get("min") is not None:
k = f"filt_{safe}_min"
clauses.append(f"n.`{prop}` >= ${k}")
params[k] = val["min"]
if val.get("max") is not None:
k = f"filt_{safe}_max"
clauses.append(f"n.`{prop}` <= ${k}")
params[k] = val["max"]
elif t == "datetime":
if isinstance(val, dict):
if val.get("min") is not None:
k = f"filt_{safe}_min"
clauses.append(f"n.`{prop}` >= datetime(${k})")
params[k] = val["min"]
if val.get("max") is not None:
k = f"filt_{safe}_max"
clauses.append(f"n.`{prop}` <= datetime(${k})")
params[k] = val["max"]
elif t == "date":
if isinstance(val, dict):
if val.get("min") is not None:
k = f"filt_{safe}_min"
clauses.append(f"n.`{prop}` >= date(${k})")
params[k] = val["min"]
if val.get("max") is not None:
k = f"filt_{safe}_max"
clauses.append(f"n.`{prop}` <= date(${k})")
params[k] = val["max"]
elif t == "bool":
k = f"filt_{safe}"
clauses.append(f"n.`{prop}` = ${k}")
params[k] = val
else: # string exact match
k = f"filt_{safe}"
clauses.append(f"n.`{prop}` = ${k}")
params[k] = val
where = " AND ".join(clauses)
return where, params
Почему бы не позволить ЯИИ писать запросы на Cypher?
Сервер Cypher MCP от Neo4j может генерировать запросы с помощью функции преобразования текста в Cypher, но для векторного поиска требуются эмбеддинги, и в настоящее время единственный способ передачи эмбеддингов между инструментами — это через контекст ЯИИ. Передача 1536-мерных векторов через модель тратит токены и время.
Если объединить весь процесс — генерацию эмбеддингов, создание и выполнение запросов на Cypher — в один инструмент, эмбеддинги останутся вне контекста модели. ЯИИ получает запрос на естественном языке с необязательными фильтрами и возвращает результаты с оценкой.
Запуск
Сервер можно запустить непосредственно с GitHub с помощью uvx. Для настройки клиента MCP в Claude Desktop, VS Code, Cursor или подобных программах добавьте запись в следующем формате:
{
"mcpServers": {
"neo4j-vector": {
"command": "uvx",
"args": [
"--from",
"git+https://github.com/tomasonjo/neo4j-prefiltering-mcp",
"--with",
"langchain-openai",
"neo4j-prefiltering-mcp"
],
"env": {
"NEO4J_URI": "neo4j+s://demo.neo4jlabs.com:7687",
"NEO4J_USER": "companies2",
"NEO4J_PASSWORD": "companies2",
"NEO4J_DATABASE": "companies2",
"EMBEDDING_MODEL": "openai:text-embedding-3-small",
"OPENAI_API_KEY": "<your-key>"
}
}
}
}
Флаг --with langchain-openai устанавливает поставщика эмбеддингов OpenAI. Чтобы использовать другой поставщик, например Cohere или локальную модель, замените эту зависимость и обновите переменную EMBEDDING_MODEL. Поиск эмбеддингов осуществляется через метод init_embeddings() в LangChain, поэтому будет работать любой поставщик, поддерживаемый LangChain.
При тестировании в Claude Desktop с примерным вопросом и использовании расширенного вызова инструмента отображаются параметры вот таких видов:
{
"query": "positive developments in artificial intelligence AI breakthroughs progress",
"top_k": 10,
"filters": {
"language": "en",
"date": {
"min": "2026-01-01",
"max": "2026-12-31"
},
"sentiment": {
"min": 0.5
}
}
}
Нет необходимости вручную писать код: никаких определений инструментов, никаких запросов, никаких схем фильтрации. Создайте ещё один векторный индекс, перезапустите сервер — и появится ещё один инструмент.
Вывод
Векторный поиск с фильтрацией в Neo4j 2026.02 в сочетании с синтаксисом SEARCH Cypher 25 предоставляет простой способ создания инструментов для фильтрованного векторного поиска. В сочетании с MCP и небольшим количеством операций анализа при запуске получается сервер, который находит векторные индексы, определяет свойства, подлежащие фильтрации, и генерирует инструменты поиска с учётом типов данных, при этом не требуется ни одной строки кода, специфичной для конкретного инструмента. Достаточно использовать базу данных Neo4j с векторными индексами — сервер сам соберёт необходимый набор инструментов. В документации по фильтрации приведён полный синтаксис.