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 Postgres Store Laravel Package

symfony/ai-postgres-store

PostgreSQL pgvector vector store integration for Symfony AI Store, with support for vector and halfvec types, distance operators, indexes, and full-text search.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Confirm the database, not just the package. The bridge is only as good as the extension underneath it. Before touching Composer, verify SELECT * FROM pg_available_extensions WHERE name = 'vector'; on every environment you care about (local, CI container, staging, prod). On managed Postgres (RDS, Cloud SQL, Azure) the extension may be available but not enabled, and on some tiers it is simply not offered. Add CREATE EXTENSION IF NOT EXISTS vector; to a migration or a provisioning step, and add a health-check endpoint that runs it idempotently.

2. Install and pin.

composer require symfony/ai-postgres-store:^0.14

This package is on the 0.x line, has a very small install base, and shipped breaking-ish surface changes as recently as v0.14 (VectorDocumentInterface, count()). Pin the exact minor (0.14.*) and treat upgrades as deliberate work, not composer update noise.

3. Read the source before the docs. The README is a signpost, not a manual — it defers entirely to the pgvector and PostgreSQL full-text docs. The real API surface lives in vendor/symfony/ai-postgres-store/src/: start with StoreFactory (added v0.9.0 — it is the intended entry point), then the store class it builds, then the bridge's test suite, which is the most reliable place to learn the canonical filter array shape and option keys.

4. Wire it into the container once, behind an interface. Don't let Symfony types leak into controllers. Bind in a service provider:

// app/Providers/VectorStoreServiceProvider.php
$this->app->singleton(\Symfony\AI\Store\StoreInterface::class, function ($app) {
    return StoreFactory::create(
        $app['db']->connection('pgvector'),
        ['table' => 'documents', 'vector_column' => 'embedding'],
    );
});

Declare a dedicated pgvector connection in config/database.php so you can tune it separately (statement timeout, pool size, hnsw.ef_search).

5. Schema first, then a throwaway first use case. Eloquent has no vector column type, so write the table by hand:

DB::statement('CREATE TABLE documents (
    id uuid PRIMARY KEY,
    content text NOT NULL,
    metadata jsonb NOT NULL DEFAULT \'{}\',
    embedding vector(1536) NOT NULL
)');
DB::statement('CREATE INDEX documents_embedding_hnsw
    ON documents USING hnsw (embedding vector_cosine_ops)');

Then in php artisan tinker or a one-off command: embed 20 records, add() them, and query() with a hard-coded vector. Getting one similarity query returning sensible neighbours is the whole proof of concept — everything after that is pipeline plumbing.

Implementation Patterns

Sync-on-save via a queue job, never inline. Embedding is a network call to an external model; doing it in the request lifecycle turns a 200ms save into a 900ms save and couples uptime to your embeddings provider. The reliable pattern is: model observer dispatches EmbedDocument job → job calls the embeddings model → job calls $store->add(...) → store row updated. Store a content_hash column on the model so the job is idempotent and can skip work when the text hasn't meaningfully changed.

An outbox beats a fire-and-forget observer. If the job is lost, the vector and the source row silently diverge. Either write an embedding_jobs row in the same transaction as the model save and let a scheduled command pick it up, or run a nightly reconciliation command that finds rows where updated_at > embedded_at and re-indexes them. With zero dependents on this package, assume you own your own consistency story.

Keep a thin DocumentIndexer service. One class with index(Model $m), forget(Model $m), reindex(), search(string $q, array $opts). Controllers and console commands call that, never the store. When the 0.x interface shifts again (and it will — clear() landed in 0.11, count() and VectorDocumentInterface in 0.14), you patch one class.

Backfills are chunked commands, not tinker one-liners.

Product::query()->whereNull('embedded_at')->chunkById(200, function ($products) {
    $vectors = $this->embeddings->embedMany($products->pluck('description')->all());
    $this->store->add(...$this->toDocuments($products, $vectors));
    Product::whereIn('id', $products->pluck('id'))->update(['embedded_at' => now()]);
});

Batch the embed call — per-row embedding is where backfill jobs go to die — and give the command a --limit and a progress bar so a re-run after a timeout is cheap.

Map domain models deliberately. v0.14's VectorDocumentInterface is the extension point worth adopting early: implement it on a small DTO or a trait on your Eloquent model so metadata (tenant, category, visibility, price band) is populated consistently instead of ad-hoc per call site. This is the difference between "we have a vector table" and "our vectors know what they're about."

Filters carry multi-tenancy, so make them non-optional. The query abstraction with filter support (v0.4.0) is how you scope results to a tenant or category. Wrap search() so it always injects tenant_id from the authenticated context rather than trusting callers to pass it. This is the single highest-value line of defence in the whole integration.

Hybrid retrieval: run both legs, merge, then rerank. pgvector similarity alone is brittle on keyword-exact queries (SKUs, names, error codes). Do a to_tsvector query and a vector query in the same request, merge with reciprocal rank fusion, and optionally rerank the top 20. This is the package's headline differentiator over a naive embedding table — use it.

Pick the distance operator and the index operator class together, on purpose. <-> L2, <#> inner product, <=> cosine — and the index must be built with the matching vector_l2_ops / vector_ip_ops / vector_cosine_ops. For OpenAI/Cohere-style normalized embeddings, cosine is the default choice. If you're storage-constrained, halfvec roughly halves index size and supports higher dimensionality, at a recall cost you should measure rather than assume.

Use count() and clear() for the operational layer you'd otherwise hand-roll. count() (v0.14) powers pagination totals and admin dashboards ("12,481 documents indexed, 340 missing embeddings"). clear() (v0.11) plus your backfill command gives you a clean, repeatable full reindex — which you will need the first time you change embedding model. Schedule it, don't do it live.

Test against real Postgres. SQLite or a mocked store will pass while the actual query does a sequential scan or throws on a dimension mismatch. Run a pgvector/pgvector:pg16 service container in CI, apply migrations, clear() between tests, and assert on result ordering, not just row counts.

Gotchas and Tips

Dimension mismatch is the classic first-day failure. vector(1536) versus a 768-dim model produces a runtime error deep inside the driver. Put dimensions in config next to the model name so they can only be changed together, and treat a model swap as: new column (or new table) → backfill → verify → drop old. Never ALTER in place on a live index.

Mismatched operator class fails silently. A cosine query against an L2 index doesn't error — it just doesn't use the index. Run EXPLAIN (ANALYZE, BUFFERS) on a representative query and confirm an Index Scan. Small tables will legitimately prefer a seq scan; benchmark with realistic row counts before concluding anything.

ANN + filters can underfill the result set. Postgres applies the metadata filter after the approximate scan, so with a narrow tenant filter and limit => 10 you can get three rows back — or zero. Mitigations, in order of preference: partition or shard by tenant; over-fetch (ask for 100, return 10); or use pgvector's iterative scan support if your extension version has it. Don't discover this in production.

count() on a vector table is a COUNT(*). It's exact, which is nice, and it can be slow on millions of rows, which isn't. Cache it for 60 seconds, or maintain a counter row updated by your indexer. Don't put a live uncached count in a hot paginated endpoint.

Recall is a tuning knob, not a constant. SET LOCAL hnsw.ef_search = 100 (or ivfflat.probes) inside the query transaction trades latency for recall. Expose it as config, benchmark it against your data, and record the chosen value in a comment — otherwise someone will "fix" slow queries by lowering it and quietly degrade results.

Table and column name quoting was buggy before v0.11. If you're pinned below that, use plain lowercase snake_case identifiers and avoid reserved words. If you must use quoted identifiers, upgrade.

Beware schema drift between the store's expectations and your migration. The bridge assumes a specific column layout. Read the store class's constructor and its INSERT/SELECT statements, then make your migration match — including any created_at/updated_at or id-type expectations (uuid vs bigint). Silent add() failures usually trace back to this.

Long-running writes hold connections. A 500-document batch insert into an HNSW-indexed table is not free — every insert updates the graph. Batch in the low hundreds, and if you're doing a full reindex, consider dropping the index, loading, and rebuilding; it's often an order of magnitude faster.

Debugging order of operations: (1) is the extension installed in this database? (2) does EXPLAIN show the index? (3) do the raw vector values round-trip correctly (log the first three floats)? (4) is the filter shape what the bridge expects? Almost every "it returns garbage" report resolves at step 1 or 3.

On maturity: budget for reading and patching. Two stars and zero dependents means you are an early adopter. Read the changelogs before every minor bump, keep the adapter layer thin enough to swap for raw SQL if needed, and send fixes upstream to symfony/ai rather than forking — the package's roadmap lives in that monorepo, not in this bridge's issue queue.

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.
roundly-consulting/kubernetes-api-for-laravel
roundly-consulting/plausible-for-laravel
gvp/gvp-laravel-template
metasyncsite/laravel-metasync-client
roundly-consulting/onboarding-for-laravel
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo