symfony/ai-surreal-db-store
SurrealDB vector store integration for Symfony AI Store, enabling similarity search over embeddings using SurrealQL vector functions and MTREE/HNSW indexes.
composer require symfony/ai surreal-db-store surreal-db/surrealdb
config/services.php:
'surrealdb' => [
'dsn' => 'http://user:pass@localhost:8000',
'namespace' => 'test',
'database' => 'test',
],
DEFINE INDEX vector_index ON table USING vector HNSW METRIC cosine DIMENSIONS 1536;
// app/Providers/AppServiceProvider.php
public function register()
{
$this->app->bind(\Symfony\AI\Store\VectorStoreInterface::class, function ($app) {
$client = new \Surreal\Client($app['config']['surrealdb.dsn']);
$client->signin(['user', 'pass'], ['namespace', 'database']);
return new \Symfony\AI\Store\SurrealDbStore($client, 'vector_index', 'table');
});
}
$store = app(\Symfony\AI\Store\VectorStoreInterface::class);
$results = $store->find($queryEmbedding, limit: 5);
count() (new in v0.14):
$store->count(); // total documents
$store->count(['type' => 'article']); // filtered count
Use this immediately after wiring the store to confirm the connection, index, and table alignment before debugging vector logic.// Store embeddings
$store->add('doc1', $embeddingArray, ['type' => 'article']);
// Query
$results = $store->find($queryEmbedding, limit: 3, filter: ['type' => 'article']);
// Retrieve metadata
foreach ($results as $result) {
echo $result->getId(); // 'doc1'
echo $result->getMetadata()['type']; // 'article'
}
// Confirm corpus size (v0.14+)
echo $store->count(['type' => 'article']);
CRUD Operations:
add() or update() with metadata.
$store->add('user_123', $userEmbedding, ['role' => 'premium']);
remove() by ID or filter.
$store->remove('user_123'); // By ID
$store->removeWhere(['role' => 'premium']); // Bulk remove
$total = $store->count(); // whole store
$premium = $store->count(['role' => 'premium']); // uses the same filter syntax as find()
INSERT statements (wrap in a transaction).Query Patterns:
$results = $store->find($queryEmbedding, limit: 10);
$results = $store->find($queryEmbedding, filter: [
'category' => 'tech',
'published' => true
]);
// Custom query via SurrealDB client
$client->query('SELECT * FROM table WHERE vector::similarity::cosine(embedding, ?) > 0.8 AND tags CONTAINS ?', [$queryEmbedding, 'ai']);
Vector Documents (new in v0.14):
add()/find() results now flow through VectorDocumentInterface instead of loose arrays + metadata bags. Wrap your domain objects:
final class ProductDocument implements \Symfony\AI\Store\VectorDocumentInterface
{
public function __construct(
private string $id,
private array $embedding,
private array $metadata = [],
) {}
public function getId(): string { return $this->id; }
public function getEmbedding(): array { return $this->embedding; }
public function getMetadata(): array { return $this->metadata; }
}
fromModel(Model $m) factory on the document class so controllers, jobs, and listeners all construct vectors the same way.RAG Pipeline Integration:
// Retrieve context for LLM
$context = $store->find($queryEmbedding, limit: 3);
$prompt = "Answer based on: " . implode("\n", array_map(fn($d) => $d->getContent(), $context));
Service Layer Abstraction:
// app/Services/AI/VectorStoreService.php
class VectorStoreService {
public function __construct(private VectorStoreInterface $store) {}
public function search(string $query, array $filters = []): array {
$embedding = $this->generateEmbedding($query);
return $this->store->find($embedding, filter: $filters);
}
public function corpusSize(array $filters = []): int {
return $this->store->count($filters); // v0.14+
}
}
Event-Driven Updates:
// Listen to model events and update vectors
Model::updated(function ($model) {
$embedding = $this->generateEmbedding($model->content);
$store->update($model->id, $embedding, $model->metadata);
});
Caching Layer:
// Cache results for 5 minutes
$cacheKey = "vector_search:{$queryHash}";
$results = Cache::remember($cacheKey, 300, function () use ($store, $queryEmbedding) {
return $store->find($queryEmbedding);
});
count() is cheap enough to call for cache-key salting or empty-store guards, but remember to bust it when you bulk-import.
Admin/Dashboard Metrics (v0.14):
// Ops page: "12,480 documents indexed, 340 pending re-embed"
return view('admin.ai', [
'indexed' => $store->count(),
'premium' => $store->count(['role' => 'premium']),
]);
Index Management:
DEFINE INDEX idx_name ON table USING vector HNSW METRIC cosine DIMENSIONS 1536;
INFO FOR INDEX idx_name;
Schema Design:
documents with embedding and metadata fields).CREATE TABLE documents;
ALTER TABLE documents ADD COLUMN embedding VECTOR DIMENSIONS 1536;
ALTER TABLE documents ADD COLUMN metadata MAP;
Connection Pooling:
$client = new \Surreal\Client($dsn);
$client->signin(['user', 'pass'], ['namespace', 'database']);
// Reuse $client in multiple store operations
Connection Handling:
signin before queries.
$client->signin(['user', 'pass'], ['namespace', 'database']); // Must call this!
booted event.Vector Dimension Mismatch:
DIMENSIONS in the index must match your embeddings.
-- Wrong: Will cause errors
DEFINE INDEX idx ON table USING vector HNSW METRIC cosine DIMENSIONS 384; -- But embeddings are 1536D
VectorDocumentInterface constructor so bad vectors never reach the store.Filter Syntax:
CONTAINS, IN, etc., not SQL syntax.
// Wrong (SQL-style)
$store->find($embedding, filter: ['category = "tech"']);
// Correct (SurrealQL)
$store->find($embedding, filter: ['category' => 'tech']);
count() — if find() filters work, count() filters will too.count() Semantics (v0.14):
count() reflects what the store sees now — SurrealDB's eventual visibility and any in-flight batch inserts mean a count() immediately after a bulk load can be lower than expected.>=. Also verify count() honours your tenant/scope filters — an unscoped count leaks cross-tenant cardinality into dashboards.count() on a large filtered set still scans; prefer cache-busting over calling it per page-view on high-traffic dashboards.Rate Limiting:
try {
$results = $store->find($embedding);
} catch (\Surreal\Exception\RateLimitException $e) {
// queue a retry with increasing delay
throw $e; // let Laravel's queue backoff handle it
}
Metadata Serialization:
$store->add('id', $embedding, json_encode(['nested' => ['key' => 'value']]));
Query Logging: Wrap the client with a decorator or use Laravel’s logging around store calls:
\Log::debug('SurrealDB find', ['embedding_len' => count($embedding), 'filter' => $filter]);
Log count() alongside writes during backfills so you can watch progress without re-running full queries.
Index Verification: Check if an index exists:
SHOW INDEXES ON table;
Performance Profiling: Compare raw SurrealQL vs. Symfony AI wrapper:
// Benchmark raw SurrealQL
$start = microtime(true);
$client->query('SELECT * FROM table WHERE vector::similarity::cosine(embedding, ?) > 0.8', [$embedding]);
$time = microtime(true) - $start;
Common Errors:
Invalid vector dimension: Mismatch between index and embedding size.Index not found: Verify index name/case sensitivity.Authentication failed: Check signin credentials/namespace.count() returns 0 with data present: Usually a namespace/database mismatch in signin() — the store is querying a different DB than your writes.Custom Distance Metrics: Extend the store to support custom metrics (e.g., dot product):
class CustomSurrealDbStore extends SurrealDbStore {
public function __construct(...) {
parent::__construct(..., 'dot_product');
}
}
Document Types (v0.14):
VectorDocumentInterface is the clean seam for domain modelling — implement it per aggregate (Product, Article, Ticket) and keep embedding generation in a shared trait/action so all document types stay consistent.VectorDocumentInterface and exposes count() — otherwise it will drift from the contract introduced in v0.14.How can I help you explore Laravel packages today?