Meilisearch · Typesense · Elasticsearch · OpenSearch · Vespa · pgvector · 向量搜索 · 混合搜索
从应用内搜索到 AI 驱动的语义检索,一文搞懂选什么
2018 年,"用什么搜索引擎"几乎只有一个答案:Elasticsearch。日志用它、站内搜索用它、推荐也勉强用它。
2026 年,同一个问题至少分裂成五个方向:
商品目录、文档搜索、自动补全
候选:Meilisearch、Typesense、Algolia
日志、指标、聚合、全文检索
候选:Elasticsearch、OpenSearch
向量 + 全文 + 重排序
候选:Vespa、pgvector、Meilisearch hybrid
海量日志、S3 归档、低成本
候选:Quickwit、OpenSearch、Loki
搜索引擎的核心数据结构。不是"文档→词",而是"词→文档列表"。
# 正排索引:文档 → 词
Doc1: ["搜索引擎", "选型", "SaaS"]
Doc2: ["Meilisearch", "搜索引擎", "Rust"]
# 倒排索引:词 → 文档列表
"搜索引擎" → [Doc1, Doc2]
"选型" → [Doc1]
"SaaS" → [Doc1]
"Meilisearch" → [Doc2]
"Rust" → [Doc2]
倒排索引让查找包含某个词的文档变成 O(1) 的跳表查找,而不是遍历所有文档。
全文搜索的默认排序算法。核心思想:一个词在文档中出现越多、在整个语料库中越罕见,这个文档的相关性越高。
score(D, Q) = Σ IDF(qi) × f(qi, D) × (k1 + 1) / (f(qi, D) + k1 × (1 - b + b × |D| / avgdl))
其中 f(qi,D) = 词频,IDF = 逆文档频率,b = 文档长度归一化(通常 0.75),k1 = 词频饱和参数(通常 1.2-2.0)
向量搜索的第一步是将文本通过嵌入模型转换为高维向量。语义相近的文本在向量空间中距离更近。
# 使用 OpenAI text-embedding-3-small
from openai import OpenAI
client = OpenAI()
response = client.embeddings.create(
model="text-embedding-3-small",
input="笔记本电脑推荐"
)
# → 1536 维向量 [0.0023, -0.0145, 0.0078, ...]
response2 = client.embeddings.create(
model="text-embedding-3-small",
input="laptop recommendation"
)
# → 语义相似,向量距离很近!
目前最主流的向量索引算法(Hierarchical Navigable Small World)。几乎所有搜索引擎都用它。
M 越大索引越大但召回更好;ef_search 越大查询越慢但越准确。这是空间/时间/质量的三方权衡。
最常用的多路召回合并算法。不需要归一化分数,直接用排名:
# RRF 公式
score(d) = Σ 1 / (k + rank_i(d))
# 示例:某文档在 BM25 排第 2,向量排第 5
# k = 60(默认值)
score = 1/(60+2) + 1/(60+5) = 0.0161 + 0.0154 = 0.0315
| 引擎 | 混合方式 | Reranker | 开箱程度 |
|---|---|---|---|
| Meilisearch | semanticRatio 参数调权 | 内置 embedder | ⭐⭐⭐⭐⭐ |
| Typesense | vector_query + 全文并行 | 需外部 | ⭐⭐⭐ |
| Elasticsearch | Boolean + kNN 组合查询 | ELSER / learning_to_rank | ⭐⭐⭐⭐ |
| OpenSearch | hybrid query pipeline | 内置 ML plugin | ⭐⭐⭐⭐ |
| Vespa | 原生多路召回 + ranking profiles | 内置 onnx 模型 | ⭐⭐⭐⭐⭐ |
| pgvector | SQL 联合查询 | 需自建 | ⭐⭐ |
一句话:Algolia 的开源替代,5 分钟上手,开箱即用的应用内搜索。
当前版本:v1.13+(2026 稳定线) | 许可证:MIT
curl -L | ./meilisearch 即跑semanticRatio 调配 BM25 与向量的权重// Meilisearch 1.x — 混合搜索
const results = await client.index('products').search('蓝色跑鞋', {
hybrid: {
embedder: 'openai', // 使用已配置的 OpenAI embedder
semanticRatio: 0.7, // 70% 向量,30% BM25
},
limit: 20,
attributesToHighlight: ['name', 'description'],
})
// 配置 embedder(一次性设置)
await client.index('products').updateSettings({
embedders: {
openai: {
source: 'openAi',
model: 'text-embedding-3-small',
apiKey: process.env.OPENAI_API_KEY,
documentTemplate: '{{doc.name}} {{doc.description}}',
}
}
})
📋 1M 产品索引基准(2026 数据)
semanticRatio 一个参数搞定一句话:和 Meilisearch 同赛道但更注重可扩展性和集群高可用。
当前版本:v0.25+ | 许可证:GPL v3
# Typesense — 创建集合并启用向量搜索
curl -X POST 'http://localhost:8108/collections' \
-H 'X-TYPESENSE-API-KEY: xyz' \
-d '{
"name": "products",
"fields": [
{"name": "name", "type": "string"},
{"name": "description", "type": "string"},
{"name": "price", "type": "float"},
{"name": "embedding", "type": "float[]", "num_dim": 1536}
]
}'
# 混合搜索:全文 + 向量
curl 'http://localhost:8108/multi_search' \
-H 'X-TYPESENSE-API-KEY: xyz' \
-d '{
"searches": [
{"collection": "products", "q": "蓝色跑鞋", "query_by": "name,description"},
{"collection": "products", "q": "*", "vector_query": "embedding:([0.01,-0.02,...]),k:50"}
]
}'
| 维度 | Meilisearch | Typesense |
|---|---|---|
| 语言 | Rust | C++ |
| 许可证 | MIT ✅ | GPL v3 ⚠️ |
| 集群高可用 | 实验性 | Raft 内置 ✅ |
| 混合搜索 | 一个参数 ✅ | 需 multi_search 手动合并 |
| 内存模型 | 全内存 | mmap 磁盘索引 ✅ |
| 前端 SDK | 更丰富 ✅ | 够用 |
| Typo 容错 | 更优秀的算法 ✅ | 基础支持 |
一句话:什么都行、什么都大、什么都要调。2026 年依然是最全能的搜索引擎。
当前版本:8.x | 许可证:AGPL v3 / Elastic License v2 双许可
2024 年底,Elastic 将 Elasticsearch 和 Kibana 重新以 AGPL v3 开源(同时保留 Elastic License v2 二选一)。这直接回应了 OpenSearch 阵营,激活了自托管和第三方云服务的信心。
-- ESQL:ES 8.11+ 的管道式查询
FROM logs-*
| WHERE @timestamp > NOW() - 1 hour AND http.status >= 500
| STATS count(*) BY service.name
| SORT count DESC
| LIMIT 10
ESQL 让 Elasticsearch 在日志/指标场景更接近 OLAP,可直接与 ClickHouse/Apache Doris 竞争。
8.x 后期版本:dense_vector 字段 + HNSW 索引 + BM25 + kNN 混合搜索已标准化。ELSER(Elastic 训练的稀疏嵌入模型)和学习排序(learning_to_rank)让 RAG 管线可以完全在 ES 内闭环。
# Elasticsearch 8.x — 混合搜索
POST products/_search
{
"query": {
"bool": {
"should": [
{
"match": {
"name": {
"query": "蓝色跑鞋",
"boost": 0.3
}
}
}
]
}
},
"knn": {
"field": "embedding",
"query_vector": [0.01, -0.02, ...],
"k": 50,
"num_candidates": 200,
"boost": 0.7
},
"rank": {
"rrf": {
"window_size": 100,
"rank_constant": 60
}
},
"size": 10
}
一句话:AWS 2021 年从 Elasticsearch 7.10 fork 出来的项目,5 年后已是独立生态。
当前版本:2.x | 许可证:Apache 2.0
一句话:唯一在推理时实时计算 ML 模型的搜索引擎。Spotify、Wikipedia、Pinterest 在用。
许可证:Apache 2.0
其他搜索引擎是"存好索引 → 查询时检索"的离线模式。Vespa 可以在查询时实时执行 ML 模型——这意味着每个用户的搜索结果可以是实时个性化排序的。
expression: 0.3 * bm25(name) + 0.7 * closeness(field, embedding)
expression: onnx(model_name) { ... }
一句话:已经用了 PostgreSQL?那你已经有了搜索引擎的 80%。加上 pgvector 就有向量搜索。
-- PostgreSQL 内置全文搜索
-- 创建 tsvector 索引
CREATE INDEX idx_products_search ON products
USING GIN (to_tsvector('simple', name || ' ' || description));
-- 全文搜索查询
SELECT name, ts_rank(search_vector, query) AS rank
FROM products, plainto_tsquery('simple', '蓝色跑鞋') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
-- 安装 pgvector 扩展
CREATE EXTENSION IF NOT EXISTS vector;
-- 添加向量列
ALTER TABLE products ADD COLUMN embedding vector(1536);
-- 创建 HNSW 索引
CREATE INDEX idx_products_embedding ON products
USING hnsw (embedding vector_cosine_ops);
-- 向量搜索
SELECT name, 1 - (embedding <=> '[0.01,-0.02,...]'::vector) AS similarity
FROM products
ORDER BY embedding <=> '[0.01,-0.02,...]'::vector
LIMIT 20;
-- PostgreSQL 混合搜索:BM25 + 向量
WITH bm25_results AS (
SELECT id, ts_rank(search_vector, query) AS bm25_score
FROM products, plainto_tsquery('simple', '蓝色跑鞋') query
WHERE search_vector @@ query
LIMIT 100
),
vector_results AS (
SELECT id, 1 - (embedding <=> '[0.01,...]'::vector) AS vec_score
FROM products
ORDER BY embedding <=> '[0.01,...]'::vector
LIMIT 100
)
SELECT
COALESCE(b.id, v.id) AS id,
COALESCE(b.bm25_score, 0) * 0.3 + COALESCE(v.vec_score, 0) * 0.7
AS combined_score
FROM bm25_results b
FULL OUTER JOIN vector_results v ON b.id = v.id
ORDER BY combined_score DESC
LIMIT 20;
| 维度 | Meilisearch | Typesense | Elasticsearch | OpenSearch | Vespa | pgvector |
|---|---|---|---|---|---|---|
| 语言 | Rust | C++ | Java | Java | C++ | C/SQL |
| 许可证 | MIT | GPL v3 | AGPL/ELv2 | Apache 2.0 | Apache 2.0 | PostgreSQL |
| 启动复杂度 | ⭐ | ⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ |
| 混合搜索 | 内置 ⭐ | 手动 | 内置 ⭐ | 内置 | 原生 ⭐ | 手动 SQL |
| Reranker | 内置 | 外部 | ELSER/LTR | ML plugin | 内置 ONNX | 无 |
| 集群 HA | 实验性 | Raft ✅ | 完善 ✅ | 完善 ✅ | 完善 ✅ | PG 复制 |
| 中文字分词 | 插件 | 插件 | ik/smartcn | ik/smartcn | 内置 | jieba 扩展 |
| 适用规模 | <10M | <50M | 1B+ | 1B+ | 1B+ | <5M |
| 内存占用 | 中 | 低 ⭐ | 高 | 高 | 高 | 低 |
| 日志场景 | ❌ | ❌ | ✅ | ✅ | ⚠️ | ❌ |
| 推荐系统 | ❌ | ❌ | ⚠️ | ⚠️ | ✅ ⭐ | ❌ |
| 前端 SDK | 优秀 ⭐ | 良好 | 丰富 | 良好 | 基础 | N/A |
| 托管服务 | Meilisearch Cloud | Typesense Cloud | Elastic Cloud | AWS OpenSearch | Vespa Cloud | 各 PG 云 |
| 月成本(小规模) | $0-30 | $0-25 | $50-200 | $50-150 | $100+ | $0 |
推荐:Meilisearch 或 Typesense
需要:typo tolerance、分面过滤、前缀搜索、高亮、混合搜索
不需要:日志分析、PB 级扩展
推荐:Meilisearch(小规模)或 pgvector(已用 PG)
需要:语义搜索、混合搜索、中文分词
不需要:实时推荐、日志
推荐:Elasticsearch 或 OpenSearch(AWS)
备选:Quickwit(S3-native、低成本)
不需要:typo tolerance、前缀搜索
// products-search.js — 完整的搜索 API
import { MeiliSearch } from 'meilisearch'
const client = new MeiliSearch({ host: 'http://localhost:7700', apiKey: 'masterKey' })
const index = client.index('products')
// 初始化索引设置
await index.updateSettings({
searchableAttributes: ['name', 'description', 'category', 'brand'],
filterableAttributes: ['price', 'category', 'brand', 'inStock'],
sortableAttributes: ['price', 'createdAt', 'rating'],
displayedAttributes: ['id', 'name', 'price', 'category', 'brand', 'image', 'rating'],
rankingRules: [
'words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'
],
synonyms: {
'手机': ['移动设备', 'cellphone'],
'笔记本': ['laptop', '笔记本电脑'],
},
typoTolerance: {
enabled: true,
minWordSizeForTypos: { oneTypo: 4, twoTypos: 8 },
disableOnAttributes: ['id'], // ID 字段不允许模糊
},
embedders: {
openai: {
source: 'openAi',
model: 'text-embedding-3-small',
apiKey: process.env.OPENAI_API_KEY,
documentTemplate: '{{doc.name}} {{doc.category}} {{doc.description}}',
}
}
})
// 搜索 API
export async function searchProducts(query, filters = {}) {
const { category, minPrice, maxPrice, hybrid = true } = filters
const searchParams = {
q: query,
limit: 20,
attributesToHighlight: ['name', 'description'],
filter: buildFilter(category, minPrice, maxPrice),
sort: filters.sortBy ? [`${filters.sortBy}:${filters.sortOrder || 'desc'}`] : undefined,
}
// 混合搜索:语义 + 全文
if (hybrid) {
searchParams.hybrid = {
embedder: 'openai',
semanticRatio: 0.6, // 60% 语义,40% 关键词
}
}
const results = await index.search(query, searchParams)
return {
hits: results.hits.map(formatHit),
total: results.estimatedTotalHits,
processingTime: results.processingTimeMs,
}
}
function buildFilter(category, minPrice, maxPrice) {
const parts = []
if (category) parts.push(`category = "${category}"`)
if (minPrice) parts.push(`price >= ${minPrice}`)
if (maxPrice) parts.push(`price <= ${maxPrice}`)
return parts.length ? parts.join(' AND ') : undefined
}
function formatHit(hit) {
return {
...hit,
_highlight: hit._formatted, // 高亮版本
}
}
# rag_search.py — RAG 混合检索
import asyncpg
from openai import AsyncOpenAI
openai = AsyncOpenAI()
class RAGSearch:
def __init__(self, db_url: str):
self.db_url = db_url
self.pool = None
async def init(self):
self.pool = await asyncpg.create_pool(self.db_url)
async def embed(self, text: str) -> list[float]:
resp = await openai.embeddings.create(
model="text-embedding-3-small",
input=text,
)
return resp.data[0].embedding
async def hybrid_search(self, query: str, limit: int = 10):
"""混合搜索:全文 + 向量,RRF 合并"""
query_vec = await self.embed(query)
vec_str = str(query_vec).replace("[", "").replace("]", "")
async with self.pool.acquire() as conn:
rows = await conn.fetch("""
WITH bm25 AS (
SELECT id, ts_rank(
fts, plainto_tsquery('simple', $1)
) AS score
FROM documents
WHERE fts @@ plainto_tsquery('simple', $1)
LIMIT 100
),
vec AS (
SELECT id, 1 - (embedding <=> $2::vector) AS score
FROM documents
ORDER BY embedding <=> $2::vector
LIMIT 100
)
SELECT
COALESCE(b.id, v.id) AS id,
COALESCE(1.0/(60 + ROW_NUMBER() OVER (
ORDER BY b.score DESC
)), 0) * 0.3 +
COALESCE(1.0/(60 + ROW_NUMBER() OVER (
ORDER BY v.score DESC
)), 0) * 0.7
AS rrf_score
FROM bm25 b
FULL OUTER JOIN vec v ON b.id = v.id
ORDER BY rrf_score DESC
LIMIT $3
""", query, f"[{vec_str}]", limit)
# 获取完整文档
ids = [r["id"] for r in rows]
if not ids:
return []
docs = await conn.fetch(
"SELECT * FROM documents WHERE id = ANY($1)", ids
)
return docs
中文没有空格分词,需要专用分词器:
| 引擎 | 中文分词方案 | 安装方式 |
|---|---|---|
| Elasticsearch | ik_max_word / ik_smart | 插件安装 |
| OpenSearch | ik / smartcn | 插件安装 |
| Meilisearch | 内置 Unicode 分段 + 字典 | 无需配置 |
| Typesense | 内置 CJK 分词 | 无需配置 |
| PostgreSQL | pg_jieba / zhparser | 扩展安装 |
| Vespa | 内置 CJK 分词器 | 配置 linguistics |
💡 实践中,向量搜索 + 混合检索大大缓解了中文分词的压力——语义理解不需要完美分词。
# Redis 缓存热门搜索结果
import redis
import json
import hashlib
r = redis.Redis()
async def cached_search(query: str, filters: dict, ttl: int = 300):
"""搜索结果缓存:热门查询 5 分钟"""
cache_key = f"search:{hashlib.md5(f'{query}:{filters}'.encode()).hexdigest()}"
# 命中缓存
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# 未命中:执行搜索
results = await search_products(query, filters)
# 写入缓存
r.setex(cache_key, ttl, json.dumps(results))
return results
# 记录搜索行为 → 后续优化排序
search_events = [
{"query": "蓝色跑鞋", "results_count": 42, "clicked_pos": 2, "timestamp": "..."},
{"query": "蓝色跑鞋", "results_count": 42, "clicked_pos": null, "timestamp": "..."},
# null = 没有点击任何结果 → 可能排序不好
]
# 计算点击率 (CTR) 和 平均点击位置
# 用于:1. 发现零结果查询 2. 发现低 CTR 查询 3. A/B 测试排序策略
| 方案 | 类型 | 何时考虑 |
|---|---|---|
| Algolia | 托管 SaaS | 不想管任何基础设施,预算充足($1/1000 搜索) |
| Quickwit | 日志搜索 | S3-native 日志归档,成本敏感 |
| Loki | 日志聚合 | Grafana 生态,只存标签不存全文 |
| Apache Doris / StarRocks | OLAP + 搜索 | 分析为主、搜索为辅 |
| ClickHouse | OLAP | 全文搜索能力有限,适合日志分析 |
| Sonic | 超轻量 | 只需自动补全,内存极小 |
| 纯向量 DB | LanceDB/ChromaDB/Pinecone | 只做 RAG 向量检索,不需要全文。详见 向量数据库模块 |