Skip to content

Commit f363adc

Browse files
feat(vector-io): Add the Neo4j vector provider.
Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
1 parent 2189b3e commit f363adc

11 files changed

Lines changed: 1376 additions & 1 deletion

File tree

.github/workflows/integration-vector-io-tests.yml

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
strategy:
3939
matrix:
4040
# Use a smaller set of providers on PRs to reduce runner contention
41-
vector-io-provider: ${{ github.event_name == 'pull_request' && fromJSON('["inline::faiss", "inline::sqlite-vec", "remote::pgvector"]') || fromJSON('["inline::faiss", "inline::sqlite-vec", "inline::milvus", "remote::chromadb", "remote::pgvector", "remote::weaviate", "remote::qdrant", "remote::elasticsearch", "remote::infinispan"]') }}
41+
vector-io-provider: ${{ github.event_name == 'pull_request' && fromJSON('["inline::faiss", "inline::sqlite-vec", "remote::pgvector", "remote::neo4j"]') || fromJSON('["inline::faiss", "inline::sqlite-vec", "inline::milvus", "remote::chromadb", "remote::pgvector", "remote::weaviate", "remote::qdrant", "remote::elasticsearch", "remote::infinispan", "remote::neo4j"]') }}
4242
python-version: ${{ github.event.schedule == '0 0 * * 1' && fromJSON('["3.12", "3.13"]') || fromJSON('["3.12"]') }}
4343
fail-fast: false # we want to run all tests regardless of failure
4444

@@ -81,6 +81,30 @@ jobs:
8181
-p 5432:5432 \
8282
pgvector/pgvector:pg17
8383
84+
- name: Start Neo4j
85+
if: matrix.vector-io-provider == 'remote::neo4j'
86+
run: |
87+
docker run --rm -d \
88+
--name neo4j \
89+
-p 7474:7474 -p 7687:7687 \
90+
-e NEO4J_AUTH=neo4j/ogxpassword \
91+
neo4j:2025.10
92+
93+
- name: Wait for Neo4j to be ready
94+
if: matrix.vector-io-provider == 'remote::neo4j'
95+
run: |
96+
echo "Waiting for Neo4j to be ready..."
97+
for _ in {1..60}; do
98+
if docker exec neo4j cypher-shell -u neo4j -p ogxpassword 'RETURN 1 AS ok' >/dev/null 2>&1; then
99+
echo "Neo4j is ready!"
100+
exit 0
101+
fi
102+
sleep 2
103+
done
104+
echo "Neo4j failed to start"
105+
docker logs neo4j
106+
exit 1
107+
84108
- name: Wait for PGVector to be ready
85109
if: matrix.vector-io-provider == 'remote::pgvector'
86110
run: |
@@ -239,6 +263,10 @@ jobs:
239263
INFINISPAN_URL: ${{ matrix.vector-io-provider == 'remote::infinispan' && 'http://localhost:11222' || '' }}
240264
INFINISPAN_USERNAME: ${{ matrix.vector-io-provider == 'remote::infinispan' && 'admin' || '' }}
241265
INFINISPAN_PASSWORD: ${{ matrix.vector-io-provider == 'remote::infinispan' && 'password' || '' }}
266+
NEO4J_URI: ${{ matrix.vector-io-provider == 'remote::neo4j' && 'bolt://localhost:7687' || '' }}
267+
NEO4J_USER: ${{ matrix.vector-io-provider == 'remote::neo4j' && 'neo4j' || '' }}
268+
NEO4J_PASSWORD: ${{ matrix.vector-io-provider == 'remote::neo4j' && 'ogxpassword' || '' }}
269+
NEO4J_DATABASE: ${{ matrix.vector-io-provider == 'remote::neo4j' && 'neo4j' || '' }}
242270
run: |
243271
uv run --no-sync \
244272
pytest -sv --stack-config="files=inline::localfs,inference=inline::sentence-transformers?trust_remote_code=true,vector_io=${{ matrix.vector-io-provider }},file_processors=inline::pypdf" \
@@ -275,6 +303,11 @@ jobs:
275303
run: |
276304
docker logs infinispan > infinispan.log
277305
306+
- name: Write Neo4j logs to file
307+
if: ${{ always() && matrix.vector-io-provider == 'remote::neo4j' }}
308+
run: |
309+
docker logs neo4j > neo4j.log
310+
278311
- name: Upload all logs to artifacts
279312
if: ${{ always() }}
280313
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
---
2+
description: |
3+
[Neo4j](https://neo4j.com/) is a remote graph database provider for OGX VectorIO.
4+
It supports vector search with Neo4j vector indexes, keyword search with full-text
5+
indexes, hybrid search with OGX reranking, and optional graph-aware expansion through
6+
relationships between retrieved chunks.
7+
8+
## Features
9+
10+
- Vector search over chunk embeddings
11+
- Keyword search over chunk text
12+
- Hybrid search using OGX reranking
13+
- Optional graph-aware retrieval through related chunks
14+
- OpenAI-compatible vector store lifecycle through OGX
15+
16+
## Usage
17+
18+
Run Neo4j locally:
19+
20+
```bash
21+
docker run -d --name ogx-neo4j -p 7474:7474 -p 7687:7687 \
22+
-e NEO4J_AUTH=neo4j/ogxpassword neo4j:2025.10
23+
```
24+
sidebar_label: Remote - Neo4J
25+
title: remote::neo4j
26+
---
27+
28+
# remote::neo4j
29+
30+
## Description
31+
32+
33+
[Neo4j](https://neo4j.com/) is a remote graph database provider for OGX VectorIO.
34+
It supports vector search with Neo4j vector indexes, keyword search with full-text
35+
indexes, hybrid search with OGX reranking, and optional graph-aware expansion through
36+
relationships between retrieved chunks.
37+
38+
## Features
39+
40+
- Vector search over chunk embeddings
41+
- Keyword search over chunk text
42+
- Hybrid search using OGX reranking
43+
- Optional graph-aware retrieval through related chunks
44+
- OpenAI-compatible vector store lifecycle through OGX
45+
46+
## Usage
47+
48+
Run Neo4j locally:
49+
50+
```bash
51+
docker run -d --name ogx-neo4j -p 7474:7474 -p 7687:7687 \
52+
-e NEO4J_AUTH=neo4j/ogxpassword neo4j:2025.10
53+
```
54+
55+
56+
## Configuration
57+
58+
| Field | Type | Required | Default | Description |
59+
|-------|------|----------|---------|-------------|
60+
| `uri` | `str` | No | bolt://localhost:7687 | Neo4j Bolt URI. |
61+
| `user` | `str` | No | neo4j | Neo4j username. |
62+
| `password` | `SecretStr \| None` | No | | Neo4j password. |
63+
| `database` | `str` | No | neo4j | Neo4j database name. |
64+
| `index_prefix` | `str` | No | ogx | Prefix for Neo4j vector and full-text indexes. |
65+
| `graph_retrieval_enabled` | `bool` | No | False | Whether to expand initial retrieval results through graph relationships. |
66+
| `graph_expansion_depth` | `int` | No | 1 | Maximum relationship traversal depth for graph-aware retrieval. |
67+
| `graph_max_neighbors` | `int` | No | 10 | Maximum graph-expanded neighbor chunks to consider per query. |
68+
| `graph_expansion_weight` | `float` | No | 0.15 | Score multiplier applied to graph-expanded chunks relative to seed chunk scores. |
69+
| `graph_relationship_types` | `list[str] \| None` | No | | Relationship types used for graph expansion. When omitted, all relationships are considered. |
70+
| `persistence` | `KVStoreReference` | No | | Config for KV store backend. |
71+
| `persistence.namespace` | `str` | No | | Key prefix for KVStore backends |
72+
| `persistence.backend` | `str` | No | | Name of backend from storage.backends |
73+
| `metadata_store` | `SqlStoreReference \| None` | No | | SQL store reference for tenant-isolated vector store metadata. |
74+
| `metadata_store.table_name` | `str` | No | | Name of the table to use for the SqlStore |
75+
| `metadata_store.backend` | `str` | No | | Name of backend from storage.backends |
76+
77+
## Sample Configuration
78+
79+
```yaml
80+
uri: ${env.NEO4J_URI:=bolt://localhost:7687}
81+
user: ${env.NEO4J_USER:=neo4j}
82+
password: ${env.NEO4J_PASSWORD:=}
83+
database: ${env.NEO4J_DATABASE:=neo4j}
84+
graph_retrieval_enabled: false
85+
graph_expansion_depth: 1
86+
graph_max_neighbors: 10
87+
graph_expansion_weight: 0.15
88+
persistence:
89+
namespace: vector_io::neo4j
90+
backend: kv_default
91+
```
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
# Neo4j VectorIO Provider Implementation Plan
2+
3+
Goal: add a first-slice `remote::neo4j` VectorIO provider that behaves like the
4+
other OGX vector store providers and proves deterministic graph-aware retrieval
5+
without changing the public OGX API.
6+
7+
## Review Findings
8+
9+
- The provider must use Neo4j's async driver internally because `VectorIO` is
10+
async. The live integration test should therefore use that driver through the
11+
real adapter contract, rather than relying on the synchronous OGX client
12+
wrapper or a mocked driver.
13+
- It tried to pass graph-specific params through `EmbeddingIndex` methods, whose
14+
shared protocol does not accept backend-specific params. The corrected design
15+
performs graph expansion in the Neo4j adapter after normal query execution.
16+
- It proposed enabling shared filter and OpenAI vector-store allowlists before
17+
proving Neo4j compatibility. The first implementation should explicitly leave
18+
those broad allowlists unchanged unless the focused tests pass.
19+
- It mixed a full generated-docs rollout into the first code slice. Generated
20+
provider docs remain part of the provider change, while CI is included here
21+
because the deterministic Neo4j integration test must run against a real
22+
service in pull requests.
23+
- The deterministic graph test should not depend on a sentence-transformer query
24+
embedding matching hand-written 3D chunk embeddings. It should use fixed
25+
vectors and deterministic keyword input against a local Neo4j container while
26+
exercising `Neo4jVectorIOAdapter`; direct index calls may be used only for the
27+
focused vector-index assertion.
28+
29+
## Scope
30+
31+
In scope:
32+
33+
- `Neo4jVectorIOConfig` with documented Pydantic fields and sample env config.
34+
- `remote::neo4j` registry entry and `neo4j` dependency wiring.
35+
- Provider factory, driver lifecycle, KV-backed vector-store registration, chunk
36+
insert/delete, vector search, keyword search, hybrid search, and graph expansion.
37+
- Unit tests for provider-local config, lifecycle, filter translation, hybrid
38+
scoring, and graph-expansion ranking. These tests must not connect to Neo4j.
39+
- A focused optional integration test that runs only when `NEO4J_URI` is set and
40+
exercises the real adapter, index creation, chunk persistence, retrieval, and
41+
deterministic relationships against a live Neo4j process. It compares the
42+
same query with graph retrieval disabled and enabled to demonstrate the
43+
additional related chunk returned by GraphRAG.
44+
45+
Out of scope for this slice:
46+
47+
- New public API fields.
48+
- Router or shared `EmbeddingIndex` protocol changes.
49+
- Adding Neo4j to broad filter/OpenAI vector-store allowlists.
50+
- Broader provider allowlist changes and unrelated CI suites.
51+
- The VectorIO GitHub Actions matrix is in scope: it starts Neo4j 2025.10,
52+
waits for Bolt readiness, passes connection variables to the Neo4j job, and
53+
uploads Neo4j logs on failure.
54+
55+
## Design
56+
57+
### Provider Config
58+
59+
Create `src/ogx/providers/remote/vector_io/neo4j/config.py` with
60+
`Neo4jVectorIOConfig`.
61+
62+
Required fields:
63+
64+
- `uri`, default `bolt://localhost:7687`
65+
- `user`, default `neo4j`
66+
- `password`, optional `SecretStr`
67+
- `database`, default `neo4j`
68+
- `index_prefix`, default `ogx`
69+
- `graph_retrieval_enabled`, default `False`
70+
- `graph_expansion_depth`, default `1`, allowed range `1..3`
71+
- `graph_max_neighbors`, default `10`
72+
- `graph_expansion_weight`, default `0.15`
73+
- `graph_relationship_types`, optional list of Neo4j relationship types
74+
- `persistence: KVStoreReference`
75+
- `metadata_store: SqlStoreReference | None`
76+
77+
`sample_run_config()` should use `${env.NEO4J_*}` templates and persist under
78+
`vector_io::neo4j`.
79+
80+
### Adapter
81+
82+
Create `src/ogx/providers/remote/vector_io/neo4j/__init__.py` and
83+
`src/ogx/providers/remote/vector_io/neo4j/neo4j.py`.
84+
85+
The adapter should:
86+
87+
- Extend `OpenAIVectorStoreMixin`, `VectorIO`, and `VectorStoresProtocolPrivate`.
88+
- Use `AsyncGraphDatabase.driver()` and verify connectivity during initialize.
89+
- Initialize `kvstore` from `config.persistence`.
90+
- Initialize optional `metadata_store` using `authorized_sqlstore()`.
91+
- Hydrate persisted vector stores from KV under `vector_stores:neo4j:v1::`.
92+
- Implement `register_vector_store`, `unregister_vector_store`,
93+
`_get_and_cache_vector_store_index`, `insert_chunks`, `query_chunks`, and
94+
`delete_chunks`.
95+
- Call `initialize_openai_vector_stores()` after KV/metadata store setup.
96+
- Close the driver on shutdown.
97+
98+
`query_chunks()` should call the shared `VectorStoreWithIndex.query_chunks()`,
99+
then call a provider-local graph expansion helper when either provider config or
100+
request params enable graph retrieval.
101+
102+
### Neo4jIndex
103+
104+
`Neo4jIndex` should implement `EmbeddingIndex`.
105+
106+
Node model:
107+
108+
- One per-vector-store chunk label, derived from a sanitized vector-store id.
109+
- Properties:
110+
- `chunk_id`
111+
- `content`
112+
- `embedding`
113+
- `chunk_content`, stored as a JSON string
114+
- safe metadata mirror properties named `metadata_<key>` for primitive metadata
115+
values used by filters
116+
- `vector_store_id`
117+
118+
Chunk writes are upserts keyed by `chunk_id`. They replace all provider-owned
119+
node properties, including mirrored metadata, so re-ingesting a chunk cannot
120+
leave stale filterable metadata behind. Replacing properties must preserve
121+
relationships attached to the chunk node.
122+
123+
Indexes:
124+
125+
- Neo4j uniqueness constraint on `chunk_id` for each vector-store label.
126+
- Neo4j vector index on `embedding`.
127+
- Neo4j full-text index on `content`.
128+
129+
Queries:
130+
131+
- Vector: `CALL db.index.vector.queryNodes(...)`.
132+
- Keyword: `CALL db.index.fulltext.queryNodes(...)`.
133+
- Hybrid: query vector and keyword separately, combine using
134+
`WeightedInMemoryAggregator.combine_search_results()`.
135+
136+
### Filters
137+
138+
Support metadata filters in the first slice because broad VectorIO tests already
139+
exercise provider filters. Implement `_translate_filters()` for:
140+
141+
- comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`
142+
- compound: `and`, `or`
143+
144+
Validate metadata keys with a conservative identifier regexp and use Cypher
145+
parameters for all values. Translate metadata filters against the mirrored
146+
`metadata_<key>` properties, not a nested `metadata` map, because Neo4j node
147+
properties cannot be nested maps. Unsupported filters should raise
148+
`NotImplementedError` with a `Failed to ...` message.
149+
150+
### Graph Expansion
151+
152+
Graph expansion remains provider-local and runs after normal retrieval.
153+
154+
Inputs:
155+
156+
- seed chunks and scores from normal query execution
157+
- `graph_retrieval_enabled`
158+
- `graph_expansion_depth`
159+
- `graph_max_neighbors`
160+
- `graph_expansion_weight`
161+
- optional `graph_relationship_types`
162+
163+
Behavior:
164+
165+
- If disabled or there are no seed chunks, return the original response.
166+
- Traverse from seed chunk nodes to neighboring chunk nodes through graph
167+
relationship paths.
168+
- Do not return the seed chunk as its own neighbor.
169+
- Score neighbor chunks as `max(seed_score) * graph_expansion_weight`.
170+
- Merge seed and neighbor chunks, preserve the highest score per chunk id, and
171+
return top `max_chunks`.
172+
173+
For the first implementation, graph fixtures can manually create relationships
174+
from chunk nodes to `(:Entity)` nodes in tests.
175+
176+
### Registration And Dependencies
177+
178+
- Add `RemoteProviderSpec` for `remote::neo4j` in
179+
`src/ogx/providers/registry/vector_io.py`.
180+
- Add `neo4j` to `starter`, `test`, and `type_checking` dependency surfaces in
181+
`pyproject.toml`.
182+
- Do not update generated provider docs or CI matrix until the focused unit and
183+
optional local integration tests pass.
184+
185+
## TDD Checklist
186+
187+
1. Add failing unit tests in `tests/unit/providers/vector_io/test_neo4j.py`.
188+
2. Verify they fail because the module/provider is missing.
189+
3. Implement config, factory, and lifecycle.
190+
4. Verify unit tests pass.
191+
5. Add failing unit tests for filter translation and graph expansion helpers.
192+
6. Implement filters and graph expansion.
193+
7. Verify unit tests pass.
194+
8. Add optional focused integration test in
195+
`tests/integration/vector_io/test_neo4j_graph_retrieval.py`. This test must
196+
use `Neo4jVectorIOAdapter`, not only a direct in-process `Neo4jIndex`, so the
197+
registered provider path is covered against a real Neo4j process.
198+
9. Run it with local Neo4j when available:
199+
200+
```bash
201+
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=ogxpassword \
202+
uv run pytest tests/integration/vector_io/test_neo4j_graph_retrieval.py -q
203+
```
204+
205+
1. Run focused style/pre-commit checks for changed files.
206+
207+
## Local Neo4j
208+
209+
Known-good local container:
210+
211+
```bash
212+
docker run -d \
213+
--name ogx-neo4j \
214+
-p 7474:7474 \
215+
-p 7687:7687 \
216+
-e NEO4J_AUTH=neo4j/ogxpassword \
217+
-v ogx-neo4j-data:/data \
218+
neo4j:2025.10
219+
```
220+
221+
Readiness check:
222+
223+
```bash
224+
for i in {1..60}; do
225+
if docker exec ogx-neo4j cypher-shell -u neo4j -p ogxpassword 'RETURN 1 AS ok'; then
226+
exit 0
227+
fi
228+
sleep 2
229+
done
230+
docker logs ogx-neo4j
231+
exit 1
232+
```

0 commit comments

Comments
 (0)