Skip to content

Commit 5c1bae7

Browse files
GuikingoneOskarStark
authored andcommitted
[Store] Add Typesense
1 parent 8cb4528 commit 5c1bae7

File tree

10 files changed

+457
-1
lines changed

10 files changed

+457
-1
lines changed

examples/.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,7 @@ NEO4J_HOST=http://127.0.0.1:7474
9696
NEO4J_DATABASE=neo4j
9797
NEO4J_USERNAME=neo4j
9898
NEO4J_PASSWORD=symfonyai
99+
100+
# Typesense
101+
TYPESENSE_HOST=http://127.0.0.1:8108
102+
TYPESENSE_API_KEY=changeMe

examples/compose.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,16 @@ services:
4545
ports:
4646
- '7474:7474'
4747
- '7687:7687'
48+
49+
typesense:
50+
image: typesense/typesense:29.0
51+
environment:
52+
TYPESENSE_API_KEY: '${TYPESENSE_API_KEY:-changeMe}'
53+
TYPESENSE_DATA_DIR: '/data'
54+
volumes:
55+
- typesense_data:/data
56+
ports:
57+
- '8108:8108'
58+
59+
volumes:
60+
typesense_data:

examples/rag/typesense.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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\Typesense\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\Uid\Uuid;
28+
29+
require_once dirname(__DIR__).'/bootstrap.php';
30+
31+
// initialize the store
32+
$store = new Store(
33+
httpClient: http_client(),
34+
endpointUrl: env('TYPESENSE_HOST'),
35+
apiKey: env('TYPESENSE_API_KEY'),
36+
collection: 'movies',
37+
);
38+
39+
// initialize the index
40+
$store->initialize();
41+
42+
// create embeddings and documents
43+
$documents = [];
44+
foreach (Movies::all() as $i => $movie) {
45+
$documents[] = new TextDocument(
46+
id: Uuid::v4(),
47+
content: 'Title: '.$movie['title'].\PHP_EOL.'Director: '.$movie['director'].\PHP_EOL.'Description: '.$movie['description'],
48+
metadata: new Metadata($movie),
49+
);
50+
}
51+
52+
// create embeddings for documents
53+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());
54+
$vectorizer = new Vectorizer($platform, $embeddings = new Embeddings());
55+
$indexer = new Indexer($vectorizer, $store, logger());
56+
$indexer->index($documents);
57+
58+
$model = new Gpt(Gpt::GPT_4O_MINI);
59+
60+
$similaritySearch = new SimilaritySearch($platform, $embeddings, $store);
61+
$toolbox = new Toolbox([$similaritySearch], logger: logger());
62+
$processor = new AgentProcessor($toolbox);
63+
$agent = new Agent($platform, $model, [$processor], [$processor], logger());
64+
65+
$messages = new MessageBag(
66+
Message::forSystem('Please answer all user questions only using SimilaritySearch function.'),
67+
Message::ofUser('Which movie fits the theme of technology?')
68+
);
69+
$result = $agent->call($messages);
70+
71+
echo $result->getContent().\PHP_EOL;

src/ai-bundle/config/options.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,19 @@
271271
->end()
272272
->end()
273273
->end()
274+
->arrayNode('typesense')
275+
->normalizeKeys(false)
276+
->useAttributeAsKey('name')
277+
->arrayPrototype()
278+
->children()
279+
->scalarNode('endpoint')->cannotBeEmpty()->end()
280+
->scalarNode('api_key')->isRequired()->end()
281+
->scalarNode('collection')->isRequired()->end()
282+
->scalarNode('vector_field')->end()
283+
->scalarNode('dimensions')->end()
284+
->end()
285+
->end()
286+
->end()
274287
->end()
275288
->end()
276289
->arrayNode('indexer')

src/ai-bundle/src/AiBundle.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
use Symfony\AI\Store\Bridge\Pinecone\Store as PineconeStore;
4747
use Symfony\AI\Store\Bridge\Qdrant\Store as QdrantStore;
4848
use Symfony\AI\Store\Bridge\SurrealDb\Store as SurrealDbStore;
49+
use Symfony\AI\Store\Bridge\Typesense\Store as TypesenseStore;
4950
use Symfony\AI\Store\Document\Vectorizer;
5051
use Symfony\AI\Store\Indexer;
5152
use Symfony\AI\Store\InMemoryStore;
@@ -678,6 +679,32 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde
678679
$container->setDefinition('ai.store.'.$type.'.'.$name, $definition);
679680
}
680681
}
682+
683+
if ('typesense' === $type) {
684+
foreach ($stores as $name => $store) {
685+
$arguments = [
686+
new Reference('http_client'),
687+
$store['endpoint'],
688+
$store['api_key'],
689+
$store['collection'],
690+
];
691+
692+
if (\array_key_exists('vector_field', $store)) {
693+
$arguments[4] = $store['vector_field'];
694+
}
695+
696+
if (\array_key_exists('dimensions', $store)) {
697+
$arguments[5] = $store['dimensions'];
698+
}
699+
700+
$definition = new Definition(TypesenseStore::class);
701+
$definition
702+
->addTag('ai.store')
703+
->setArguments($arguments);
704+
705+
$container->setDefinition('ai.store.'.$type.'.'.$name, $definition);
706+
}
707+
}
681708
}
682709

683710
/**

src/ai-bundle/tests/DependencyInjection/AiBundleTest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,15 @@ private function getFullConfig(): array
274274
'namespaced_user' => true,
275275
],
276276
],
277+
'typesense' => [
278+
'my_typesense_store' => [
279+
'endpoint' => 'http://localhost:8108',
280+
'api_key' => 'foo',
281+
'collection' => 'my_collection',
282+
'vector_field' => 'vector',
283+
'dimensions' => 768,
284+
],
285+
],
277286
],
278287
'indexer' => [
279288
'my_text_indexer' => [

src/store/CHANGELOG.md

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

src/store/doc/index.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@ You can find more advanced usage in combination with an Agent using the store fo
3939

4040
* `Similarity Search with MariaDB (RAG)`_
4141
* `Similarity Search with Meilisearch (RAG)`_
42+
* `Similarity Search with memory storage (RAG)`_
4243
* `Similarity Search with MongoDB (RAG)`_
4344
* `Similarity Search with Neo4j (RAG)`_
4445
* `Similarity Search with Pinecone (RAG)`_
4546
* `Similarity Search with Qdrant (RAG)`_
4647
* `Similarity Search with SurrealDB (RAG)`_
47-
* `Similarity Search with memory storage (RAG)`_
48+
* `Similarity Search with Typesense (RAG)`_
4849

4950
Supported Stores
5051
----------------
@@ -60,6 +61,7 @@ Supported Stores
6061
* `Postgres`_ (requires `ext-pdo`)
6162
* `Qdrant`_
6263
* `SurrealDB`_
64+
* `Typesense`_
6365

6466
.. note::
6567

@@ -101,6 +103,7 @@ This leads to a store implementing two methods::
101103
.. _`Similarity Search with memory storage (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/in-memory.php
102104
.. _`Similarity Search with Qdrant (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/qdrant.php
103105
.. _`Similarity Search with Neo4j (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/neo4j.php
106+
.. _`Similarity Search with Typesense (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/typesense.php
104107
.. _`Azure AI Search`: https://azure.microsoft.com/products/ai-services/ai-search
105108
.. _`Chroma`: https://www.trychroma.com/
106109
.. _`MariaDB`: https://mariadb.org/projects/mariadb-vector/
@@ -112,4 +115,5 @@ This leads to a store implementing two methods::
112115
.. _`InMemory`: https://www.php.net/manual/en/language.types.array.php
113116
.. _`Qdrant`: https://qdrant.tech/
114117
.. _`Neo4j`: https://neo4j.com/
118+
.. _`Typesense`: https://typesense.org/
115119
.. _`GitHub`: https://github.com/symfony/ai/issues/16
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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\Typesense;
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 $apiKey,
33+
#[\SensitiveParameter] private string $collection,
34+
private string $vectorFieldName = '_vectors',
35+
private int $embeddingsDimension = 1536,
36+
) {
37+
}
38+
39+
public function add(VectorDocument ...$documents): void
40+
{
41+
foreach ($documents as $document) {
42+
$this->request('POST', \sprintf('collections/%s/documents', $this->collection), $this->convertToIndexableArray($document));
43+
}
44+
}
45+
46+
public function query(Vector $vector, array $options = []): array
47+
{
48+
$documents = $this->request('POST', 'multi_search', [
49+
'searches' => [
50+
[
51+
'collection' => $this->collection,
52+
'q' => '*',
53+
'vector_query' => \sprintf('%s:([%s], k:%d)', $this->vectorFieldName, implode(', ', $vector->getData()), $options['k'] ?? 10),
54+
],
55+
],
56+
]);
57+
58+
return array_map($this->convertToVectorDocument(...), $documents['results'][0]['hits']);
59+
}
60+
61+
public function initialize(array $options = []): void
62+
{
63+
if ([] !== $options) {
64+
throw new InvalidArgumentException('No supported options.');
65+
}
66+
67+
$this->request('POST', 'collections', [
68+
'name' => $this->collection,
69+
'fields' => [
70+
[
71+
'name' => 'id',
72+
'type' => 'string',
73+
],
74+
[
75+
'name' => $this->vectorFieldName,
76+
'type' => 'float[]',
77+
'num_dim' => $this->embeddingsDimension,
78+
],
79+
[
80+
'name' => 'metadata',
81+
'type' => 'string',
82+
],
83+
],
84+
]);
85+
}
86+
87+
/**
88+
* @param array<string, mixed> $payload
89+
*
90+
* @return array<string, mixed>
91+
*/
92+
private function request(string $method, string $endpoint, array $payload): array
93+
{
94+
$url = \sprintf('%s/%s', $this->endpointUrl, $endpoint);
95+
$result = $this->httpClient->request($method, $url, [
96+
'headers' => [
97+
'X-TYPESENSE-API-KEY' => $this->apiKey,
98+
],
99+
'json' => $payload,
100+
]);
101+
102+
return $result->toArray();
103+
}
104+
105+
/**
106+
* @return array<string, mixed>
107+
*/
108+
private function convertToIndexableArray(VectorDocument $document): array
109+
{
110+
return [
111+
'id' => $document->id->toRfc4122(),
112+
$this->vectorFieldName => $document->vector->getData(),
113+
'metadata' => json_encode($document->metadata->getArrayCopy()),
114+
];
115+
}
116+
117+
/**
118+
* @param array<string, mixed> $data
119+
*/
120+
private function convertToVectorDocument(array $data): VectorDocument
121+
{
122+
$document = $data['document'] ?? throw new InvalidArgumentException('Missing "document" field in the document data.');
123+
124+
$id = $document['id'] ?? throw new InvalidArgumentException('Missing "id" field in the document data.');
125+
126+
$vector = !\array_key_exists($this->vectorFieldName, $document) || null === $document[$this->vectorFieldName]
127+
? new NullVector() : new Vector($document[$this->vectorFieldName]);
128+
129+
$score = $data['vector_distance'] ?? null;
130+
131+
return new VectorDocument(Uuid::fromString($id), $vector, new Metadata(json_decode($document['metadata'], true)), $score);
132+
}
133+
}

0 commit comments

Comments
 (0)