1. 项目概述:为什么要在Laravel里做语义搜索?

如果你做过电商或者内容平台,肯定遇到过用户抱怨“搜不到东西”。用户输入“适合雨天穿的轻便鞋”,你的数据库里明明有“防水透气运动鞋”,但传统的 LIKE 查询或者基于关键词的全文搜索(比如用Laravel Scout配Algolia)就是匹配不上。这种挫败感是双向的——用户找不到商品,你损失了潜在的订单。

这就是语义搜索要解决的问题。它不关心字面是否匹配,而是理解查询的“意图”和内容的“含义”。背后的核心技术,是把一段文本(无论是商品描述还是用户提问)转换成一串高维度的数字,也就是“向量”或“嵌入”。你可以把它想象成在一个多维空间里给每段话打上一个坐标点。意思相近的文本,比如“跑步鞋”和“运动鞋”,它们的坐标点就会靠得很近,不管它们用的词是不是一样。

以前搞这套需要专门的机器学习团队和复杂的基础设施,但现在,借助成熟的云服务API(比如OpenAI的Embeddings)和已经集成到传统数据库里的向量扩展(比如PostgreSQL的pgvector),我们这些普通的Web开发者也能在熟悉的Laravel框架里,用写业务逻辑的思维,搭建出一个智能的搜索发现引擎。这不仅仅是“搜索”的升级,更是产品“发现”能力的质变,能直接提升用户体验和转化率。

2. 技术栈选型与核心思路拆解

2.1 为什么是这套组合拳?

看到“AI”、“向量”这些词,别慌。我们选的每一个组件,都是为了在“功能强大”和“开发维护成本”之间找到最佳平衡点,确保方案能落地,而不是做个Demo就完事。

  1. Laravel 11 :这是我们的老本行,生态成熟,ORM好用,队列、缓存、任务调度等基础设施开箱即用,能让我们聚焦在搜索逻辑本身,而不是重复造轮子。
  2. OpenAI Embeddings API :自己训练 embedding 模型不现实。OpenAI的 text-embedding-3-small 模型效果足够好,价格极其便宜(每百万token 0.02美元),并且有官方维护的PHP SDK,调用起来就是几行代码的事。这是将“语义”能力引入我们应用的最快路径。
  3. PostgreSQL + pgvector :这是关键决策。我们没选专用的向量数据库(如Pinecone、Weaviate),而是用了PG的扩展。好处太多了:
    • 技术栈统一 :不用维护另一套数据库,利用现有的PostgreSQL连接和备份机制。
    • 事务支持 :向量更新和商品信息更新可以在同一个事务里,保证数据一致性。
    • 无缝结合 :可以轻松实现后面要讲的“混合搜索”,在一个SQL查询里同时进行向量相似度和关键词匹配,这是专用向量库有时比较麻烦的地方。
    • 成熟稳定 :PostgreSQL本身和pgvector扩展都久经考验。
  4. Laravel Scout :虽然原文没深入用,但提它是为了点明架构思路。Scout提供了一个优雅的搜索抽象层。如果你的场景后期需要切换引擎(比如从数据库全文搜索切到Elasticsearch),或者想保持代码一致性,用Scout来封装我们的向量搜索逻辑是一个很专业的选择。不过本文为了直观,会先用裸的Eloquent查询来演示。

2.2 整体工作流设计

整个系统跑起来,就两条主线: 索引构建 查询处理

索引构建(离线/异步)

  1. 当一个新的 Product 被创建或更新时,我们触发一个异步任务。
  2. 这个任务会拼接商品的名称、描述、分类、标签等文本,生成一段代表该商品的“富文本”。
  3. 调用OpenAI Embedding API,将这段文本转换为一个1536维的向量。
  4. 将这个向量存入 products 表的 embedding 字段(一种特殊的向量类型)。
  5. embedding 字段建立HNSW索引,这是为了在查询时能进行快速的“近似最近邻”搜索,而不是慢如蜗牛的全局计算。

查询处理(在线)

  1. 用户输入搜索词,比如“办公室久坐用的舒服椅子”。
  2. 后端同样调用Embedding API,将这个词转换为查询向量。
  3. 在数据库中,执行一个查询: 按 cosine 距离找出和查询向量最接近的N个商品向量
  4. 返回这些商品作为结果。

听起来很简单,对吧?魔鬼都在细节里。接下来我们一步步拆解怎么把它做稳、做好。

3. 环境与数据层搭建实操

3.1 依赖安装与基础配置

首先,在Laravel项目中安装必要的包:

composer require openai-php/laravel
composer require pgvector/pgvector

openai-php/laravel 包提供了Facade、配置文件和方便的注入方式。 pgvector/pgvector 包则提供了Laravel中处理 vector 数据类型的支持,比如在迁移中定义向量字段,以及在Eloquent中方便地使用向量运算符。

接着,在 .env 文件中配置你的OpenAI密钥和想要使用的模型。对于大多数搜索场景, text-embedding-3-small 在效果和成本上是最平衡的。

OPENAI_API_KEY=sk-your-secret-key-here
OPENAI_EMBEDDING_MODEL=text-embedding-3-small

然后发布OpenAI包的配置文件,通常它会自动设置好,但检查一下无妨:

php artisan vendor:publish --provider="OpenAI\Laravel\ServiceProvider"

3.2 数据库迁移:启用pgvector并添加字段

这里有个关键点: pgvector 是一个PostgreSQL扩展,需要先在数据库层面启用它,然后才能使用 vector 数据类型。

创建迁移文件:

php artisan make:migration add_embedding_to_products_table

打开迁移文件,你需要做两件事:

<?php
// database/migrations/xxxx_add_embedding_to_products_table.php

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

return new class extends Migration
{
    public function up(): void
    {
        // 1. 启用pgvector扩展。注意:这通常需要数据库超级用户权限。
        // 在开发环境(如Laravel Sail)或云服务(Supabase, Neon)中,可能已默认启用或可通过UI启用。
        // 如果遇到权限错误,可能需要联系DBA或查看云服务商文档。
        DB::statement('CREATE EXTENSION IF NOT EXISTS vector');

        // 2. 向products表添加向量字段。
        // `text-embedding-3-small`模型生成1536维的向量。
        Schema::table('products', function (Blueprint $table) {
            $table->vector('embedding', 1536)->nullable();
        });

        // 3. 为embedding字段创建HNSW索引以加速相似性搜索。
        // `vector_cosine_ops`表示使用余弦相似度作为距离度量。
        // HNSW是一种近似最近邻索引,在精度和速度之间取得很好平衡,非常适合生产环境。
        DB::statement('CREATE INDEX products_embedding_idx ON products USING hnsw (embedding vector_cosine_ops)');
    }

    public function down(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->dropColumn('embedding');
        });
        // 注意:通常不推荐在回滚中DROP EXTENSION,因为它可能被其他表依赖。
        // DB::statement('DROP EXTENSION IF EXISTS vector');
    }
};

重要提示 CREATE EXTENSION 语句可能需要较高的数据库权限。如果你在使用共享的数据库服务或没有超级用户权限,这一步可能会失败。解决方案是:

  1. 联系你的数据库管理员,让他们在数据库实例上启用 pgvector 扩展。
  2. 如果你使用的是Supabase、Neon、AWS RDS PostgreSQL等云服务,它们通常提供了在控制台一键启用扩展的功能,或者默认已经启用。请查阅对应服务的文档。
  3. 在本地开发环境(如使用Laravel Sail),你可以通过进入PostgreSQL容器内部执行 CREATE EXTENSION vector; 命令。

运行迁移:

php artisan migrate

3.3 模型准备

确保你的 Product 模型( app/Models/Product.php )引入了 HasEmbeddings trait(来自 pgvector/pgvector 包),并定义了哪些字段用于生成嵌入文本。虽然这个trait不是强制的,但它提供了一些有用的作用域方法。

<?php
// app/Models/Product.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use PgVector\Laravel\HasEmbeddings;
use PgVector\Laravel\Vector;

class Product extends Model
{
    use HasEmbeddings; // 提供 `->orderByNearestEmbedding` 等便捷作用域

    // 指定哪些属性应被拼接起来生成嵌入向量
    // 这个定义主要用于给 `HasEmbeddings` trait 的 `embedFrom` 方法使用,我们也可以自定义逻辑。
    protected array $embedFrom = ['name', 'description', 'category'];

    // 如果你的标签是JSON字段或关联关系,可能需要自定义方法
    // ...
}

4. 核心服务层:嵌入生成与存储

4.1 构建嵌入生成服务

我们不能把API调用代码到处写。创建一个专门的服务类,负责处理文本预处理和调用OpenAI。

<?php
// app/Services/EmbeddingService.php

namespace App\Services;

use Illuminate\Support\Facades\Log;
use OpenAI\Laravel\Facades\OpenAI;
use OpenAI\Exceptions\ErrorException;

class EmbeddingService
{
    /**
     * 根据给定文本生成嵌入向量。
     *
     * @param string $text
     * @return array 返回一个浮点数数组(向量)
     * @throws \Exception 当API调用失败时抛出异常
     */
    public function generate(string $text): array
    {
        // 1. 文本预处理
        $preparedText = $this->prepareText($text);

        // 2. 安全检查:确保文本不为空
        if (empty(trim($preparedText))) {
            // 返回一个零向量或抛出异常,取决于你的业务逻辑
            // 对于商品来说,名称和描述至少有一个,所以这里简单返回空数组并记录日志
            Log::warning('Attempted to generate embedding for empty text.');
            return array_fill(0, 1536, 0.0); // 返回一个1536维的零向量
        }

        try {
            // 3. 调用OpenAI Embeddings API
            $response = OpenAI::embeddings()->create([
                'model' => config('openai.embedding_model', 'text-embedding-3-small'),
                'input' => $preparedText,
                // 可选参数:`dimensions`可以降低维度以减少存储和计算成本,但可能影响精度。
                // 'dimensions' => 256,
            ]);

            // 4. 提取并返回向量
            return $response->embeddings[0]->embedding;

        } catch (ErrorException $e) {
            // 处理API错误,例如额度不足、网络问题等
            Log::error('OpenAI Embedding API call failed.', [
                'error' => $e->getMessage(),
                'text_sample' => substr($preparedText, 0, 100)
            ]);
            // 根据业务需求决定:抛出异常、返回空向量或使用降级方案
            throw new \Exception('Embedding generation failed: ' . $e->getMessage(), 0, $e);
        }
    }

    /**
     * 预处理文本:清理、标准化、截断。
     * OpenAI模型有上下文长度限制(如8192个token),超长文本会被截断。
     * 对于商品描述,我们通常不需要极长的上下文。
     *
     * @param string $text
     * @return string
     */
    private function prepareText(string $text): string
    {
        // 合并多余的空白字符(换行、多个空格等)为单个空格
        $normalized = preg_replace('/\s+/', ' ', $text);

        // 去除首尾空格
        $trimmed = trim($normalized);

        // 安全截断。8000字符是一个比较安全的估计,远低于token限制。
        // 更精确的做法是使用tokenizer,但为了简单起见,字符截断在大多数情况下可行。
        return mb_substr($trimmed, 0, 8000);
    }
}

实操心得 prepareText 方法里的截断逻辑很关键。OpenAI的Embedding模型按输入token数收费并有长度限制。对于商品搜索,我们通常不需要完整的、冗长的HTML描述。更好的做法是:在拼接文本时,就只选取最重要的字段(名称、关键描述、分类),并提前清洗掉HTML标签。你也可以考虑为“长描述”生成一个独立的向量,与“短文本”向量结合使用,但这会显著增加复杂度。

4.2 异步任务与模型观察者

生成嵌入向量是一个相对较慢的IO操作(网络请求到OpenAI), 绝对不能在用户发起的同步请求(如创建商品的HTTP请求)中直接进行 。我们必须用队列异步处理。

首先,创建一个任务:

php artisan make:job GenerateProductEmbedding
<?php
// app/Jobs/GenerateProductEmbedding.php

namespace App\Jobs;

use App\Models\Product;
use App\Services\EmbeddingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use PgVector\Laravel\Vector;

class GenerateProductEmbedding implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * 创建新任务实例。
     * 注意:我们只传递Product的ID,而不是整个模型,以避免序列化大对象。
     */
    public function __construct(
        private readonly int $productId
    ) {}

    /**
     * 执行任务。
     */
    public function handle(EmbeddingService $embeddingService): void
    {
        // 1. 重新从数据库查找商品,确保获取最新数据
        $product = Product::find($this->productId);
        if (!$product) {
            // 商品可能已被删除
            $this->fail(new \Exception("Product [{$this->productId}] not found."));
            return;
        }

        // 2. 构建用于生成嵌入的文本
        // 这里比原文例子更健壮:处理可能为空的字段,并包含更多相关属性。
        $textParts = [];
        $textParts[] = $product->name;
        $textParts[] = $product->description;

        // 假设分类是一个字符串字段或关联模型的名字
        if ($product->category) {
            $textParts[] = is_object($product->category) ? $product->category->name : $product->category;
        }

        // 处理标签:可能是逗号分隔的字符串,或JSON数组,或关联集合
        if ($product->tags) {
            if (is_string($product->tags)) {
                $tagsArray = explode(',', $product->tags);
                $textParts[] = implode(' ', array_map('trim', $tagsArray));
            } elseif (is_array($product->tags)) {
                $textParts[] = implode(' ', $product->tags);
            } elseif (method_exists($product->tags, 'pluck')) { // 假设是Eloquent集合
                $textParts[] = $product->tags->pluck('name')->implode(' ');
            }
        }

        // 添加品牌、材质等任何有助于定义商品语义的字段
        if ($product->brand) {
            $textParts[] = $product->brand;
        }
        if ($product->material) {
            $textParts[] = $product->material;
        }

        // 过滤掉空的部分并用空格连接
        $combinedText = implode(' ', array_filter($textParts, fn($part) => !empty(trim($part ?? ''))));

        // 3. 生成嵌入向量
        $embeddingArray = $embeddingService->generate($combinedText);

        // 4. 更新商品记录,使用`updateQuietly`避免再次触发观察者循环
        $product->updateQuietly([
            'embedding' => new Vector($embeddingArray),
        ]);

        // 可选:记录日志或触发事件
        \Log::info("Generated and saved embedding for product [{$product->id}]: {$product->name}");
    }
}

然后,创建一个模型观察者,在商品保存后自动分派任务:

php artisan make:observer ProductObserver --model=Product
<?php
// app/Observers/ProductObserver.php

namespace App\Observers;

use App\Models\Product;
use App\Jobs\GenerateProductEmbedding;

class ProductObserver
{
    /**
     * 处理 Product "saved" 事件。
     * 注意:在"saved"事件中触发,这样无论是创建还是更新都会处理。
     * 但我们需要避免无限循环(任务更新商品后又触发saved)。
     */
    public function saved(Product $product): void
    {
        // 重要:检查是否是嵌入任务触发的更新,避免循环。
        // 一个简单的方法是检查请求中是否有标记,或者检查特定字段是否被修改。
        // 更可靠的方法:使用一个专门的队列,并在任务中使用`updateQuietly`。
        // 这里我们假设任务总是使用`updateQuietly`,所以观察者仍然会被调用,
        // 但我们可以添加一个条件来避免重复分派任务。

        // 条件示例:只有当商品的核心文本字段发生变化时,才重新生成嵌入。
        // 但为了简单和确保数据一致性,我们可以选择每次保存都重新生成(成本考虑)。
        // 更好的做法:检查相关字段是否被“脏”修改。
        if ($product->isDirty(['name', 'description', 'category', 'tags'])) {
            // 使用延迟分发,避免在同一个请求周期内对数据库造成过大压力
            // 给一个简短的延迟,确保数据库事务已提交
            GenerateProductEmbedding::dispatch($product->id)->delay(now()->addSeconds(5));
        }
    }

    /**
     * 处理 Product "deleted" 事件。
     * 如果商品被删除,对应的向量数据也会随行删除(因为它在同一张表),
     * 所以通常不需要额外处理。但如果你的向量存储在外部,这里需要清理。
     */
    public function deleted(Product $product): void
    {
        // 如果需要外部清理,可以在这里分派一个删除任务
    }
}

最后,在 App\Providers\AppServiceProvider boot 方法中注册观察者:

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Product::observe(ProductObserver::class);
}

注意事项 :队列驱动配置。确保你的 .env 中配置了正确的队列驱动(如 redis , database , sqs ),并运行队列处理器: php artisan queue:work 。对于嵌入生成这种可能大量且耗时的任务,强烈建议使用独立的队列,比如 php artisan queue:work --queue=embeddings ,这样不会影响你应用中处理订单、发送邮件等高优先级任务。

5. 搜索查询实现与性能优化

5.1 基础语义搜索控制器

现在,当用户搜索时,我们需要将查询词转换为向量,并在数据库中查找最相似的向量。

<?php
// app/Http/Controllers/Api/ProductSearchController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\EmbeddingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use PgVector\Laravel\Vector;

class ProductSearchController extends Controller
{
    public function __construct(
        private readonly EmbeddingService $embeddingService
    ) {}

    /**
     * 处理语义搜索请求。
     */
    public function __invoke(Request $request): JsonResponse
    {
        $request->validate([
            'q' => 'required|string|min:1|max:500', // 搜索词
            'limit' => 'sometimes|integer|min:1|max:100',
            'threshold' => 'sometimes|numeric|min:0|max:1', // 相似度阈值
        ]);

        $queryText = $request->string('q');
        $limit = $request->input('limit', 20);
        $threshold = $request->input('threshold', 0.0); // 默认不过滤

        // 1. 缓存查询向量!这是控制成本和延迟的关键。
        $cacheKey = 'embedding:query:' . md5($queryText . config('openai.embedding_model'));
        $queryVector = Cache::remember($cacheKey, now()->addHours(24), function () use ($queryText) {
            $embeddingArray = $this->embeddingService->generate($queryText);
            return new Vector($embeddingArray);
        });

        // 2. 执行向量相似度搜索
        // 使用 `<=>` 运算符计算余弦距离。距离越小,相似度越高。
        $productsQuery = Product::query()
            ->whereNotNull('embedding') // 只搜索已生成向量的商品
            ->orderByRaw('embedding <=> ?', [$queryVector]); // 按余弦距离升序排序

        // 3. (可选)应用相似度阈值过滤
        // 余弦距离范围是[0, 2],0表示完全相同,2表示完全相反。
        // 我们通常关心距离小于某个阈值的结果。例如,距离<0.3可能表示强相关。
        if ($threshold > 0) {
            // 注意:这里需要在SELECT子句中计算距离才能用于WHERE过滤。
            // 一种方法是使用子查询,但可能影响性能。另一种方法是先获取更多结果再在应用层过滤。
            // 这里展示一个更直接但可能低效的方法(仅适用于小数据集或阈值筛选后结果集很小的情况):
            // $productsQuery->whereRaw('(embedding <=> ?) < ?', [$queryVector, $threshold]);
            // 对于生产环境,建议先不设阈值,获取稍多的结果(如limit*2),然后在PHP中过滤。
        }

        // 4. 获取结果
        $products = $productsQuery
            ->limit($limit * 2) // 多取一些,为后续混合搜索或应用层过滤留余地
            ->get(['id', 'name', 'slug', 'price', 'category', 'image_url']); // 只选择需要的字段

        // 5. (可选)在应用层计算并附加相似度分数
        $products->each(function ($product) use ($queryVector) {
            // 注意:在数据库排序后,我们可能不需要重新计算距离。
            // 但如果需要将分数返回给前端,可以计算。
            // 由于pgvector的<=>运算符在索引中已使用,这里为了演示计算分数。
            // 实际生产中可以跳过,或让数据库在SELECT中返回距离值。
            // $product->similarity_score = 1 - ($product->embedding->cosineDistance($queryVector) / 2); // 转换为0-1的相似度
        });

        // 6. 返回JSON响应
        return response()->json([
            'query' => $queryText,
            'count' => $products->count(),
            'results' => $products->take($limit)->values(), // 确保最终返回数量不超过limit
        ]);
    }
}

别忘了在 routes/api.php 中添加路由:

use App\Http\Controllers\Api\ProductSearchController;

Route::get('/products/search', ProductSearchController::class);

现在,访问 /api/products/search?q=舒适办公椅 就能得到基于语义的搜索结果了。

5.2 混合搜索:语义 + 关键词的强强联合

纯粹的语义搜索并非万能。考虑以下场景:

  • 用户搜索确切的型号“AIR-MAX-2024-BLK”。关键词匹配应该直接命中。
  • 用户搜索“红色连衣裙”,但你的向量模型可能对颜色这种具体属性不敏感,而传统的文本搜索在“红色”这个词上可以精确匹配。

混合搜索结合了两种方式的优点。一个简单但有效的实现如下:

// 在 ProductSearchController 中新增一个方法,或修改 __invoke 方法
public function hybridSearch(Request $request): JsonResponse
{
    $request->validate([
        'q' => 'required|string|min:1|max:500',
        'limit' => 'sometimes|integer|min:1|max:50',
        'semantic_weight' => 'sometimes|numeric|min:0|max:1', // 语义搜索权重
    ]);

    $queryText = $request->string('q');
    $limit = $request->input('limit', 20);
    $semanticWeight = $request->input('semantic_weight', 0.7); // 默认更偏向语义

    // 1. 获取查询向量(同样需要缓存)
    $cacheKey = 'embedding:query:' . md5($queryText);
    $queryVector = Cache::remember($cacheKey, now()->addHours(24), function () use ($queryText) {
        $embeddingArray = $this->embeddingService->generate($queryText);
        return new Vector($embeddingArray);
    });

    // 2. 并行执行两种搜索(理想情况应使用队列或并行化,这里简化)
    // a) 语义搜索
    $semanticResults = Product::query()
        ->whereNotNull('embedding')
        ->orderByRaw('embedding <=> ?', [$queryVector])
        ->limit($limit * 3) // 获取更多候选,用于融合
        ->get(['id', 'name', 'description', 'price', 'category'])
        ->keyBy('id'); // 用ID作为键方便后续处理

    // b) 关键词全文搜索(使用PostgreSQL的全文搜索功能)
    // 假设我们在products表上有对name和description创建的GIN索引
    $keywordResults = Product::query()
        ->whereFullText(['name', 'description'], $queryText) // Laravel 9+ 的全文搜索作用域
        // 或者使用原生表达式:
        // ->whereRaw("to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, '')) @@ plainto_tsquery('english', ?)", [$queryText])
        ->limit($limit * 3)
        ->get(['id', 'name', 'description', 'price', 'category'])
        ->keyBy('id');

    // 3. 结果融合 - 使用 Reciprocal Rank Fusion (RRF)
    // RRF是一种简单有效的融合方法,它结合了两个结果列表的排名。
    $k = 60; // RRF常数,通常取值在60左右
    $scores = [];

    // 为语义搜索结果打分
    $semanticRank = 1;
    foreach ($semanticResults as $id => $product) {
        $scores[$id] = ($scores[$id] ?? 0) + (1.0 / ($k + $semanticRank));
        $semanticRank++;
    }

    // 为关键词搜索结果打分
    $keywordRank = 1;
    foreach ($keywordResults as $id => $product) {
        $scores[$id] = ($scores[$id] ?? 0) + (1.0 / ($k + $keywordRank));
        $keywordRank++;
    }

    // 4. 按融合分数排序
    arsort($scores); // 按分数降序排列
    $sortedProductIds = array_keys(array_slice($scores, 0, $limit, true));

    // 5. 按最终ID顺序获取完整的商品对象
    $finalProducts = Product::whereIn('id', $sortedProductIds)
        ->get(['id', 'name', 'description', 'price', 'category', 'image_url'])
        ->sortBy(function ($product) use ($sortedProductIds) {
            return array_search($product->id, $sortedProductIds);
        })->values();

    return response()->json([
        'query' => $queryText,
        'strategy' => 'hybrid_rrf',
        'count' => $finalProducts->count(),
        'results' => $finalProducts,
    ]);
}

核心要点 :RRF融合的好处是它不需要对两个不同系统的分数进行标准化(向量距离和全文搜索的相关性分数量纲不同)。它只依赖排名,更鲁棒。对于生产系统,你可能会将两种搜索放在不同的服务或队列任务中并行执行,以降低延迟。

5.3 性能优化与生产级考量

  1. 索引策略 :我们之前创建的 HNSW 索引对于快速近似最近邻搜索至关重要。对于千万级以下的向量,HNSW通常表现良好。如果数据量极大,你可能需要调整HNSW的构建参数( m ef_construction )以在构建速度、查询速度和精度之间权衡。这需要在迁移的 CREATE INDEX 语句中指定。

    CREATE INDEX products_embedding_idx ON products USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
    
  2. 查询缓存 :如前所述,缓存查询向量是必须的。你还可以考虑缓存最终的搜索结果,特别是对于热门搜索词。但要注意商品信息可能会变,所以缓存时间不宜过长,或者需要在商品更新时使相关缓存失效。

  3. 分页 :向量搜索的分页比传统搜索更棘手。 ORDER BY embedding <=> ? OFFSET 20 LIMIT 10 这种写法效率很低,因为数据库需要计算所有行的距离才能排序。一种方案是使用“游标分页”,记住最后一行的距离和ID,下一次查询使用 WHERE (embedding <=> ?) > last_distance OR (embedding <=> ? = last_distance AND id > last_id) 。但这比较复杂。对于大多数发现场景,无限滚动加载前50-100条结果可能就足够了。

  4. 过滤与筛选 :在向量搜索前或后结合属性过滤(如价格范围、分类)是常见需求。 pgvector 支持在 WHERE 子句中结合向量搜索和其他条件,但复杂的过滤可能会影响索引使用。通常建议先进行高效的属性过滤(利用B-tree索引),再在过滤后的结果集上进行向量搜索。

6. 批量索引与运维命令

当你第一次上线这个功能,或者有大量历史商品需要处理时,需要一个批量索引命令。

php artisan make:command IndexProductEmbeddings
<?php
// app/Console/Commands/IndexProductEmbeddings.php

namespace App\Console\Commands;

use App\Jobs\GenerateProductEmbedding;
use App\Models\Product;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Bus;

class IndexProductEmbeddings extends Command
{
    protected $signature = 'products:index-embeddings
                            {--chunk-size=100 : Number of products to process per chunk}
                            {--queue=embeddings : The queue to dispatch jobs to}
                            {--missing-only : Only index products that have no embedding}';

    protected $description = 'Generate and store embeddings for products in batch.';

    public function handle(): int
    {
        $query = Product::query();
        if ($this->option('missing-only')) {
            $query->whereNull('embedding');
            $this->info('Indexing only products without existing embeddings.');
        }

        $total = $query->count();
        if ($total === 0) {
            $this->info('No products to index.');
            return Command::SUCCESS;
        }

        $bar = $this->output->createProgressBar($total);
        $bar->start();

        $chunkSize = (int) $this->option('chunk-size');
        $queueName = $this->option('queue');

        $query->chunkById($chunkSize, function ($products) use ($queueName, $bar) {
            $jobs = [];
            foreach ($products as $product) {
                $jobs[] = new GenerateProductEmbedding($product->id);
            }
            // 批量分发作业,减少开销
            Bus::batch($jobs)->onQueue($queueName)->dispatch();
            $bar->advance($products->count());
        });

        $bar->finish();
        $this->newLine(2);
        $this->info("Successfully dispatched embedding generation jobs for {$total} products to the [{$queueName}] queue.");
        $this->line('Process the queue with: php artisan queue:work --queue=' . $queueName);

        return Command::SUCCESS;
    }
}

运行命令:

# 为所有商品生成嵌入(谨慎使用,可能产生大量API调用和队列任务)
php artisan products:index-embeddings

# 仅为缺少嵌入的商品生成(增量索引)
php artisan products:index-embeddings --missing-only

# 使用更大的块大小和指定队列
php artisan products:index-embeddings --chunk-size=500 --queue=low-priority

运维提醒 :批量索引时,务必注意OpenAI API的速率限制(TPM/RPM)。你需要在 GenerateProductEmbedding 任务中加入重试逻辑和速率控制,或者使用更高级的队列驱动(如Laravel Horizon)来限制并发任务数。一个简单的办法是在任务中使用 sleep ,但更好的方式是使用令牌桶算法或在应用层进行限流。

7. 常见问题、排查与进阶思路

7.1 效果不理想?调试与优化你的嵌入

语义搜索的效果很大程度上取决于你喂给模型的“文本”质量。如果搜索结果看起来不相关,试试以下步骤:

  1. 检查输入文本 :打印出 GenerateProductEmbedding 任务中拼接的 $combinedText 。它是否清晰、包含了商品的所有关键语义信息?有没有被无关的HTML标签、特殊字符或营销废话污染?
  2. 字段权重 :商品名称可能比长描述更重要。你可以尝试在拼接时给名称字段更高的“权重”,简单的方法就是重复多次: $textParts[] = str_repeat($product->name . ' ', 3); 。更精细的做法是在生成向量前对文本进行加权,但这需要更复杂的预处理。
  3. 领域适配 :OpenAI的通用嵌入模型在大多数情况下表现良好,但如果你有非常垂直的领域(如法律条文、医疗术语),通用模型可能无法捕捉细微的语义差别。可以考虑:
    • 微调嵌入模型 :成本高,技术复杂,通常不推荐。
    • 使用领域专用模型 :探索Hugging Face上开源的、在特定领域训练的嵌入模型,并寻找其PHP或REST API的调用方式。
    • 后处理重排 :先用向量搜索召回一批候选结果(比如100个),然后用一个更小、更精准的模型(或基于规则的逻辑)对它们进行重新排序。

7.2 性能与成本监控

  1. API成本 text-embedding-3-small 很便宜,但量大了也要关注。在 EmbeddingService 中记录每次调用的token数量。OpenAI的响应头里通常包含 usage 信息。定期检查账单,并设置预算警报。
  2. 查询延迟 :监控 /search 端点的响应时间。延迟主要来自:
    • 查询向量生成(缓存可解决)。
    • 数据库向量搜索。确保 EXPLAIN ANALYZE 你的查询,确认使用了 HNSW 索引。对于超大规模数据,你可能需要分区或使用专门的向量数据库。
    • 结果融合逻辑(如果是混合搜索)。确保它在应用层是高效的。
  3. 队列健康度 :嵌入生成是异步的。监控你的队列长度和处理速度。如果队列堆积,新上架的商品可能无法立即被搜索到。确保有足够的队列处理器在工作。

7.3 扩展可能性

  1. 多语言搜索 :OpenAI的嵌入模型支持多语言。如果你的商品信息有多种语言,直接使用对应语言的查询即可,模型能跨语言理解语义(例如,用中文搜索“手机壳”,能匹配英文描述“iPhone case”的商品)。
  2. 图像与多模态搜索 :除了文本,你还可以为商品主图生成视觉嵌入向量(使用如CLIP模型)。将文本向量和图像向量结合(例如,取平均或拼接),可以实现“用文字搜图片”或“用图片找相似”的功能。这需要存储多个向量字段。
  3. 个性化搜索 :记录用户的点击、购买行为,为其生成一个“用户兴趣向量”。在搜索时,将查询向量与用户兴趣向量以某种方式结合,使搜索结果个性化。这是一个更高级的推荐系统方向。
  4. 使用Laravel Scout驱动 :为了更好的抽象,你可以创建一个自定义的Scout引擎( Laravel\Scout\Engines\Engine ),将向量搜索的逻辑封装在里面。这样,在你的代码中就可以统一使用 Product::search('舒适椅子')->get() 这样的语法,后端可以灵活切换搜索引擎。

搭建这样一个向量搜索系统,从原型到生产环境,最大的挑战往往不是代码本身,而是数据质量、基础设施的稳定性和对效果持续的评估与调优。建议从一个小的、重要的商品子集开始试点,收集真实用户的搜索日志,分析哪些查询效果好、哪些不好,然后迭代优化你的文本预处理逻辑和搜索策略。当你看到用户能用自然语言轻松找到他们甚至不知道如何精确描述的商品时,这种体验的提升所带来的价值,会远超最初的投入。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐