Most vector search tutorials follow a basic pattern: install an extension, store a float array, and use a standard where-clause. In production multi-tenant RAG systems, this approach is risky. If application logic fails, data leaks occur. This pgvector laravel setup guide focuses on hard isolation using PostgreSQL Row Level Security (RLS) to ensure the database engine itself prevents "ghost context" leaks.
We will utilize the Laravel 13 AI SDK to handle embedding generation while offloading security to the Postgres engine. By implementing RLS on vector columns, you ensure that even if a query scope is missed, the vector engine cannot see data belonging to another tenant. This setup requires PostgreSQL 16 or later with the pgvector extension enabled.
Checking the Prerequisites
A multi-tenant architecture requires a stable foundation. Ensure your environment can handle the performance demands of joining vector distances with RLS filters.
The Postgres Floor
You need PostgreSQL 15 or higher. Version 15+ includes vital planner improvements for high-scale RLS performance. If using Docker, use postgres:15-alpine or newer. Enable the extension in your database console:
CREATE EXTENSION IF NOT EXISTS vector;
This enables the vector data type necessary for storing high-dimensional embeddings.
The SDK Payload
Laravel 13 introduces native AI support, removing the need for third-party wrappers. This SDK manages communication with embedding providers like OpenAI or Mistral. Install the package and scaffold the configuration:
composer require laravel/ai php artisan ai:install
Configure your API keys in config/ai.php. This integrates embedding generation into the framework lifecycle, simplifying the pipeline from Eloquent models to the vector store.
Drafting the Semantic Schema
Migrations serve as your source of truth. The schema must define physical boundaries to prevent data leakage at the database level.
The Blueprint Helper
Include Schema::ensureVectorExtensionExists(); at the top of your up() method. This prevents CI/CD failures on fresh Postgres instances by ensuring the vector extension is loaded before creating vector columns.
The Tenant Anchor
Add a $table->uuid('tenant_id')->index(); to your table. This column acts as the anchor for Row Level Security, allowing Postgres to silo data at the engine level. For OpenAI’s text-embedding-3-small, define a vector column with 1536 dimensions.
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::ensureVectorExtensionExists();
Schema::create('knowledge_base', function (Blueprint $table) {
$table->id();
$table->uuid('tenant_id')->index();
$table->text('content');
$table->vector('embedding', 1536);
$table->timestamps();
});
}
};
Run php artisan migrate to establish the structure. The embedding column should be explicitly typed as a vector in your database.
Forging the Security Policy
Application-level filtering is prone to human error. To prevent one tenant from accessing another's semantic data, move the gatekeeping logic into the database engine via RLS.
Activating RLS
Postgres tables are open by default. You must enable RLS to make the table invisible to queries unless a specific policy allows it. Run this via a DB::statement:
DB::statement('ALTER TABLE knowledge_base ENABLE ROW LEVEL SECURITY;');
Once enabled, standard queries will return zero results until a policy is defined and the tenant context is provided.
Defining the Boundary
Create a policy using the FOR ALL shorthand to cover all CRUD operations. We will link access to a custom Postgres session variable: app.current_tenant_id.
DB::statement("
CREATE POLICY tenant_isolation_policy ON knowledge_base
FOR ALL
TO public
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
");
The Postgres engine will now silently discard rows that do not match the session's tenant_id, regardless of the application-level query structure.
The Magic Middleware Bridge
To make RLS functional, Laravel must pass the tenant identity to the Postgres session for every request.
The Database Session Hook
Generate middleware: php artisan make:middleware SetTenantContext. This class will set the app.current_tenant_id variable in the Postgres session, which persists for the duration of the connection.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
$tenantId = $request->user()?->tenant_id;
if (!$tenantId) {
abort(403, 'Tenant context is required.');
}
DB::statement("SET app.current_tenant_id = ?", [$tenantId]);
return $next($request);
}
}
Register this middleware in bootstrap/app.php. By wrapping your search routes in this middleware, you ensure the database acts as the final firewall against cross-tenant data exposure.
Ingesting Data with the AI SDK
With RLS active, the database will reject any row insertion that lacks an authenticated tenant_id matching the session.
Generating the Vector
The AI SDK abstracts provider communication. You can generate a numeric array from text content with a single call:
$text = "The quick brown fox jumps over the lazy dog"; $vector = AI::embeddings()->create($text);
The AI SDK abstracts provider communication. You can generate a numeric array from text content with a single call:
Eloquent Integration
Laravel 13 automatically handles the translation of PHP arrays into the PostgreSQL vector format (e.g., [0.1, 0.2, ...]).
$document = Document::create([
'tenant_id' => auth()->user()->tenant_id,
'content' => $text,
'embedding' => $vector,
]);
Postgres validates this insert against the app.current_tenant_id. If the IDs do not match, the database throws a violation error, enforcing hard isolation.
Querying Without the 'where' Clause
RLS removes the need to manually chain tenant IDs to every query. Your Eloquent calls remain clean and secure.
The Power of whereVectorSimilarTo
Use the whereVectorSimilarTo method from the AI SDK for semantic search. The results are automatically scoped by the database engine.
$queryVector = AI::embeddings()->generate("How do I reset my password?");
$results = Document::query()
->whereVectorSimilarTo('embedding', $queryVector)
->limit(5)
->get();
return $results;
This architecture ensures that even the most mathematically similar vector will be ignored if it belongs to a different tenant_id.
Verifying Isolation
Test the safety net by attempting to query data while logged in as different tenants. A "closer" vector belonging to Tenant B should never appear in a search performed by Tenant A. Manually changing the session variable in a DB tool will verify that the query results shift according to the session context.
The HNSW Speed Boost
Linear scans cause latency as datasets grow. Approximate Nearest Neighbor (ANN) search via HNSW (Hierarchical Navigable Small Worlds) provides a significant performance boost.
Indexing for Latency
HNSW is preferred over IVFFlat because it builds incrementally and does not require a training phase. Implement the index via migration:
public function up(): void
{
DB::statement('CREATE INDEX ON knowledge_base USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);');
}
Use vector_cosine_ops for cosine similarity (common with OpenAI) or L2 operators for Euclidean distance. Use EXPLAIN ANALYZE to confirm that the query planner utilizes the index rather than a sequential scan.
Troubleshooting and Gotchas
Production environments introduce specific challenges for RLS and pgvector.
The Persistent Connection Trap
Environments like Laravel Octane or FrankenPHP keep database connections alive. A session variable set for one request may persist to the next. Use SET LOCAL within a transaction to ensure the variable clears after the lifecycle.
DB::transaction(function () use ($tenantId, $vector) {
DB::statement("SET LOCAL app.current_tenant_id = ?", [$tenantId]);
return Document::query()
->whereVectorSimilarTo('embedding', $vector)
->get();
});
The Dimension Wall
PostgreSQL strictly enforces vector dimensions. Switching models (e.g., from 1536 to 768 dimensions) will cause a QueryException. Always wrap ingestion logic in try-catch blocks to handle dimension mismatches.
Superuser Immunity
Database superusers and table owners bypass RLS. If your application connects as the postgres user, RLS is ignored. Always use a dedicated application user with restricted permissions. Verify with:
-- Log in as app user SELECT * FROM knowledge_base; -- Should only return rows for the current session tenant
Will Your Data Stay Put?
By shifting tenant isolation from application logic to PostgreSQL RLS, you create a "fail-closed" system. The Laravel 13 AI SDK enables embeddings to be treated as first-class citizens while the database engine serves as the final arbiter of security. This approach allows developers to focus on retrieval quality and features without the constant risk of cross-tenant data leaks.
Frequently Asked Questions
Why use Row Level Security (RLS) instead of application-level filtering for vector search?
Application-level logic is prone to human error, such as missing a query scope. By implementing RLS at the database engine level, PostgreSQL itself acts as a firewall. It ensures that even if an application query is poorly formed, the database will never return vectors belonging to a different tenant, providing a fail-closed security model for sensitive RAG systems.
Which indexing method is recommended for high-performance pgvector searches in Laravel?
HNSW (Hierarchical Navigable Small Worlds) is the preferred indexing strategy over IVFFlat for most Laravel applications. Unlike IVFFlat, HNSW does not require a separate training phase and builds the index incrementally as data is inserted. When configured with cosine similarity operators, it significantly reduces latency for large-scale semantic searches while maintaining high retrieval accuracy.
Related Articles
Discussion
Leave a comment
Comments are moderated before appearing.
No comments yet — be the first to share your thoughts.