Skip to content

Turbopuffer Engine + Semantic/Hybrid Search with Database Support - #1007

Merged
taylorotwell merged 11 commits into
11.xfrom
tpuf
Aug 20, 2026
Merged

Turbopuffer Engine + Semantic/Hybrid Search with Database Support#1007
taylorotwell merged 11 commits into
11.xfrom
tpuf

Conversation

@taylorotwell

@taylorotwell taylorotwell commented Aug 6, 2026

Copy link
Copy Markdown
Member

This PR adds an initial Turbopuffer engine to Laravel Scout.

The engine supports synchronizing Eloquent models to Turbopuffer namespaces and searching them through Scout’s existing builder API. It uses Laravel’s HTTP client directly, so no additional PHP SDK dependency is required.

Features

  • Batched model upserts and deletes
  • Automatic namespace creation on first write
  • Namespace deletion through scout:flush and scout:delete-index
  • Weighted BM25 search across one or more attributes
  • Turbopuffer distance metadata exposed as _turbopuffer_dist
  • Bounded pagination with filtered candidate counts

Configuration

Set the Scout driver and Turbopuffer credentials:

SCOUT_DRIVER=turbopuffer
TURBOPUFFER_API_KEY=tpuf_...
TURBOPUFFER_REGION=gcp-us-central1

Configure each searchable model in config/scout.php:

use App\Models\Document;

'turbopuffer' => [
    // ...

    'model-settings' => [
        Document::class => [
            'searchable-attributes' => [
                'title' => 3,
                'content' => 1,
            ],
            'schema' => [
                'user_id' => [
                    'type' => 'uint',
                ],
                'title' => [
                    'type' => 'string',
                    'full_text_search' => true,
                ],
                'content' => [
                    'type' => 'string',
                    'full_text_search' => true,
                ],
            ],
        ],
    ],
],

The numeric searchable attribute values are relative BM25 weights. In this example, title matches contribute three times their normal BM25 score.

Searching

Basic weighted full-text search:

$documents = Document::search('ocean climate')->get();

Search with a Turbopuffer filter:

$documents = Document::search('ocean climate')
    ->where('user_id', 1)
    ->take(20)
    ->get();

Multiple filters are combined using Turbopuffer’s And expression:

$documents = Document::search('database migration')
    ->where('user_id', 1)
    ->where('published_at', '>=', '2026-01-01')
    ->whereIn('status', ['published', 'featured'])
    ->get();

Pagination

Turbopuffer does not provide offset pagination for ranked searches. To support Scout pagination, the engine requests the first page * perPage results and slices the requested page locally.

A separate filtered count aggregation supplies Scout’s required total. For BM25 and vector searches, this represents the filtered candidate corpus rather than an exact count of semantically relevant matches.

Result windows beyond Turbopuffer’s 10,000-record limit are rejected.

@pushpak1300

Copy link
Copy Markdown
Member

HYB

@taylorotwell

taylorotwell commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

This PR now includes first-class semantic and hybrid search support for the Turbopuffer engine.

Semantic Search

$documents = Document::search('software that stays online when computers fail')
    ->semantic()
    ->where('user_id', 1)
    ->get();

Scout generates a query embedding through the optional laravel/ai package and translates the search into a Turbopuffer ANN query.

Hybrid Search

$documents = Document::search('zero downtime database migration')
    ->hybrid()
    ->where('user_id', 1)
    ->get();

Hybrid searches send BM25 and ANN subqueries to Turbopuffer in one request. Turbopuffer combines their rankings server-side using reciprocal rank fusion.

Custom RRF weights are also supported:

$documents = Document::search('zero downtime database migration')
    ->hybrid(textWeight: 2, semanticWeight: 1)
    ->get();

Configuration

Models configure the vector attribute and dimensions alongside their existing schema:

Document::class => [
    'searchable-attributes' => [
        'title' => 3,
        'content' => 1,
    ],

    'embedding' => [
        'attribute' => 'embedding',
        'dimensions' => 1536,
    ],

    'schema' => [
        'title' => [
            'type' => 'string',
            'full_text_search' => true,
        ],
        'content' => [
            'type' => 'string',
            'full_text_search' => true,
        ],
        'embedding' => [
            'type' => '[1536]f32',
            'ann' => true,
        ],
    ],
],

The model defines the text used to generate its embedding:

public function toSearchableEmbedding(): string
{
    return $this->title."\n\n".$this->content;
}

Document embeddings are generated in batches of 100 during normal Scout indexing. Laravel AI’s configured default embedding provider and model are used automatically. If laravel/ai is unavailable, Scout throws an actionable exception when embedding generation is attempted.

@taylorotwell taylorotwell changed the title Turbopuffer Engine Aug 7, 2026
@taylorotwell

Copy link
Copy Markdown
Member Author

Follow-up: Database Engine Semantic and Hybrid Search

This PR now also adds semantic and hybrid search support to Scout's database engine when using Laravel 13, PostgreSQL, and pgvector.

This makes it possible to keep embeddings in the application's own database and use the same Scout APIs introduced for Turbopuffer:

$articles = Article::search('keeping homes comfortable without electricity')
    ->semantic(minSimilarity: 0.6)
    ->where('user_id', 1)
    ->get();
$articles = Article::search('renewable energy storage')
    ->hybrid(
        textWeight: 1,
        semanticWeight: 2,
        minSimilarity: 0.6,
    )
    ->where('user_id', 1)
    ->get();

Schema

The model needs a nullable PostgreSQL vector column. It should be nullable because Scout generates and stores the embedding after the model itself has been persisted.

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

Schema::ensureVectorExtensionExists();

Schema::create('articles', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->string('title');
    $table->text('body');
    $table->vector('embedding', dimensions: 1536)->nullable();
    $table->timestamps();

    $table->vectorIndex('embedding');
    $table->fullText(['title', 'body']);
});

The vector index uses PostgreSQL's HNSW index with cosine distance. Null embeddings are not included in the vector index and become searchable automatically after Scout stores a generated vector.

Searchable Model

The text used to generate an embedding is defined with toSearchableEmbedding(). It may return either source text or an already-generated embedding array.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Attributes\SearchUsingFullText;
use Laravel\Scout\Searchable;

class Article extends Model
{
    use Searchable;

    #[SearchUsingFullText(['title', 'body'])]
    public function toSearchableArray(): array
    {
        return [
            'title' => $this->title,
            'body' => $this->body,
        ];
    }

    public function toSearchableEmbedding(): string|array
    {
        return $this->title."\n\n".$this->body;
    }
}

Scout stores embeddings in an embedding column by default. Models may override the column name:

public function searchableEmbeddingColumn(): string
{
    return 'search_vector';
}

If an application uses Turbopuffer as its default Scout driver but wants a specific model to use PostgreSQL, the model can select the database engine directly:

public function searchableUsing()
{
    return Scout::engine('database');
}

Embedding Synchronization

When searchable models are created, updated, or imported, the database engine:

  • Processes models in batches of 100.
  • Accepts source text or precomputed embedding arrays from toSearchableEmbedding().
  • Generates text embeddings through the optional Laravel AI SDK.
  • Enables Laravel AI's embedding cache for generated vectors.
  • Updates the vector column directly without firing another model event.

Existing records may be embedded using Scout's normal import command:

php artisan scout:import "App\Models\Article"

When Scout queues synchronization, the vector remains null until the indexing job completes. Models saved while Scout synchronization is disabled also remain unembedded until they are imported or synchronized later.

Semantic Search

Semantic searches generate an embedding for the query and use Laravel's PostgreSQL vector query methods:

  • whereVectorSimilarTo() applies the minimum similarity threshold.
  • orderByVectorDistance() ranks matching records by cosine distance.
  • The model's qualified key is used as a deterministic tie-breaker.
  • Scout filters, query callbacks, and soft-delete constraints are retained.

The default minimum similarity is 0.6 when no value is supplied:

Article::search($query)->semantic();

It may be changed explicitly:

Article::search($query)->semantic(minSimilarity: 0.45);

Hybrid Search

Database hybrid search executes two PostgreSQL queries against the same constrained model query:

  1. The normal database full-text search branch.
  2. The pgvector semantic similarity branch.

Scout then combines the ranked model lists in PHP using weighted reciprocal rank fusion with an RRF constant of 60. Records present in both lists receive contributions from both rankings.

Article::search($query)->hybrid(
    textWeight: 1,
    semanticWeight: 3,
    minSimilarity: 0.5,
);

The database engine uses a stable candidate window of up to 1,000 records per branch. Hybrid pagination ranks that same bounded candidate set for every page, preventing results from shifting as deeper pages are requested. Attempts to paginate beyond the 1,000-record window are rejected.

The reported hybrid total is the number of records in the fused result set, not the total number of rows satisfying only the model filters.

@benesch benesch left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a quick look, and only one minor thing jumped out to me. Seems like a really nice simple API for semantic/hybrid search for a v1. Can always circle back and find a way to add more of the advanced API features down the road!

'consistency' => $builder->options['consistency'] ?? null,
], fn ($value) => ! is_null($value)));

$results['total'] = min((int) ($count['aggregations']['count'] ?? 0), $maximum);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up that certain counts on turbopuffer are not optimized, so this can be quite expensive for some queries.

return $nativeFilters;
}

return ['And', [$nativeFilters, $scoutFilters]];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does Scout have a way to support Or filters?

}

/**
* Generate embeddings using the optional Laravel AI SDK.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One option that might be worth exploring in the future is having a mode where users can have turbopuffer natively handle the embeddings: https://turbopuffer.com/docs/embedding

Can save a bit of trouble for folks who don't want to get a separate account with an embedding provider and plumb in the API key.

@taylorotwell

Copy link
Copy Markdown
Member Author

This PR now supports Turbopuffer's native embedding feature in addition to embeddings generated through Laravel AI.

With native embeddings enabled, Turbopuffer generates both document and query vectors. Applications do not need to install Laravel AI, configure a separate embedding provider, or define toSearchableEmbedding() on the model.

Turbopuffer currently documents native embeddings as a private beta feature, so the Turbopuffer account must have access before enabling this mode.

Configuration

Set the embedding driver to turbopuffer and select the string attribute that Turbopuffer should embed:

use App\Models\Document;

'turbopuffer' => [
    // ...

    'model-settings' => [
        Document::class => [
            'searchable-attributes' => [
                'title' => 3,
                'content' => 1,
            ],

            'embedding' => [
                'driver' => 'turbopuffer',
                'attribute' => 'embedding_text',
            ],

            'schema' => [
                'user_id' => [
                    'type' => 'uint',
                ],
                'title' => [
                    'type' => 'string',
                    'full_text_search' => true,
                ],
                'content' => [
                    'type' => 'string',
                    'full_text_search' => true,
                ],
                'embedding_text' => [
                    'type' => 'string',
                    'embed' => [
                        'model' => 'voyage/voyage-4',
                        'dimensions' => 1024,
                        'attribute' => 'embedding',
                    ],
                ],
            ],
        ],
    ],
],

In this example:

  • embedding_text is the source text sent to Turbopuffer and used for semantic ranking.
  • embedding is the generated vector attribute stored by Turbopuffer.
  • model selects Turbopuffer's managed embedding model.
  • dimensions is Scout's configuration name; Scout translates it to Turbopuffer's dims request field.

The dimensions and generated attribute options may be omitted when the model's Turbopuffer defaults are sufficient:

'embedding_text' => [
    'type' => 'string',
    'embed' => 'voyage/voyage-4',
],

Searchable Model

The selected source attribute must be included in toSearchableArray():

public function toSearchableArray(): array
{
    return [
        'user_id' => $this->user_id,
        'title' => $this->title,
        'content' => $this->content,
        'embedding_text' => $this->title."\n\n".$this->content,
    ];
}

No embedding callback is needed:

// No toSearchableEmbedding() method is required.

During normal Scout synchronization, the engine writes embedding_text to Turbopuffer without a vector. Turbopuffer embeds that text and stores the resulting vector in embedding.

@taylorotwell
taylorotwell merged commit 1b14c19 into 11.x Aug 20, 2026
29 checks passed
@taylorotwell
taylorotwell deleted the tpuf branch August 20, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

4 participants