Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ai Azure Search Store Laravel Package

symfony/ai-azure-search-store

Azure AI Search vector store integration for Symfony AI Store. Index and query embeddings using Azure AI Search, with links to Microsoft docs on vector search overview, creating vector indexes, and running vector queries.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Prerequisites (do these before touching PHP). This package does not create your Azure infrastructure. You need an Azure AI Search service, an API key (or managed-identity setup), and — most importantly — a pre-created index with:

  • an id field (Edm.String, key),
  • a vector field (Collection(Edm.Single)), with dimensions matching your embedding model exactly,
  • every metadata field you intend to filter on marked filterable: true at creation time.

Creating the index via the Azure portal once, by hand, is the fastest way to learn the shape. Then commit the index JSON into your repo and apply it with an Artisan command (see below).

2. Install and pin. Pre-1.0, so pin the minor and read changelogs on bump:

composer require symfony/ai-azure-search-store:0.14.*

Then check what symfony/ai version got pulled in — the store's interfaces (StoreInterface, VectorDocumentInterface) live there, and a store feature release can raise that floor.

3. Where to look first, in order.

  1. The package's src/ directory — it is small. Find the Store class and the config/DTO it expects (endpoint, key, index name, API version). That's your source of truth; the README is genuinely just three Azure links.
  2. symfony/ai's StoreInterface, VectorDocument(Interface), and VectorQuery. Those three types are your entire day-to-day API surface.
  3. The Azure docs linked from the README — specifically create a vector index and create a vector query. You will debug index schema far more often than PHP.

4. Bind it in Laravel. There is no Laravel service provider shipped, so write one:

// app/Providers/AzureSearchServiceProvider.php
public function register(): void
{
    $this->app->singleton(StoreInterface::class, fn () => new AzureSearchStore(
        endpoint: config('services.azure_search.endpoint'),
        apiKey: config('services.azure_search.key'),
        indexName: config('services.azure_search.index'),
    ));
}

Add a thin AzureSearch facade over the binding if you like the ergonomics, but binding to the interface is the part that matters — it's what makes tests and a future store swap cheap.

5. First use case: internal docs search. Seed five chunks with a known embedding, query with a literal string you've already embedded, confirm ordering. Do it in php artisan tinker before writing any feature code. If that round-trip works, everything else is plumbing.

$store->add(new VectorDocument(id: 'doc-1', embedding: $vector, metadata: ['tenant_id' => 'acme', 'category' => 'billing']));

$results = $store->query(new VectorQuery($questionVector, topK: 5));

Implementation Patterns

Keep Eloquent as the source of truth; treat the store as a derived index. Every row in Azure should be reconstructible from your database. Store the model's primary key (prefixed, e.g. article:42) as the document id so you can hydrate results back into models with a single whereIn — and so re-indexing is idempotent upsert rather than duplicate insertion.

One DTO implements VectorDocumentInterface. Make SearchDocument the single translation seam between Eloquent and the store:

final class SearchDocument implements VectorDocumentInterface { /* id, embedding, metadata */ }

// app/Models/Concerns/Indexable.php
public function toSearchDocument(): SearchDocument { ... }

This is the piece that protects you from pre-1.0 churn and from Azure lock-in: if you later move to pgvector, you rewrite this class, not your call sites.

Ingest through queued jobs, never in the request cycle. An Eloquent observer should not call Azure — it should dispatch IndexDocumentJob. Reasons: embedding calls plus Azure round-trips are 200–500ms, the network can fail, and you do not want a search outage to roll back a user's save.

static::saved(fn (Article $a) => IndexDocumentJob::dispatch($a)->onQueue('search'));
static::deleted(fn (Article $a) => RemoveDocumentJob::dispatch($a->searchId())->onQueue('search'));

Batch inside the job (Azure accepts large batches, but keep to a few hundred docs / well under the 16 MB request limit), and set ->onQueue('search') with a dedicated worker so backfills don't starve your default queue.

Embed once, store the vector. If you re-embed the same chunk on every re-index you'll pay twice. Either cache the embedding keyed by a hash of the source text, or store it in a DB column so a metadata-only change doesn't re-embed.

Query pattern: embed → filter → hydrate.

$vector = $embedder->embed($question);                    // cache this by question hash
$hits   = $store->query(
    (new VectorQuery($vector, topK: 20))->withFilter("tenant_id eq 'acme' and category eq 'billing'")
);
$models = Article::whereIn('id', $hits->map(fn ($h) => $h->id))->get()->keyBy('id');

Push tenant_id into the store-level filter, not a post-filter in PHP — otherwise you retrieve 20 hits and possibly discard all 20.

Cache aggressively at the decorator layer. Wrap the bound store in a CachedStore decorator rather than sprinkling Cache::remember through controllers:

  • queries: 1–5 min TTL, keyed by hash of (query vector or question, topK, filter);
  • count(): 5–15 min TTL — it's a billable call, dashboard-only, and eventually consistent anyway.

count() is a cheap win for guardrails. Per-tenant document quotas before ingest, "1,240 results" in UI, ingest reconciliation. Cache it; never call it per page view.

clear() belongs in tests and reindex commands only. There is no "delete by filter" shortcut worth relying on for tenant offboarding in a hurry — if you need it, iterate remove() in a queued job.

Testing. Two options, both cheap: bind a FakeStore implementing StoreInterface in the container for feature tests, or if you want to exercise the real bridge, inject a MockHttpClient (the bridge uses symfony/http-client, and supports ScopedHttpClient as of 0.7) and assert on the request body. Never let the default test env hit a live index.

Gotchas and Tips

Eventual consistency will bite you in tests and in UX. Azure indexes asynchronously. add() returning successfully does not mean query() will find the document. Consequences: (a) assertions like "after publish, search finds it" need a retry loop with a short backoff, not a bare assert; (b) after a user edits a record, their next search may return the stale version. Design the UI accordingly — show the DB record, use search only for discovery.

Index schema is where the real coupling lives. Adding a filterable field means recreating or updating the index and reindexing; you cannot retroactively filter on a non-filterable field, and you cannot change vector dimensions in place. Treat the index definition as versioned code (JSON in resources/azure/, an artisan search:index command that creates/updates it), and plan a full reindex every time you change embedding model.

Dimensions must match the embedding model exactly. A silent mismatch produces confusing 400s. Assert the dimension count in the index-creation command.

Filter syntax is OData, not SQL. field eq 'value', and/or, single quotes for strings, search.in(field, 'a,b') for sets. No LIKE. And every field in the expression must be filterable. Wrap filter construction in one helper class so this syntax stays in one file.

Cosine/squared-L2 only. Azure has no native dot product. If your embedding model recommends dot product, normalize vectors on write and read, or accept cosine — but be consistent, because mixing breaks relevance silently.

Throttling is normal at volume. Azure returns 429 under load. Configure retry with backoff on the Symfony HTTP client, and rate-limit your search queue. For a batch backfill, cap concurrency at 1–2 workers and expect it to take a while.

Pre-1.0 churn is the real risk. v0.14 added VectorDocumentInterface and count() in minor releases; that's the pattern. Pin 0.14.*, read the changelog before bumping, and keep all Azure-specific shapes (metadata keys, filter strings, index field names) behind your own mapper. Half a day per minor release is a realistic budget.

Debugging is cross-system. A failing query is one of: wrong dimension, missing filterable field, wrong filter syntax, throttling, or a genuinely empty index. Log the Azure request id and check the service's own logs/metrics — Symfony AI's exception message won't tell you which field is missing. In Laravel, the Symfony HTTP client isn't in the HTTP-client profiler, so add a small middleware (ScopedHttpClient support exists for this) that logs method, URL, status, and duration.

Latency budget: 100–300 ms per vector query versus tens of ms for a local index. That's fine for search pages, bad for autocomplete-per-keystroke. Debounce, cache, and keep a Redis-backed short TTL in front of anything interactive.

Don't read the store as a source of truth. count() lags, filters are approximate after writes, and stored metadata can drift from your database. Any invariant you care about (billing, ownership, permissions) must be re-checked against Eloquent after hydration — don't let tenant_id in metadata be your only access control.

Extension points worth using now: the StoreInterface binding (swap a fake or a second provider), a VectorDocumentInterface DTO (portability), and a decorator store (caching, metrics, retries). Those three give you ~90% of the insulation you'll want from a 0.14 package with two GitHub stars and no vendor SLA.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin