Laravel Daily's

Reranking Search Results in Laravel: Build a Smarter REST API with the AI SDK

Illustration of Laravel semantic search using vector retrieval and AI reranking

Semantic search finds documents with similar meaning. Reranking improves the order of those results.

This two-stage approach works well for documentation, support portals, product catalogs, and internal knowledge bases. A vector database retrieves a broad candidate set. A reranking model then scores each candidate against the user’s exact query.

This tutorial uses Laravel 13 and laravel/ai 0.11. You will build a REST endpoint that:

  1. Generates an embedding for the query.
  2. Retrieves candidate documents with native vector search.
  3. Reranks those candidates with the Laravel AI SDK.
  4. Returns ranked JSON with relevance scores.

Laravel is a productive php web framework for this workflow because the framework, database layer, and AI integrations use familiar PHP APIs.

How two-stage search works

Vector similarity is fast and broad. It is useful for finding related content, even when the query and document use different words.

However, vector similarity does not always produce the best final order. A document can be semantically related without answering the user’s intent precisely.

Reranking adds a second model call:

User query
    ↓
Query embedding
    ↓
Vector similarity search
    ↓
Candidate documents
    ↓
AI reranking
    ↓
Final ranked results

The first stage optimizes for recall. The second stage optimizes for precision.

Laravel developer workflow showing embeddings, vector databases, and ranked REST API results

Install the Laravel AI SDK

Install laravel/ai 0.11 with Composer:

composer require laravel/ai:"0.11.*"

Publish the package configuration:

php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"

Add the credentials for the providers you plan to use:

OPENAI_API_KEY=
COHERE_API_KEY=
JINA_API_KEY=
VOYAGEAI_API_KEY=

The AI SDK supports Cohere, Jina, and VoyageAI for reranking. You can review the current provider matrix in the Laravel AI SDK documentation.

Create a vector-backed document table

Laravel supports native vector columns with PostgreSQL and the pgvector extension. It also supports MariaDB 11.7 and later.

Create a migration for the documents table:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        if (DB::connection()->getDriverName() === 'pgsql') {
            Schema::ensureVectorExtensionExists();
        }

        Schema::create('documents', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('content');
            $table->vector('embedding', dimensions: 1536)->index();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('documents');
    }
};

The dimension count must match your embedding model. This example uses 1,536 dimensions.

The Document model should cast the vector column with AsVector:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\AsVector;
use Illuminate\Database\Eloquent\Model;

class Document extends Model
{
    protected function casts(): array
    {
        return [
            'embedding' => AsVector::class,
        ];
    }
}

Run the migration:

php artisan migrate

For PostgreSQL, make sure pgvector is installed and enabled. For MariaDB, use version 11.7 or later.

Generate document embeddings

Documents and queries must use the same embedding provider, model, and dimensions.

For a single document, use Str::of()->toEmbeddings():

use Illuminate\Support\Str;
use Laravel\Ai\Enums\Lab;

$text = $document->title."\n".$document->content;

$embedding = Str::of($text)->toEmbeddings(
    provider: Lab::OpenAI,
    dimensions: 1536,
    model: 'text-embedding-3-small',
);

$document->update([
    'embedding' => $embedding,
]);

For batch imports, use Embeddings::for() to generate multiple vectors in one operation:

use Laravel\Ai\Embeddings;
use Laravel\Ai\Enums\Lab;

$texts = $documents
    ->map(fn (Document $document) => $document->title."\n".$document->content)
    ->all();

$response = Embeddings::for($texts)
    ->dimensions(1536)
    ->generate(
        provider: Lab::OpenAI,
        model: 'text-embedding-3-small',
    );

$documents->values()->each(
    fn (Document $document, int $index) => $document->update([
        'embedding' => $response->embeddings[$index],
    ])
);

In production, run this work in a queued job. Re-embed a document whenever its searchable content changes.

Retrieve candidates with native vector search

The AI SDK provides whereVectorSimilarTo() for vector similarity queries.

Generate an embedding for the incoming query:

$queryEmbedding = Str::of($query)->toEmbeddings(
    provider: Lab::OpenAI,
    dimensions: 1536,
    model: 'text-embedding-3-small',
);

Then retrieve a larger candidate set than you intend to return:

$candidates = Document::query()
    ->whereVectorSimilarTo(
        'embedding',
        $queryEmbedding,
        minSimilarity: 0.35,
    )
    ->limit(50)
    ->get();

The minSimilarity value uses cosine similarity. A higher threshold returns fewer, more similar documents. A lower threshold increases recall.

The candidate limit should usually exceed the final result limit. If the API returns 10 results, retrieving 30 to 100 candidates gives the reranker more useful choices.

You can also pass the query string directly:

$candidates = Document::query()
    ->whereVectorSimilarTo(
        'embedding',
        'How do I configure queued jobs?',
        minSimilarity: 0.35,
    )
    ->limit(50)
    ->get();

Laravel will generate the query embedding automatically. Explicit embeddings give you more control over the provider, model, caching, and dimensions.

Vector search architecture with Laravel, PostgreSQL pgvector, MariaDB, and document embeddings

Rerank candidates with Cohere, Jina, or VoyageAI

The Reranking class accepts an array of document strings. It returns a RerankingResponse containing RankedDocument objects.

Each ranked document includes:

  • index: The original document position.
  • document: The text sent to the provider.
  • score: The provider’s relevance score.

A basic example looks like this:

use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Reranking;

$response = Reranking::of([
    'Laravel is a PHP web framework.',
    'React is a JavaScript library.',
    'Symfony is another PHP framework.',
])
    ->limit(2)
    ->rerank(
        'Which PHP frameworks are suitable for APIs?',
        provider: Lab::Cohere,
    );

$topResult = $response->first();

$topResult->document;
$topResult->score;
$topResult->index;

The SDK supports these reranking providers:

  • Lab::Cohere
  • Lab::Jina
  • Lab::VoyageAI

You can use each provider’s default model or select one explicitly:

$response = Reranking::of($documents)
    ->limit(10)
    ->rerank(
        $query,
        provider: Lab::VoyageAI,
        model: 'rerank-2.5-lite',
    );

Provider model names can change. Keep them in configuration when your application needs stable deployment settings.

Build the REST API endpoint

Create a controller:

php artisan make:controller Api/SearchController

The controller below validates the request, runs vector search, reranks the candidates, and maps the ranked indexes back to Eloquent models.

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Document;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Reranking;

class SearchController extends Controller
{
    public function __invoke(Request $request)
    {
        $validated = $request->validate([
            'query' => ['required', 'string', 'min:2', 'max:500'],
            'limit' => ['sometimes', 'integer', 'min:1', 'max:20'],
            'provider' => [
                'sometimes',
                Rule::in([
                    Lab::Cohere->value,
                    Lab::Jina->value,
                    Lab::VoyageAI->value,
                ]),
            ],
        ]);

        $query = $validated['query'];
        $limit = $validated['limit'] ?? 10;

        $provider = Lab::from(
            $validated['provider'] ?? Lab::Cohere->value
        );

        $queryEmbedding = Str::of($query)->toEmbeddings(
            provider: Lab::OpenAI,
            dimensions: 1536,
            model: 'text-embedding-3-small',
        );

        $candidates = Document::query()
            ->whereVectorSimilarTo(
                'embedding',
                $queryEmbedding,
                minSimilarity: 0.35,
            )
            ->limit(50)
            ->get()
            ->values();

        if ($candidates->isEmpty()) {
            return response()->json([
                'query' => $query,
                'results' => [],
            ]);
        }

        $documents = $candidates
            ->map(fn (Document $document) => [
                'title' => $document->title,
                'content' => $document->content,
            ])
            ->all();

        $reranked = Reranking::of(
            collect($documents)
                ->map(fn (array $document) => json_encode($document))
                ->all()
        )
            ->limit($limit)
            ->rerank($query, provider: $provider);

        $results = collect($reranked->results)
            ->map(function ($ranked) use ($candidates) {
                $document = $candidates->get($ranked->index);

                return [
                    'id' => $document->id,
                    'title' => $document->title,
                    'content' => $document->content,
                    'score' => $ranked->score,
                ];
            })
            ->values();

        return response()->json([
            'query' => $query,
            'provider' => $provider->value,
            'results' => $results,
        ]);
    }
}

Register the route in routes/api.php:

use App\Http\Controllers\Api\SearchController;
use Illuminate\Support\Facades\Route;

Route::get('/search', SearchController::class);

Call the endpoint with:

GET /api/search?query=How%20do%20I%20configure%20queued%20jobs%3F&limit=5&provider=jina

The response contains the final ranking:

{
  "query": "How do I configure queued jobs?",
  "provider": "jina",
  "results": [
    {
      "id": 42,
      "title": "Queue Configuration",
      "content": "Configure queue connections in config/queue.php...",
      "score": 0.96
    }
  ]
}

The index value is important. It points to the original position in the candidate array. That lets you return database identifiers and metadata without sending those fields to the reranking provider.

Use the collection rerank macro

The AI SDK also registers a rerank macro on Laravel collections. It is useful when you only need reordered models.

$ranked = $candidates->rerank(
    by: fn (Document $document) => $document->title."\n".$document->content,
    query: $query,
    limit: $limit,
    provider: Lab::Cohere,
);

You can rerank one field:

$ranked = $candidates->rerank(
    by: 'content',
    query: $query,
    limit: 10,
    provider: Lab::Jina,
);

Or several fields:

$ranked = $candidates->rerank(
    by: ['title', 'content'],
    query: $query,
    limit: 10,
    provider: Lab::VoyageAI,
);

The macro returns the original collection items in their new order. Use Reranking::of() directly when your response needs provider scores.

Practical production considerations

Reranking adds a network request. Keep the candidate set bounded. A set of 30 to 100 documents is a useful starting point.

Cache query embeddings when repeated searches are common. The SDK supports embedding caching through config/ai.php and request-level cache options.

Protect the endpoint with authentication and rate limiting. Search queries may contain sensitive customer or business data.

Log provider, model, candidate count, final count, and latency. These measurements help you tune the similarity threshold and candidate limit. Laravel’s AI SDK events can help you observe AI operations.

Laravel’s AI testing tools also support faking reranking calls:

use Laravel\Ai\Reranking;

Reranking::fake();

Then assert that your endpoint sent the expected query and limit:

Reranking::assertReranked(
    fn ($prompt) => $prompt->query === 'How do I configure queued jobs?'
        && $prompt->limit === 5
);

Illustration of a ranked Laravel API response with relevance scores, validation, and testing tools

Keep retrieval broad and ranking precise

Native vector search gives Laravel applications a fast retrieval layer. The AI SDK adds a consistent interface for reranking through Cohere, Jina, and VoyageAI.

Together, they provide a practical foundation for AI-powered search without introducing a separate search service for every use case.

The next step is combining this pipeline with retrieval-augmented generation. Use vector search and reranking to select trusted context, then pass the highest-quality results to an agent or model for a grounded answer.

Previous
useHttp in Inertia 3.x: Clean Standalone Requests for Your Laravel + Vue SPA
Next
URL-Driven State in Inertia 3.x: Search, Sort, and Paginate Your Laravel + Vue SPA