symfony/ai-typesense-store
Typesense vector store integration for Symfony AI Store, adding a fast, typo-tolerant search backend for storing and querying embeddings. Supports vector search via the Typesense API within your Symfony AI applications.
This is a Symfony AI bridge, not a Laravel package — there is no service provider, no config/typesense.php, and no auto-discovery. You will wire it manually. Treat it as a library you compose, and budget the first hour for reading source rather than docs, because the README is a stub.
Prerequisites you need before touching the package
float[] vector fields):
docker run -d -p 8108:8108 -v /tmp/typesense-data:/data \
typesense/typesense:29.0 --data-dir /data --api-key=xyz --enable-cors
symfony/ai-store installed (this bridge implements its StoreInterface).composer require symfony/ai-typesense-store symfony/ai-store symfony/http-client
Where to look first, in this order
src/ in the repo — specifically the concrete TypesenseStore constructor signature and any StoreFactory class. v0.11 added StoreFactory and "streamlined base URL handling across bridges", so expect a DSN-style config (typesense://host:8108/collection) plus an injected HttpClientInterface, rather than the typesense/typesense-php SDK. Verify this before writing config — the PM pitch doc's new TypesenseStore(new TypesenseClient(...), 'products') snippet is illustrative, not authoritative.StoreInterface in symfony/ai-store — that is your real contract: add(), find(), remove(), clear() (v0.11), count() (v0.14).VectorDocumentInterface (v0.14) — read this second; it is the seam you will build your domain models on.First use case: index five documents, query one
Create the collection yourself, out of band, with the vector field declared explicitly. Dimension must match your embedding model exactly:
curl -X POST http://localhost:8108/collections -H "X-TYPESENSE-API-KEY: xyz" -H "Content-Type: application/json" -d '{
"name": "products",
"fields": [
{"name": "title", "type": "string"},
{"name": "category", "type": "string", "facet": true},
{"name": "price", "type": "float", "facet": true},
{"name": "embedding", "type": "float[]", "num_dim": 1536, "vec_dist": "cosine"}
]
}'
Then bind the store as a singleton in a service provider so you get one client per process:
// app/Providers/AiServiceProvider.php
$this->app->singleton(StoreInterface::class, fn () => (new TypesenseStoreFactory(
$this->app->make(HttpClientInterface::class),
))->create('typesense://' . config('ai.typesense.host') . ':8108/products?api_key=' . config('ai.typesense.key')));
Confirm the wiring with a single insert and a single nearest-neighbour query before you build anything else — if dimension or schema is wrong, this is where it fails loudly, not three layers up in a chat controller.
The ingestion pipeline is a three-stage job, not a store call. Chunk → embed (batch) → add(). The store is only stage three. Put stages one and two in a queued job so a re-index of 40k documents doesn't run in a request:
class IndexProductChunk implements ShouldQueue
{
public function handle(StoreInterface $store, EmbedderInterface $embedder): void
{
$vector = $embedder->embed($this->chunk->text); // stage 2
$store->add(new ProductDocument( // stage 3
id: $this->chunk->id, // stable, deterministic id
vector: $vector,
metadata: ['category' => $this->chunk->category, 'price' => $this->chunk->price],
));
}
}
Use deterministic IDs ("product-{$id}-chunk-{$n}"). Re-running ingestion then becomes idempotent instead of duplicating the corpus — this matters enormously when you change chunking strategy and re-index.
Model your records as VectorDocumentInterface implementations, not arrays. This is the single highest-value habit with v0.14. A readonly DTO gives you static analysis, IDE completion, and — critically — a seam for swapping to pgvector later without touching controllers:
final readonly class ProductDocument implements VectorDocumentInterface
{
public function __construct(
public string $id,
public array $vector,
public array $metadata,
) {}
}
Always embed the query at query time with the same model used at index time. The most common production bug in this class of package is a silent model swap on one side only. Enforce it by keeping model name in config adjacent to the collection name, and stamping the model name into collection metadata so a mismatch is detectable.
Filters are your precision lever; vectors are your recall lever. v0.4 added a query abstraction with filter support. Declare filterable fields in the Typesense schema (facet: true for faceting, correct types for range filters) and compose semantic similarity with hard constraints:
$results = $store->find(
$query->setVector($embedder->embed('noise cancelling headphones'))
->setLimit(10)
->setFilter('category:=electronics && price:<500')
);
For RAG, take limit 5–20, feed the metadata text back as context, and keep the store behind your own Retriever interface so your prompt-building code never imports a Typesense type.
Multi-tenancy: choose collection-per-tenant or filter-field-per-tenant, deliberately. Collection-per-tenant gives hard isolation and per-tenant count() for quota enforcement, but multiplies schema management and Typesense collection overhead. A tenant_id filter field is cheaper and works fine up to large tenant counts — but then every single query must carry the filter, so wrap the store in a repository that injects it and never expose the raw store to feature code.
count() and clear() are operational primitives, not request-path ones. Use count() for usage dashboards, "N results available" UI, and per-plan quota checks — but cache it (60s is plenty) or push it to a scheduled command that writes to a metrics table. Use clear() for full re-index jobs (php artisan ai:reindex) where you drop and rebuild a collection. v0.14's count is a corpus-size primitive, not an aggregation engine: it will not give you per-category facets or price histograms. For those, call Typesense's facet_counts via the raw HTTP endpoint.
Testing: two layers. A fake StoreInterface implementation (in-memory array) for feature tests — cheap, no Docker. A separate integration test group that boots a Typesense container and runs the real store, tagged and excluded from the default suite. Anything touching filters or vector ordering must be in the integration group; in-memory fakes lie about Typesense's matching semantics.
Symfony package in a Laravel app — check the constraint graph first. composer require and then read the resolution. The Symfony AI packages carry symfony/* requirements that can collide with the versions Laravel has pinned. If it resolves, you're fine; if it fights, use -W and inspect what moves. Also budget for the fact that you're depending on a framework-coupled library from a non-framework app: no DI autoconfiguration, no config validation, no artisan generators.
Version 0.x with a thin README means the changelog is your documentation. Pin ^0.14 and read every release note before upgrading. The changelog already shows behaviour-bearing changes shipped in patch-sized increments: v0.10 hardened remove() against query injection, v0.11 added clear() and reworked base URL handling, v0.14 introduced VectorDocumentInterface. Assume breaks between minor versions; never use * or dev-main in production.
Pre-0.10 has a known filter-injection fix in remove(). If you inherit a lockfile on an older version, upgrade. More generally: never interpolate user input into a Typesense filter_by string. Whitelist field names against an allowlist and cast/escape values. Filters are a query language, and you are assembling it by hand.
Schema creation is likely your job, not the bridge's. Verify whether the store auto-creates a collection; assume it does not and create it explicitly in a migration-like artisan command. Two consequences: (a) a typo in num_dim fails at first insert, not at boot, so validate at startup with a health check; (b) Typesense rejects documents with fields absent from the schema, so any new metadata key you start emitting requires a schema change (schema updates for adding fields are supported; changing num_dim is not — that requires a new collection and full re-index).
Changing embedding model = new collection + full re-index. There is no in-place migration path. Design for it now: version your collection names (products_v2) and keep the previous collection live behind a config flag so you can A/B or roll back.
count() right after bulk ingest may lag. Typesense is near-real-time, not transactional. In tests that assert on count() after add(), either poll with a short backoff or assert on find() of the specific document by ID. Do not sleep-loop in production code.
Debug by dropping a level, not by adding logging. When a query returns nothing, curl the Typesense endpoint directly (POST /collections/products/documents/search) with the same body. If curl works and the store doesn't, the bug is in filter serialization or the vector payload shape. To see what the bridge actually sends, decorate the injected HttpClientInterface with a logging wrapper — that's the cleanest extension point this package gives you, and it also lets you add timeouts and retry policy centrally.
Extension points worth knowing, in order of usefulness: (1) decorate StoreInterface for caching, logging, and tenant enforcement — zero coupling to Typesense; (2) swap HttpClientInterface for retries/timeouts; (3) custom VectorDocumentInterface classes for typed records; (4) StoreFactory DSN for environment-specific config. Avoid forking the bridge; the API surface is small enough that decoration covers almost everything.
Config caching trap. If you read env() inside the singleton closure, php artisan config:cache will bake in stale values. Read through config() only, and make sure your Typesense key lives in config/ai.php (populated from .env) rather than being read directly from the environment at container-resolution time.
Scope check before you commit. Typesense's sweet spot is sub-100ms vector search over millions of records. If your corpus is under ~10k documents, a pgvector column and an order-by-distance query will be simpler, transactional, and one less service to operate. Adopt this bridge when you specifically want Typesense's typo tolerance, facet counts, and hybrid keyword+vector behaviour alongside the vectors — or when you already run Typesense for keyword search and want one fewer datastore.
How can I help you explore Laravel packages today?