Turbopuffer Engine + Semantic/Hybrid Search with Database Support - #1007
Conversation
|
HYB |
|
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. |
|
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 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 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:
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:
The default minimum similarity is 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:
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
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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]]; |
There was a problem hiding this comment.
Does Scout have a way to support Or filters?
| } | ||
|
|
||
| /** | ||
| * Generate embeddings using the optional Laravel AI SDK. |
There was a problem hiding this comment.
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.
|
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
ConfigurationSet the embedding driver to 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:
The 'embedding_text' => [
'type' => 'string',
'embed' => 'voyage/voyage-4',
],Searchable ModelThe selected source attribute must be included in 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 |
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
Configuration
Set the Scout driver and Turbopuffer credentials:
Configure each searchable model in
config/scout.php: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:
Search with a Turbopuffer filter:
Multiple filters are combined using Turbopuffer’s And expression:
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.