Serwer MCP bez konfiguracji do filtrowanej wyszukiwania wektorowego w Neo4j
Automatycznie odkrywaj indeksy wektorowe Neo4j, przeglądaj typy właściwości oraz twórz narzędzia wyszukiwania typu MCP, które wykorzystują filtrowanie w czasie indeksowania za pomocą Cypher 25, bez konieczności ręcznej konfiguracji.
Wyobraź sobie graf wiedzy pełen artykułów prasowych oraz agenta, który otrzymuje polecenie: pokaż pozytywne doniesienia o energii odnawialnej z Reutersa z zeszłego tygodnia. W tym zdaniu kryją się trzy różne elementy. Jeden to wyszukiwanie semantyczne pod hasłem „energia odnawialna”. Pozostałe to filtry metadanych dotyczące źródła (Reuters), tonu (pozytywnego) oraz daty (zeszłego tygodnia).
Zwykłe wyszukiwanie wektorowe radzi sobie z częścią semantyczną. Bez filtrów jednak z radością pokaże negatywne artykuły z 2019 roku opublikowane na przypadkowych blogach, a model językowy nie ma sposobu, by stwierdzić, że są one nieistotne, dopóki nie przeczyta ich wszystkich. To właśnie jest problem wyszukiwania wektorowego z filtrowaniem metadanych, a Neo4j od dawna oferuje dwa sposoby na jego rozwiązanie.
- Wpierw zastosuj filtrowanie za pomocą Cyphera, aby zmniejszyć zbiór kandydatów, a następnie przeprowadź wyszukiwanie wektorowe na tym, co pozostało. Wyniki są dokładne, ale mogą być powolne, gdy zbiór kandydatów pozostaje duży.
Oba podejścia miały swoje zalety, a jednocześnie pozostawiały miejsce na ulepszenia.
Jakie zmiany przyniosło Neo4j 2026.02
Neo4j 2026.02 (patrz notatki wydawnicze) udostępnił ogólnie dostępne wyszukiwanie wektorowe z filtrami jako część Cypher 25. Predykaty można teraz stosować wewnątrz indeksu wektorowego podczas wyszukiwania, dzięki czemu algorytm HNSW pomija wektory niespójne z kryteriami przed sortowaniem. Opóźnienie pozostaje niskie, precyzja wysoka, a nie ma potrzeby pobierania zbędnych danych. Pojawiła się również nowa, wbudowana klauzula SEARCH, która zastępuje starszą procedurę db.index.vector.queryNodes() i czyni wyszukiwanie wektorowe pełnoprawną częścią języka zapytań.
To rodzi pytanie: czy serwer MCP mógłby samodzielnie odkryć indeksy wektorowe, przeanalizować ich właściwości metadanych i stworzyć narzędzia wyszukiwania z filtrami bez konieczności ręcznej konfiguracji?
Ogólny serwer wyszukiwania wektorowego bez żadnej konfiguracji
Każdy, kto budował narzędzia MCP do wyszukiwania wektorowego, zna ten standardowy proces: pisze kod Cypher, definiuje parametry, konfiguruje model embeddingu oraz dokumentuje filtry. Dla dobrze znanych indeksów to wystarcza. Jednak bardziej atrakcyjny jest serwer, który może być użyty z dowolną bazą danych Neo4j – wystarczy wskazać na odpowiedni instancję, a serwer sam zajmie się resztą.
To właśnie robi ten serwer. Podczas uruchamiania łączy się z Neo4j, znajduje wszystkie indeksy wektorowe, pobiera próbkę węzła z każdego z nich w celu identyfikacji typów właściwości, a następnie tworzy dla każdego indeksu pełnowartościowe narzędzie MCP z parametrami filtrów oraz dokumentacją, którą może przeczytać model LLM.
Jedna rzecz nie może zostać odkryta automatycznie: model embeddingu. Neo4j jeszcze nie przechowuje go w metadanych indeksu, a do wywołania modelu i tak potrzebny byłby klucz API. Dlatego dostawca embeddingów oraz jego dane uwierzytelniające to jedyne ustawienia, które należy podać.
Krok 1: znalezienie indeksów wektorowych
Najpierw serwer prosi Neo4j o wszystkie indeksy wektorowe:
records, _, _ = driver.execute_query(
"""
SHOW VECTOR INDEXES
""",
database_=NEO4J_DATABASE,
)
Wynik pokazuje nazwę każdego indeksu, etykietę węzła, który obejmuje, oraz właściwości zindeksowane. Jeśli nie ma indeksów wektorowych, serwer kończy pracę z błędem, ponieważ nie ma nic do dostarczenia.
Krok 2: typy próbek i przykładowe wartości
Dla każdego indeksu serwer musi wiedzieć, która właściwość przechowuje embedding oraz jaki typ ma każda z pozostałych. Czy published_date przechowuje zwykłą datę czy pełny zapis czasu? Czy source to tekst? Czy ocena nastroju to liczba dziesiętna? Specjalna pomocnikowa właściwość _sample_property odpowiada na to, sprawdzając pojedynczy węzeł dla każdej właściwości:
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
Zwraca wykryty typ oraz rzeczywistą wartość przykładu. Główna funkcja gromadzi je w dwóch słownikach – jednym dla typów, a drugim dla przykładów:
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
Funkcja _detect_type sprawdza kolejno listę (wektor), wartości typu bool, int, float, datetime oraz date, a w razie braku odpowiedniego typu używa wartości typu string. Datetime i date są traktowane jako różne typy. Właściwość zawierająca składniki czasowe ma typ datetime i jest przekształcana za pomocą funkcji datetime() w Neo4j, a nie date(). Lista wartości typu float stanowi embedding, natomiast wszystkie pozostałe właściwości stają się polami metadanych, które można filtrować. W przykładzie z wiadomościami wynik wygląda następująco:
embedding -> vector
published_date -> date (or datetime, if timestamps are present)
source -> string
sentiment -> float
Słownik meta_examples przechowuje rzeczywiste wartości przykładów, takie jak „Reuters” dla źródła lub 0,72 dla sentymentu, a one wzbogacają generowane docstrings na następnym etapie.
Krok 3: generowanie narzędzi wyszukiwania
Teraz wszystko łączy się w całość. Dla każdego indeksu funkcja zamknięta przechwytuje jego metadane i tworzy funkcję wyszukiwania:
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
Niektóre szczegóły wymagają uwagi:
- Przedrostek
CYPHER 25włącza nowszą składnię potrzebną do klauzuli SEARCH oraz filtrowania w indeksie. - Klauzula
WHEREznajduje się wewnątrz bloku SEARCH. To właśnie działa filtrywanie w indeksie z wersji 2026.02. Sam HNSW uwzględnia te warunki, dzięki czemu wyniki przybliżone najbliższego sąsiada są filtrowane, bez utraty dokładności po dalszym filtrowaniu. - Atrybut embedding jest ustawiony na null w zwracanych wierszach (
{embed_prop}: null), aby duże wektory nie wracały do LLM, gdzie tylko marnowałyby tokeny. - Dokumentacja jest generowana dynamicznie na podstawie wybranych typów i zawiera konkretny przykład wartości dla każdego filtru. Dzięki temu agent wie, jakie filtry istnieją, jakie typy są oczekiwane oraz jak wyglądają realistyczne wartości.
Budowanie klauzuli WHERE
Funkcja _build_where przekształca słownik filtrów w Pythonie na predykat Cypher, wybierając formę odpowiednią do zadeklarowanego typu każdej właściwości:
- Liczby zmiennoprzecinkowe i całkowite otrzymują filtry zakresu za pomocą
{"min": ..., "max": ...}, co skutkuje porównaniami>=i<=. - Data i godzina wykorzystują tę samą logikę zakresu, ale w opakowaniu funkcji
datetime(). - Data wykorzystuje ją również, lecz w opakowaniu funkcji
date(). - Liczby logiczne używają dokładnego porównania równości.
- Strony tekstowe, będące domyślnym typem, również wykorzystują dokładne porównanie równości.
Wszystkie predykaty są łączone za pomocą AND, co odpowiada prostym porównaniom właściwości obsługiwanych przez Neo4j w filtrowaniu indeksu wektorowego.
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
Dlaczego nie pozwolić LLM na napisanie zapytania Cypher?
Server MCP Cypher w Neo4j mógłby generować zapytania za pomocą funkcji tekst-do-Cypher, ale wyszukiwanie wektorowe wymaga embeddingów, a obecnie jedynym sposobem przenoszenia embeddingów między narzędziami jest użycie kontekstu LLM. Przesyłanie wektorów o wymiarze 1536 przez model marnuje tokeny i czas.
Zawieranie całego procesu – generowania embeddingów, tworzenia i wykonywania zapytań Cypher – w jednym narzędziu zapobiega dostępowi embeddingów do kontekstu modelu. LLM otrzymuje zapytanie w języku naturalnym oraz opcjonalne filtry i otrzymuje wyniki ocenione.
Uruchamianie
Server można uruchomić bezpośrednio z GitHub za pomocą uvx. Dodaj taką pozycję do konfiguracji klienta MCP dla Claude Desktop, VS Code, Cursor lub podobnych narzędzi:
{
"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>"
}
}
}
}
Flaga --with langchain-openai instaluje dostawcę embeddingów OpenAI. Aby użyć innego dostawcy, takiego jak Cohere lub lokalnego modelu, należy zastąpić tę zależność i zaktualizować EMBEDDING_MODEL. Wyszukiwanie embeddingów odbywa się poprzez metodę init_embeddings() w LangChain, więc każdy dostawca obsługiwany przez LangChain będzie działał.
Testowanie w Claude Desktop z przykładowym pytaniem oraz rozszerzenie wywołania narzędzia pokazuje następujące parametry:
{
"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
}
}
}
Nie ma potrzeby ręcznego pisania żadnych informacji: brak definicji narzędzi, brak zapytań, brak schematów filtrów. Stwórz kolejny indeks wektorowy, uruchom ponownie serwer i pojawi się kolejne narzędzie.
Główne wnioski
Poyszukiwanie wektorowe z filtrowaniem w Neo4j 2026.02, w połączeniu z składnią SEARCH w Cypher 25, oferuje prosty sposób na korzystanie z narzędzi do filtrowanego poyszukiwania wektorowego. W połączeniu z MCP oraz niewielką introspekcją podczas uruchamiania otrzymujemy serwer, który znajduje indeksy wektorowe, wykrywa właściwości podlegające filtrowaniu i generuje narzędzia do wyszukiwania typizowane, bez konieczności pisania ani jednej linijki kodu specyficznego dla danego narzędzia. Wystarczy skierować go na bazę danych Neo4j zawierającą indeksy wektorowe, a serwer sam stworzy odpowiedni zestaw narzędzi. Dokumentacja dotycząca filtrowania zawiera pełną składnię.
Pozycje pokrewne
- Spring AI Recipe: Filtrowanie wyników RAG z metadanymi według tytułu gry — Przewodnik po Spring AI, który taguje dokumenty według tytułu, wykorzystuje LLM do wybrania odpowiedniej wartości z pytania i filtrowa na jej podstawie wyniki wyszukiwania wektorowego.
- MCP w szczegółach: wiadomości JSON-RPC, serwery FastMCP i śledzenie ReAct — Prześledź żądanie Model Context Protocol od narzędzi typu list do ostatecznej odpowiedzi, z wykorzystaniem serwerów FastMCP, agenta LangGraph ReAct oraz narzędzia do rejestrowania każdego kroku.