Skip to content

Commit b87d0be

Browse files
Guikingonechr-hertel
authored andcommitted
[Store] Add Neo4j
1 parent 2b6f483 commit b87d0be

File tree

8 files changed

+485
-1
lines changed

8 files changed

+485
-1
lines changed

examples/.env

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,9 @@ QDRANT_SERVICE_API_KEY=changeMe
9090
SURREALDB_HOST=http://127.0.0.1:8000
9191
SURREALDB_USER=symfony
9292
SURREALDB_PASS=symfony
93+
94+
# Neo4J
95+
NEO4J_HOST=http://127.0.0.1:7474
96+
NEO4J_DATABASE=neo4j
97+
NEO4J_USERNAME=neo4j
98+
NEO4J_PASSWORD=symfonyai

examples/compose.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,11 @@ services:
3737
SURREAL_HTTP_MAX_KEY_BODY_SIZE: 49152
3838
ports:
3939
- '8000:8000'
40+
41+
neo4j:
42+
image: neo4j
43+
environment:
44+
NEO4J_AUTH: 'neo4j/${NEO4J_PASSWORD:-symfonyai}'
45+
ports:
46+
- '7474:7474'
47+
- '7687:7687'

examples/rag/neo4j.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Agent\Agent;
13+
use Symfony\AI\Agent\Toolbox\AgentProcessor;
14+
use Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch;
15+
use Symfony\AI\Agent\Toolbox\Toolbox;
16+
use Symfony\AI\Fixtures\Movies;
17+
use Symfony\AI\Platform\Bridge\OpenAI\Embeddings;
18+
use Symfony\AI\Platform\Bridge\OpenAI\GPT;
19+
use Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory;
20+
use Symfony\AI\Platform\Message\Message;
21+
use Symfony\AI\Platform\Message\MessageBag;
22+
use Symfony\AI\Store\Bridge\Neo4j\Store;
23+
use Symfony\AI\Store\Document\Metadata;
24+
use Symfony\AI\Store\Document\TextDocument;
25+
use Symfony\AI\Store\Document\Vectorizer;
26+
use Symfony\AI\Store\Indexer;
27+
use Symfony\Component\HttpClient\HttpClient;
28+
use Symfony\Component\Uid\Uuid;
29+
30+
require_once dirname(__DIR__).'/bootstrap.php';
31+
32+
// initialize the store
33+
$store = new Store(
34+
httpClient: HttpClient::create(),
35+
endpointUrl: env('NEO4J_HOST'),
36+
username: env('NEO4J_USERNAME'),
37+
password: env('NEO4J_PASSWORD'),
38+
databaseName: env('NEO4J_DATABASE'),
39+
vectorIndexName: 'Movies',
40+
nodeName: 'movies',
41+
);
42+
43+
// initialize the table
44+
$store->initialize();
45+
46+
// create embeddings and documents
47+
$documents = [];
48+
foreach (Movies::all() as $i => $movie) {
49+
$documents[] = new TextDocument(
50+
id: Uuid::v4(),
51+
content: 'Title: '.$movie['title'].\PHP_EOL.'Director: '.$movie['director'].\PHP_EOL.'Description: '.$movie['description'],
52+
metadata: new Metadata($movie),
53+
);
54+
}
55+
56+
// create embeddings for documents
57+
$platform = PlatformFactory::create($_SERVER['OPENAI_API_KEY']);
58+
$vectorizer = new Vectorizer($platform, $embeddings = new Embeddings());
59+
$indexer = new Indexer($vectorizer, $store);
60+
$indexer->index($documents);
61+
62+
$model = new GPT(GPT::GPT_4O_MINI);
63+
64+
$similaritySearch = new SimilaritySearch($platform, $embeddings, $store);
65+
$toolbox = new Toolbox([$similaritySearch], logger: logger());
66+
$processor = new AgentProcessor($toolbox);
67+
$agent = new Agent($platform, $model, [$processor], [$processor]);
68+
69+
$messages = new MessageBag(
70+
Message::forSystem('Please answer all user questions only using SimilaritySearch function.'),
71+
Message::ofUser('Which movie fits the theme of technology?')
72+
);
73+
$response = $agent->call($messages);
74+
75+
echo $response->getContent().\PHP_EOL;

src/store/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ CHANGELOG
4444
- Pinecone
4545
- Qdrant
4646
- SurrealDB
47+
- Neo4j
4748
* Add Retrieval Augmented Generation (RAG) support:
4849
- Document embedding storage
4950
- Similarity search for relevant documents

src/store/composer.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
"ai",
77
"mongodb",
88
"pinecone",
9-
"chromadb"
9+
"chromadb",
10+
"mariadb",
11+
"postgres",
12+
"meilisearch",
13+
"surrealdb",
14+
"qdrant",
15+
"neo4j"
1016
],
1117
"license": "MIT",
1218
"authors": [

src/store/doc/index.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ You can find more advanced usage in combination with an Agent using the store fo
4444
* `Similarity Search with Pinecone (RAG)`_
4545
* `Similarity Search with Qdrant (RAG)`_
4646
* `Similarity Search with SurrealDB (RAG)`_
47+
* `Similarity Search with Neo4j (RAG)`_
4748

4849
Supported Stores
4950
----------------
@@ -58,6 +59,7 @@ Supported Stores
5859
* `Postgres`_ (requires `ext-pdo`)
5960
* `Qdrant`_
6061
* `SurrealDB`_
62+
* `Neo4j`_
6163

6264
.. note::
6365

@@ -98,6 +100,7 @@ This leads to a store implementing two methods::
98100
.. _`Similarity Search with SurrealDB (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/surrealdb.php
99101
.. _`Similarity Search with memory storage (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/in-memory.php
100102
.. _`Similarity Search with Qdrant (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/qdrant.php
103+
.. _`Similarity Search with Neo4j (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/neo4j.php
101104
.. _`Azure AI Search`: https://azure.microsoft.com/products/ai-services/ai-search
102105
.. _`Chroma`: https://www.trychroma.com/
103106
.. _`MariaDB`: https://mariadb.org/projects/mariadb-vector/
@@ -108,4 +111,5 @@ This leads to a store implementing two methods::
108111
.. _`SurrealDB`: https://surrealdb.com/
109112
.. _`InMemory`: https://www.php.net/manual/en/language.types.array.php
110113
.. _`Qdrant`: https://qdrant.tech/
114+
.. _`Neo4j`: https://neo4j.com/
111115
.. _`GitHub`: https://github.com/symfony/ai/issues/16

src/store/src/Bridge/Neo4j/Store.php

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Store\Bridge\Neo4j;
13+
14+
use Symfony\AI\Platform\Vector\NullVector;
15+
use Symfony\AI\Platform\Vector\Vector;
16+
use Symfony\AI\Store\Document\Metadata;
17+
use Symfony\AI\Store\Document\VectorDocument;
18+
use Symfony\AI\Store\Exception\InvalidArgumentException;
19+
use Symfony\AI\Store\InitializableStoreInterface;
20+
use Symfony\AI\Store\VectorStoreInterface;
21+
use Symfony\Component\Uid\Uuid;
22+
use Symfony\Contracts\HttpClient\HttpClientInterface;
23+
24+
/**
25+
* @author Guillaume Loulier <[email protected]>
26+
*/
27+
final readonly class Store implements InitializableStoreInterface, VectorStoreInterface
28+
{
29+
public function __construct(
30+
private HttpClientInterface $httpClient,
31+
private string $endpointUrl,
32+
#[\SensitiveParameter] private string $username,
33+
#[\SensitiveParameter] private string $password,
34+
#[\SensitiveParameter] private string $databaseName,
35+
private string $vectorIndexName,
36+
private string $nodeName,
37+
private string $embeddingsField = 'embeddings',
38+
private int $embeddingsDimension = 1536,
39+
private string $embeddingsDistance = 'cosine',
40+
private bool $quantization = false,
41+
) {
42+
}
43+
44+
public function add(VectorDocument ...$documents): void
45+
{
46+
foreach ($documents as $document) {
47+
$this->request('POST', \sprintf('db/%s/query/v2', $this->databaseName), [
48+
'statement' => \sprintf('CREATE (n:%s {id: $id, metadata: $metadata, %s: $embeddings}) RETURN n', $this->nodeName, $this->embeddingsField),
49+
'parameters' => [
50+
'id' => $document->id->toRfc4122(),
51+
'metadata' => json_encode($document->metadata->getArrayCopy()),
52+
'embeddings' => $document->vector->getData(),
53+
],
54+
]);
55+
}
56+
}
57+
58+
public function query(Vector $vector, array $options = [], ?float $minScore = null): array
59+
{
60+
$response = $this->request('POST', \sprintf('db/%s/query/v2', $this->databaseName), [
61+
'statement' => \sprintf('CALL db.index.vector.queryNodes("%s", 5, $vectors) YIELD node, score RETURN node, score', $this->vectorIndexName),
62+
'parameters' => [
63+
'vectors' => $vector->getData(),
64+
],
65+
]);
66+
67+
return array_map($this->convertToVectorDocument(...), $response['data']['values']);
68+
}
69+
70+
public function initialize(array $options = []): void
71+
{
72+
$this->request('POST', \sprintf('db/%s/query/v2', $this->databaseName), [
73+
'statement' => \sprintf(
74+
'CREATE VECTOR INDEX %s IF NOT EXISTS FOR (n:%s) ON n.%s OPTIONS { indexConfig: {`vector.dimensions`: %d, `vector.similarity_function`: "%s", `vector.quantization.enabled`: %s}}',
75+
$this->vectorIndexName, $this->nodeName, $this->embeddingsField, $this->embeddingsDimension, $this->embeddingsDistance, $this->quantization ? 'true' : 'false',
76+
),
77+
]);
78+
}
79+
80+
/**
81+
* @param array<string, mixed> $payload
82+
*
83+
* @return array<string, mixed>
84+
*/
85+
private function request(string $method, string $endpoint, array $payload = []): array
86+
{
87+
$url = \sprintf('%s/%s', $this->endpointUrl, $endpoint);
88+
89+
$response = $this->httpClient->request($method, $url, [
90+
'auth_basic' => \sprintf('%s:%s', $this->username, $this->password),
91+
'json' => $payload,
92+
]);
93+
94+
return $response->toArray();
95+
}
96+
97+
/**
98+
* @param array<string|int, mixed> $data
99+
*/
100+
private function convertToVectorDocument(array $data): VectorDocument
101+
{
102+
$payload = $data[0];
103+
104+
$id = $payload['properties']['id'] ?? throw new InvalidArgumentException('Missing "id" field in the document data');
105+
106+
$vector = !\array_key_exists($this->embeddingsField, $payload['properties']) || null === $payload['properties'][$this->embeddingsField]
107+
? new NullVector()
108+
: new Vector($payload['properties'][$this->embeddingsField]);
109+
110+
return new VectorDocument(
111+
id: Uuid::fromString($id),
112+
vector: $vector,
113+
metadata: new Metadata(json_decode($payload['properties']['metadata'], true)),
114+
score: $data[1] ?? null
115+
);
116+
}
117+
}

0 commit comments

Comments
 (0)