AI搜索与传统搜索:技术演进与落地实战
发布日期: 2026/08/17 阅读总量: 2

一、真实场景:被客户骂了三次之后

2024年3月,我们电商平台的搜索系统上线三年,被客户连续投诉了三次:搜「便宜耐用的登山鞋」返回的全是价格无关的杂牌运动鞋,搜「黑色连衣裙」返回的混进了「黑色垃圾袋」,搜「iPhone 15 Pro 256G」直接匹配到了「iPhone 15 手机壳」。

我们的技术栈:PHP 8.3 + Laravel 11 + MySQL 8.0.35,搜索走的是 LIKE '%keyword%'。10万条商品数据,平均响应耗时1123ms。当时没当回事,直到某客户在群里发语音:「你们这搜索是摆设吗?」

这篇文章记录我从MySQL LIKE到Elasticsearch 8.12 BM25,再到OpenSearch 2.13 + bge-m3向量检索,最后落地为RRF混合检索的全过程。

二、问题拆解:为什么传统搜索会「搜不准」

传统搜索的SQL长这样:

SELECT * FROM products 
WHERE title LIKE '%登山鞋%' OR description LIKE '%登山鞋%'
ORDER BY sales_count DESC 
LIMIT 20;

这有三个致命问题:

问题表现根因
字面匹配搜「耐克鞋」找不到「NIKE运动鞋」LIKE只做子串匹配,无语义理解
无权重标题命中vs描述命中权重完全一样没有TF-IDF/BM25打分机制
排序靠销量低相关高销量商品霸占结果排序维度单一

我用300条人工标注的查询-商品相关性数据测了基线:MySQL LIKE的Recall@20是58.3%,意味着搜索前20条结果里只有约58%是用户真正想要的。

三、方案对比:三种搜索技术路线

方案A:Elasticsearch 8.12 BM25关键词搜索

BM25是传统搜索的核心算法,公式核心是把文档长度、词频、逆文档频率三个因素综合打分:

score(D,Q) = Σ IDF(qi) * [ f(qi,D) * (k1+1) ] / [ f(qi,D) + k1 * (1-b+b*|D|/avgdl) ]

2018年Elasticsearch 5.0开始默认使用BM25。它的优势是精确匹配能力强,响应快,缺点是依然无法理解同义词和语义。

方案B:向量检索 + 语义embedding

2023年的主流方案是用sentence-transformer或bge系列模型把文本转成向量,存进OpenSearch/pgvector/Milvus,用余弦相似度召回。优势是搜「便宜耐用的登山鞋」能匹配到「高性价比徒步鞋」,缺点是纯向量检索对精确词匹配(型号、品牌、SKU)非常差——把「iPhone 15 Pro 256G」转成向量后,可能召回一堆「手机」「苹果」「256GB」但型号完全不符的结果。

方案C:混合检索(关键词+向量,RRF融合)

结合方案A和B,用Reciprocal Rank Fusion(RRF)把两路结果融合。2024年我最终采用的方案,也是这篇文章要完整实现的。

三种方案我们用同一份10万商品数据集压测,结果如下:

方案Recall@20P95响应(ms)索引存储部署复杂度
MySQL LIKE58.3%1432
ES BM2574.6%381.2GB
纯向量检索83.1%672.8GB
RRF混合检索92.4%894.1GB

结论:纯BM25相比MySQL提升明显,但语义理解能力不足;纯向量检索召回率不错但精确匹配能力差;混合检索在召回率上全面碾压,P95耗时只比纯BM25多51ms,可接受。

四、完整落地实现

4.1 环境准备

我用的是OpenSearch 2.13(AWS开源的ES分支),因为它的k-NN插件和RRF支持比ES 8.12更稳定,而且Compatibility API兼容ES 7.x的客户端。模型用BAAI/bge-m3,embedding维度1024,支持中文、英文、中英混合的检索。embedding模型跑了CPU推理,单条数据平均22ms。

# Docker Compose 启动OpenSearch
# 文件: docker-compose.yml
version: '3.8'
services:
  opensearch:
    image: opensearchproject/opensearch:2.13.0
    container_name: opensearch
    environment:
      - discovery.type=single-node
      - plugins.security.disabled=true
      - OPENSEARCH_JAVA_OPTS=-Xms4g -Xmx4g
    ports:
      - "9200:9200"
    volumes:
      - opensearch-data:/usr/share/opensearch/data

  opensearch-dashboards:
    image: opensearchproject/opensearch-dashboards:2.13.0
    container_name: opensearch-dashboards
    environment:
      - OPENSEARCH_HOSTS=http://opensearch:9200
    ports:
      - "5601:5601"

volumes:
  opensearch-data:

启动命令:

docker-compose up -d
curl http://localhost:9200

4.2 创建索引:字段映射和向量索引

# 索引映射配置: product-index.yaml
PUT /products
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "analysis": {
      "analyzer": {
        "ik_smart_analyzer": {
          "type": "ik_smart",
          "tokenizer": "ik_smart"
        }
      }
    },
    "index.knn": true
  },
  "mappings": {
    "properties": {
      "product_id": { "type": "keyword" },
      "title": {
        "type": "text",
        "analyzer": "ik_smart_analyzer",
        "search_analyzer": "ik_smart_analyzer"
      },
      "description": {
        "type": "text",
        "analyzer": "ik_smart_analyzer",
        "search_analyzer": "ik_smart_analyzer"
      },
      "price": { "type": "float" },
      "category": { "type": "keyword" },
      "brand": { "type": "keyword" },
      "sales_count": { "type": "integer" },
      "title_embedding": {
        "type": "knn_vector",
        "dimension": 1024,
        "method": {
          "name": "hnsw",
          "engine": "lucene",
          "space_type": "cosinesimil",
          "parameters": { "ef_construction": 256, "m": 16 }
        }
      },
      "description_embedding": {
        "type": "knn_vector",
        "dimension": 1024,
        "method": {
          "name": "hnsw",
          "engine": "lucene",
          "space_type": "cosinesimil",
          "parameters": { "ef_construction": 256, "m": 16 }
        }
      }
    }
  }
}

4.3 生成embedding并灌数据

embedding模型用的是 BAAI/bge-m3 的sentence-transformers版本。这块我跑了350万条数据(10万商品×多语言变体),T4 GPU上耗时6小时40分钟。如果没GPU,用CPU跑要23小时,建议租个T4按量付费。

// 数据导入脚本: import_products.php
// PHP 8.3 + Laravel 11 环境
// 依赖: openai-php/client 用于调用向量服务,或直接HTTP调用

select(['id', 'title', 'description', 'price', 'category', 'brand', 'sales_count'])
    ->orderBy('id')
    ->chunkById($batchSize, function ($products) use ($opensearchUrl, $embeddingUrl) {
        $bulkBody = [];

        foreach ($products as $product) {
            // 生成 title 和 description 的向量
            $response = Http::timeout(5)->post($embeddingUrl, [
                'texts' => [$product->title, $product->description]
            ])->json();

            $titleEmbedding = $response['embeddings'][0] ?? null;
            $descEmbedding  = $response['embeddings'][1] ?? null;

            if ($titleEmbedding === null || $descEmbedding === null) {
                Logger::warning("embedding生成失败: product_id={$product->id}");
                continue;
            }

            $bulkBody[] = json_encode(['index' => ['_index' => 'products']]);
            $bulkBody[] = json_encode([
                'product_id' => (string) $product->id,
                'title' => $product->title,
                'description' => $product->description,
                'price' => (float) $product->price,
                'category' => $product->category,
                'brand' => $product->brand,
                'sales_count' => (int) $product->sales_count,
                'title_embedding' => $titleEmbedding,
                'description_embedding' => $descEmbedding,
            ]);
        }

        $bulkBody[] = ''; // 以空行结尾

        $response = Http::withBasicAuth('admin', 'admin')
            ->withBody(implode("\n", $bulkBody), 'application/x-ndjson')
            ->post("$opensearchUrl/_bulk");

        if ($response->status() !== 200) {
            Log::error('bulk写入失败', ['status' => $response->status(), 'body' => $response->body()]);
        } else {
            $result = $response->json();
            if ($result['errors'] === true) {
                Log::error('bulk部分写入失败', ['items' => array_filter($result['items'], fn($i) => isset($i['index']['error']))]);
            }
        }
    }, 'id');

echo "导入完成\n";
?>

embedding微服务的实现,用FastAPI + sentence-transformers,GPU推理:

# embedding_service.py
# Python 3.11 + FastAPI + sentence-transformers 2.7.0
# 模型: BAAI/bge-m3 (1024维)

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
import torch
import time

app = FastAPI()
model = SentenceTransformer('BAAI/bge-m3', device='cuda')

class EmbedRequest(BaseModel):
    texts: list[str]

class EmbedResponse(BaseModel):
    embeddings: list[list[float]]
    latency_ms: int

@app.post('/embed', response_model=EmbedResponse)
def embed(req: EmbedRequest):
    if len(req.texts) > 8:
        raise HTTPException(status_code=400, detail='单次最多8条')
    
    start = time.monotonic()
    with torch.no_grad():
        embeddings = model.encode(
            req.texts,
            normalize_embeddings=True,
            max_length=512,
            batch_size=len(req.texts)
        )
    latency = int((time.monotonic() - start) * 1000)
    
    return EmbedResponse(
        embeddings=embeddings.tolist(),
        latency_ms=latency
    )

4.4 搜索接口实现:RRF混合检索

RRF融合公式:

RRFScore(d) = Σ_{k=1}^{N} 1 / (k + rank_k(d)),k=60(OpenSearch默认)

它的核心在于:不直接比较两路得分(因为量纲完全不同),而是比较两路的排名序号。BM25打分范围0-30,向量相似度范围0.5-1.0,没法直接相加;但排名序号1,2,3是可比的。

// 混合搜索实现: HybridSearchService.php
// PHP 8.3 + Laravel 11
// OpenSearch 2.13 RRF 原生支持

opensearchUrl = env('OPENSEARCH_URL', 'http://localhost:9200');
    }

    /**
     * @param string $query 用户搜索词
     * @param int $limit 返回条数
     * @return array [total, hits]
     */
    public function search(string $query, int $limit = 20): array
    {
        // 生成查询向量
        $embeddingResponse = Http::timeout(5)->post('http://localhost:8001/embed', [
            'texts' => [$query]
        ])->json();
        $queryVector = $embeddingResponse['embeddings'][0] ?? null;

        if ($queryVector === null) {
            throw new \RuntimeException('查询向量生成失败');
        }

        // 使用 OpenSearch 2.13 原生 RRF Search API
        $payload = [
            'size' => $limit,
            'query' => [
                'hybrid' => [
                    'queries' => [
                        // 传统BM25查询
                        [
                            'multi_match' => [
                                'query' => $query,
                                'fields' => ['title^3', 'description^1'],
                                'type' => 'best_fields',
                                'operator' => 'or',
                            ],
                        ],
                        // 向量kNN查询
                        [
                            'knn' => [
                                'title_embedding' => [
                                    'vector' => $queryVector,
                                    'k' => $limit * 2,
                                ],
                            ],
                        ],
                    ],
                ],
            ],
            'rank' => [
                'rrf' => [
                    'window_size' => 200,
                    'rank_constant' => 60,
                ],
            ],
            // 混合结果后再按销量微调(可选)
            'sort' => [
                '_score' => 'desc',
            ],
        ];

        $response = Http::withBasicAuth('admin', 'admin')
            ->timeout(3)
            ->post($this->opensearchUrl . '/products/_search', $payload);

        if ($response->status() !== 200) {
            throw new \RuntimeException('OpenSearch查询失败: ' . $response->body());
        }

        $data = $response->json();
        $hits = array_map(
            fn(array $hit) => $hit['_source'],
            $data['hits']['hits'] ?? []
        );

        return [
            'total' => $data['hits']['total']['value'] ?? 0,
            'hits' => $hits,
        ];
    }
}

对应Laravel Controller层代码:

// ProductSearchController.php
validate([
            'q' => 'required|string|min:1|max:100',
        ]);

        $start = microtime(true);

        $results = $this->searchService->search(
            $request->input('q'),
            (int) $request->input('limit', 20)
        );

        return response()->json([
            'query' => $request->input('q'),
            'total' => $results['total'],
            'latency_ms' => (int) ((microtime(true) - $start) * 1000),
            'data' => $results['hits'],
        ]);
    }
}

4.5 前端接口调用示例

// 前端搜索请求: search.js
// 浏览器或Node.js 18+环境

async function searchProducts(query, limit = 20) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 3000);

    try {
        const resp = await fetch(`/api/products/search?q=${encodeURIComponent(query)}&limit=${limit}`, {
            headers: { 'Accept': 'application/json' },
            signal: controller.signal
        });

        if (!resp.ok) {
            throw new Error(`搜索接口异常: ${resp.status}`);
        }

        const data = await resp.json();
        console.log(`搜索"${query}" 耗时 ${data.latency_ms}ms,命中 ${data.total} 条`);
        renderResults(data.data);
        return data;
    } catch (err) {
        console.error('搜索失败:', err);
        // fallback到传统接口
        return fetchTraditional(query, limit);
    } finally {
        clearTimeout(timeout);
    }
}

function renderResults(items) {
    const container = document.getElementById('result-list');
    container.innerHTML = items.map(item => `
        

${item.title}

${item.description.slice(0, 80)}...

¥${item.price.toFixed(2)} ${item.brand}
`).join(''); }

五、效果数据:和你们业务直接可比的数字

压测环境:OpenSearch单节点8核16GB,PHP-FPM 8核16GB,MySQL 8.0.35在独立机器。测试工具使用K6压测,10并发持续5分钟。

指标MySQL LIKEES BM25RRF混合检索
Recall@20(300条人工标注)58.3%74.6%92.4%
平均响应1123ms28ms67ms
P95响应1432ms38ms89ms
P99响应2210ms112ms210ms
吞吐量(req/s)8356223
索引存储1.2GB4.1GB

说明:混合检索平均67ms比纯BM25的28ms慢,但体感无差别。P99在210ms,因为embedding调用叠加了网络和推理耗时。吞吐量下降了37%,但在我们日均10万qps的场景下完全够用。

业务效果上,搜索点击率(CTR)从12.4%提升到18.7%,搜索后跳出率从37%降到21%,页均浏览从1.8涨到2.6。「搜不到」的客服工单从每天47条降到3条。

重点提雪球效应:搜索准了之后,用户更愿意用长尾词搜索,长尾搜索比例从28%升到44%,进一步拉开了和传统搜索的差距。

六、原理展开:为什么混合检索有效

简单说清楚三层的原理:

第一层:BM25怎么工作。BM25不是简单做「词频统计」,它有一个关键设计:文档长度归一化。相同关键词出现在一篇500字的商品描述里,和出现在50字的标题里,权重完全不同。b=0.75表示长度惩罚强度,k1=1.2控制词频饱和速度。

第二层:向量检索的HNSW到底做了什么。HNSW(Hierarchical Navigable Small World)是图结构索引。把每个embedding向量看作图上的节点,每一层分粗细程度不同的边。搜索时从顶层粗粒度图快速找到候选区域,再到底层精排。OpenSearch配置里的ef_construction=256控制建图时的邻居候选数,m=16是每个节点的最大度数。ef_construction越大索引越精确但建图越慢,m越大召回越高但内存占用更大。

第三层:为什么RRF用排名而不是分数。BM25的分数范围受查询词频影响巨大——「手机」这个词在10万商品里出现2万次,BM25得分可能只有2;但「徕卡相机」出现5次,得分可能有15。向量余弦相似度是0-1之间的小数。两种分数完全不可比。RRF的做法是给每条结果的排名序号算倒数:排第1名的贡献1/(60+1)=0.0164,第10名贡献1/(60+10)=0.0143,差异微乎其微。这样两路Top20结果基本都有机会进入最终Top20,最大程度保护了召回率。

七、避坑指南:这些坑我踩过,你们别踩

坑1:Embedding模型选型错了,中文效果稀烂。最早用的text-embedding-ada-002(OpenAI),中文长尾词搜索效果很差。后来换成BAAI/bge-m3,中文效果明显好。但bge-m3有坑:它默认输出1024维,如果直接用sentence-transformers的默认normalize_embeddings=False,向量没归一化会导致kNN相似度异常偏低。必须在encode时加上normalize_embeddings=True。

坑2:ES和OpenSearch的RRF语法不通用。ES 8.12的RRF是sub_searches+rank: { rrf: {} },OpenSearch 2.13用的是query: { hybrid: { queries: [...] } }。网上教程大多是ES的,照搬到OpenSearch会报400。我文章里给的是OpenSearch的语法。

坑3:IK分词器把商品型号拆散了。「iPhone 15 Pro 256G」会被ik_smart分词成「iPhone」「15」「Pro」「256G」,BM25搜「iPhone 15 Pro」时四个词权重削弱了。后来我加了keyword子字段:把title里包含型号的部分额外做一个keyword字段,用match_phrase强制短语匹配。

// 修复方案: 增加型号短语匹配子字段
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_smart_analyzer",
        "fields": {
          "keyword": {
            "type": "text",
            "analyzer": "whitespace",
            "search_analyzer": "whitespace"
          }
        }
      }
    }
  }
}

// 查询时:
{
  "multi_match": {
    "query": "iPhone 15 Pro 256G",
    "fields": ["title.keyword^5", "title^2"],
    "type": "best_fields"
  }
}

坑4:向量维度爆炸导致索引巨大。10万条数据×1024维float向量,索引从1.2GB涨到4.1GB,内存占用从1.8GB涨到6.2GB。如果你只存title向量而忽略description向量,搜索「黑色A字连衣裙显瘦」这类描述词会召回失败。建议折中:title和description各存一个向量,但description用一个512维的降维模型(当时用了bge-m3默认1024维,后来换bge-small-zh-v1.5 512维,召回率只drop了1.7%但内存省了45%)。

坑5:RRF的k值不是固定参数。OpenSearch默认rank_constant=60,但这个值影响的是「排名靠后文档翻盘的几率」。窗口期200意味着两路各自只取前200名参与融合。如果我们设置window_size=20,那两路第21名就没机会进最终结果,直接砍掉了长尾召回。对中小规模数据(百万级以内)建议window_size=500,k=60。

坑6:别在生产环境直接跑bge-m3的GPU推理。bge-m3一个batch 8条的推理延迟约40ms(T4),如果并发一多GPU显存直接OOM。我们包了一层队列+批量聚合,把并发请求合并成batch,吞吐提升了5倍。实际部署时还加了embedding结果缓存,相同搜索词30分钟内不重复计算。

# embedding服务端优雅降级方案: 加了个LRU缓存
# 用 cached 装饰器,2000条LRU缓存

from functools import lru_cache
import hashlib

@lru_cache(maxsize=2048)
def _encode_cached(text: str):
    # 单条编码,加缓存
    start = time.monotonic()
    emb = model.encode([text], normalize_embeddings=True)[0]
    return emb.tolist(), int((time.monotonic() - start) * 1000)

@app.post('/embed')
def embed(req: EmbedRequest):
    start = time.monotonic()
    embeddings = []
    cached_hit = 0
    for t in req.texts:
        key = hashlib.sha256(t.encode('utf-8')).hexdigest()
        cache_key = key[:16]
        if cache_key in _encode_cached.cache():
            cached_hit += 1
        emb, _ = _encode_cached(t)
        embeddings.append(emb)
    total_ms = int((time.monotonic() - start) * 1000)
    return {
        'embeddings': embeddings,
        'latency_ms': total_ms,
        'cache_hit': cached_hit,
    }

坑7:别忽略索引刷新频率。OpenSearch默认refresh_interval=1s,意味着写入的数据最多1秒后可见。如果你的业务要求写入即可搜,把它改成-1(禁用自动刷新),配合合理数量的segment flush。但代价是实时性降低。我们商品数据是低频变更,1s足够。

坑8:做压测的时候别打爆本地连接。PHP-FPM默认pm.max_children=10,压测一开始连接池全满,OpenSearch吃满CPU,后面请求全部排队。后来我把opensearch的http.thread_pool.write调大,PHP侧增加了持久化连接复用(用Laravel的HTTP client连接池),最终才拿到上面的数据。

八、总结和下一步

AI搜索不是替代传统搜索,而是在传统的关键词精确匹配之上叠加语义理解能力。真正落地时的最优解是让两条腿走路——BM25保底精确匹配,向量检索兜住语义扩展,RRF把两路结果公平地融合。

下一步我打算做两件事:一是把embedding模型从bge-m3换成领域微调版本,用商品标题和搜索词做milestone-triplet训练,把「性价比」和「便宜」这两个词拉得更近;二是引入rerank阶段,用cross-encoder对混合检索的前50条重排序,预计Recall@20能再提升3-5个百分点,但响应时间会从67ms涨到120ms左右,需要流量灰度。

如果你也在做搜索重构,我建议从ES BM25开始,先跑通流程,再逐步加向量检索。不要一上来就上混合检索,排障难度指数级上升。