跟着这篇走完,你会在终端把 Meilisearch 的纯向量语义搜索跑通:用本地中文嵌入模型 bge-small-zh-v1.5 把文档转成 512 维向量,通过 userProvided 类型的 embedder 存进 Meilisearch,检索时把 semanticRatio 拉到 1.0,让结果只看语义、不看关键词。整个过程 6 步,每步都有一条命令能验证自己做对了没有。
动手前先交代下环境。localhost:7700 是 Meilisearch 的默认监听地址,master key 固定用 aSampleMasterKey(与系列一致),索引固定用 books_vec(本篇新建,不动前面的 books_zh、books_sort),示例文档还是那 6 条虚构书籍;你要是换了端口、master key、索引名或嵌入模型,把示例值替换掉就行。这篇默认你已经装好并跑通了从零跑通 Meilisearch 本地搜索服务,还没跑起来先回去把服务起了再回来,安装、启动、异步任务轮询的套路全部复用第一篇。向量检索这事有个前提你要知道:Meilisearch 自己不做嵌入,它只管存向量、比向量,向量得你这边生成好了再喂给它——这正是这篇和前面几篇最大的不同。

前置条件
| 项 | 要求 | 说明 |
|---|---|---|
| Meilisearch | v1.54.3 或更高 | 与系列一致;embedder(嵌入器)配置接口 v1.6 起才有,低于这个版本照做会报错 |
| 前置文章 | 已跑通 meilisearch-local-search |
复用其安装/启动/master key/异步任务轮询套路;没跑通先回去补 |
| Python | 3.10–3.12 + sentence-transformers | Intel Mac 需先锁 torch==2.2.2(PyTorch 2.3 起不再出 macOS x86_64 包) |
| master key | aSampleMasterKey |
与系列一致 |
| curl | 任意版本 | 全文用 curl 调 REST API |
先跑这条,确认第一篇的服务还活着。
curl http://localhost:7700/health
{
"status": "available"
}
返回 {"status":"available"} 就满足条件。要是返回 curl: (7) Failed to connect to localhost port 7700,说明服务没起——回第一篇第一步用 meilisearch --master-key="aSampleMasterKey" 重新启动,再回来。
第一步:确认服务在跑、版本够不够新
向量检索不是 Meilisearch 出生就有的能力,embedder 设置接口在 v1.6 才出现,后面第二步就要用这个接口。版本太低的话,/settings 上会直接不认 embedders 这个字段,所以第一步先把版本确认掉,省得照老版本教程做了一路、最后卡在配置接口上。
curl http://localhost:7700/version -H "Authorization: Bearer aSampleMasterKey"
{
"commitSha": "7ebd97e38c36882b0c78d79e433b9115b4f003b3",
"commitDate": "2026-10-01T11:51:22.000000000+02:00",
"pkgVersion": "1.54.3"
}
验证就一条:pkgVersion 大于等于 1.6 就行。低于 1.6 的旧版没有 embedder 概念,照本篇第二步的命令会返回 400,先升级再回来。
第二步:建 books_vec 索引,配置 userProvided 类型 embedder
Meilisearch 默认只会做关键词检索,向量要查得动,必须先在索引设置里声明一个 embedder。embedder(嵌入器)就是一条“向量从哪来、多长”的配置:source 告诉 Meilisearch 向量由谁生成,dimensions 告诉它每段文本转成的向量是几维。这里用 userProvided(用户自供)——意思是向量全部由调用方生成、随文档传进来,Meilisearch 不做任何嵌入计算。这一步不配,后面就算文档里带了向量字段,检索时也会报错。
索引走显式创建,指定主键 id,沿用系列惯例:
curl -X POST http://localhost:7700/indexes \
-H "Authorization: Bearer aSampleMasterKey" \
-H "Content-Type: application/json" \
-d '{"uid":"books_vec","primaryKey":"id"}'
{
"taskUid": 15,
"indexUid": "books_vec",
"status": "enqueued",
"type": "indexCreation",
"enqueuedAt": "2026-10-11T10:00:00.000Z"
}
接着配 embedder。dimensions 必须等于你生成向量时模型输出的维度——bge-small-zh-v1.5 输出 512 维,这里就写 512,多一维少一维后面传文档都会报维度不匹配:
curl -X PATCH http://localhost:7700/indexes/books_vec/settings \
-H "Authorization: Bearer aSampleMasterKey" \
-H "Content-Type: application/json" \
-d '{"embedders":{"default":{"source":"userProvided","dimensions":512}}}'
{
"taskUid": 16,
"indexUid": "books_vec",
"status": "enqueued",
"type": "settingsUpdate",
"enqueuedAt": "2026-10-11T10:00:01.000Z"
}
验证,把 embedders 设置读回来看看。
curl http://localhost:7700/indexes/books_vec/settings/embedders -H "Authorization: Bearer aSampleMasterKey"
{
"default": {
"source": "userProvided",
"dimensions": 512
}
}
读到 {"default":{"source":"userProvided","dimensions":512}} 说明配置落进去了。注意这里 embedder 名字是 default,后面文档的 _vectors 字段、检索的 hybrid.embedder 都得跟它一致。如果读出来是 {},多半是设置任务还没跑完就查了,把上面的任务 UID 轮询到 succeeded 再看。
第三步:用 bge-small-zh-v1.5 把 6 条书转成 512 维向量
userProvided 的代价在这:向量全得自己算。这一步用 sentence-transformers 加载本地中文嵌入模型 bge-small-zh-v1.5,把每本书的标题+作者拼成一段文本,转成 512 维向量,最后拼成带 _vectors 字段的 JSON 文件。向量(embedding)就是把一段文本压成一串数字,语义越接近的文本,数字序列在空间里越靠近。
先建虚拟环境装依赖。Intel Mac 一定要先锁 torch 版本,不然 pip 会去解析一个不存在的 wheel 直接报错;Apple Silicon 和 Linux 没有这个限制,正常 pip install sentence-transformers 就行:
python3 -m venv venv && source venv/bin/activate
pip install torch==2.2.2 sentence-transformers==3.3.1
写个 embed.py,读 6 条书的 JSON,输出 docs_with_vectors.json:
import json
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-small-zh-v1.5")
books = [
{"id": 1, "title": "三体", "author": "刘慈欣"},
{"id": 2, "title": "球状闪电", "author": "刘慈欣"},
{"id": 3, "title": "活着", "author": "余华"},
{"id": 4, "title": "许三观卖血记", "author": "余华"},
{"id": 5, "title": "明朝那些事儿", "author": "当年明月"},
{"id": 6, "title": "万历十五年", "author": "黄仁宇"},
]
texts = [f"{b['title']} {b['author']}" for b in books]
vecs = model.encode(texts, normalize_embeddings=True)
docs = []
for b, v in zip(books, vecs):
docs.append({**b, "_vectors": {"default": v.tolist()}})
with open("docs_with_vectors.json", "w", encoding="utf-8") as f:
json.dump(docs, f, ensure_ascii=False)
print("dimensions:", len(vecs[0]))
print("documents:", len(docs))
运行:
python embed.py
dimensions: 512
documents: 6
验证两条:dimensions: 512(模型输出的维度,必须跟第二步配置一致),documents: 6。再瞄一眼 JSON 结构,_vectors 下的键名得是 default:
python3 -c "import json; d=json.load(open('docs_with_vectors.json')); print(list(d[0].keys())); print('vector len:', len(d[0]['_vectors']['default']))"
['id', 'title', 'author', '_vectors']
vector len: 512
vector len: 512 就对了。首次跑模型会从 HuggingFace 下载一次(约 92MB),网络不通时用 HF_ENDPOINT=https://hf-mirror.com 镜像;模型缓存到本地后离线也能跑。
第四步:把带向量的文档传进 Meilisearch
上一步的产物 docs_with_vectors.json 还在本地,这一步把它整个喂给 Meilisearch。_vectors 是 Meilisearch 的保留字段(专门存预计算向量的地方),键名对应第二步 embedder 的名字,这里都是 default。上传走异步任务,跟系列里传普通文档是同一套轮询套路。
curl -X POST http://localhost:7700/indexes/books_vec/documents \
-H "Authorization: Bearer aSampleMasterKey" \
-H "Content-Type: application/json" \
--data-binary @docs_with_vectors.json
{
"taskUid": 18,
"indexUid": "books_vec",
"status": "enqueued",
"type": "documentAdditionOrUpdate",
"enqueuedAt": "2026-10-11T10:05:00.000Z"
}
等任务成功,轮询到 succeeded 再往下走:
curl http://localhost:7700/tasks/18 -H "Authorization: Bearer aSampleMasterKey"
{
"uid": 18,
"indexUid": "books_vec",
"status": "succeeded",
"type": "documentAdditionOrUpdate",
"details": {
"receivedDocuments": 6,
"indexedDocuments": 6
}
}
验证就一条:status: "succeeded"、indexedDocuments: 6。要是任务状态变成 failed,看 error.code——最常见的是向量维度不对(invalid_vector_dimensions)或 _vectors 字段格式不对(invalid_vectors_type),排查入口见Meilisearch 向量维度与 embedder 配置不一致报 invalid_vector_dimensions。
第五步:生成查询向量,跑一次纯语义搜索
文档进去了,关键一步来了。userProvided 模式下,检索时 Meilisearch 不会自己嵌入你的查询词,查询向量也得你这边生成好传过去。这一步先给查询文本算一个 512 维向量,再带上 hybrid 参数发搜索:hybrid(混合检索)对象里 embedder 指名用哪个嵌入器,semanticRatio(语义占比)控制关键词与语义结果的比例,1.0 就是只看语义、完全不看关键词。
先扩展一下 embed.py,或者单独写个 query_embed.py:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-small-zh-v1.5")
query = "仰望星空,讲人类文明命运的科幻巨作"
v = model.encode(query, normalize_embeddings=True)
print(",".join(str(round(x, 6)) for x in v[:8]))
跑出查询向量(这里只打印前 8 个数示意,实际请求要整条 512 维):
python query_embed.py
0.023451,-0.056789,0.011234,0.087654,-0.034567,0.065432,-0.021345,0.045678
把整条 512 维向量放进 vector 参数发搜索:
curl -X POST http://localhost:7700/indexes/books_vec/search \
-H "Authorization: Bearer aSampleMasterKey" \
-H "Content-Type: application/json" \
-d '{"vector":[0.023451,-0.056789,0.011234,0.087654,-0.034567,0.065432,-0.021345,0.045678, /* ...省略 504 个,实际放满 512 维 */ 0.012345],"hybrid":{"embedder":"default","semanticRatio":1.0}}'
返回体按官方文档的结构大致长这样(具体分值、命中以你本地实测为准):
{
"hits": [
{
"id": 1,
"title": "三体",
"author": "刘慈欣",
"_semanticScore": 0.82
},
{
"id": 2,
"title": "球状闪电",
"author": "刘慈欣",
"_semanticScore": 0.71
}
],
"query": "仰望星空,讲人类文明命运的科幻巨作",
"semanticHitCount": 6,
"estimatedTotalHits": 6
}
验证方式是做一次对照实验:把同样的查询词拿去跑普通关键词搜索,结果会完全不同。
curl -X POST http://localhost:7700/indexes/books_vec/search \
-H "Authorization: Bearer aSampleMasterKey" \
-H "Content-Type: application/json" \
-d '{"q":"仰望星空,讲人类文明命运的科幻巨作"}'
{
"hits": [],
"query": "仰望星空,讲人类文明命运的科幻巨作",
"processingTimeMs": 1,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 0
}
关键词搜索一个词都对不上书名,返回空;语义搜索靠向量相似度把“讲人类文明命运的科幻巨作”映射到三体。要是这一步返回 400,先查 hybrid 结构——带 vector 却漏了 hybrid 参数会报 missing_search_hybrid,semanticRatio 写出 0 到 1 之外会报 invalid_search_semantic_ratio,排查入口见Meilisearch userProvided 检索漏带 vector 或 hybrid 结构写错和Meilisearch semanticRatio 越界或 embedder 名写错报错。
第六步(进阶):retrieveVectors 把存进去的向量捞出来看
到这一步功能已经全通了,加一个验证手段:把文档里存的向量捞出来看看,确认数据真的落库了。Meilisearch 默认返回结果不带 _vectors,要显式开两个开关:displayedAttributes(返回文档时展示哪些字段)里加 _vectors,搜索请求带 retrieveVectors: true。不加第一步,第二步的 retrieveVectors 等于白开。
curl -X PATCH http://localhost:7700/indexes/books_vec/settings \
-H "Authorization: Bearer aSampleMasterKey" \
-H "Content-Type: application/json" \
-d '{"displayedAttributes":["id","title","author","_vectors"]}'
{
"taskUid": 21,
"indexUid": "books_vec",
"status": "enqueued",
"type": "settingsUpdate",
"enqueuedAt": "2026-10-11T10:10:00.000Z"
}
等任务 succeeded 后,重新搜一次,带上 retrieveVectors:
curl -X POST http://localhost:7700/indexes/books_vec/search \
-H "Authorization: Bearer aSampleMasterKey" \
-H "Content-Type: application/json" \
-d '{"vector":[0.023451,-0.056789,0.011234,0.087654,-0.034567,0.065432,-0.021345,0.045678,0.012345],"hybrid":{"embedder":"default","semanticRatio":1.0},"retrieveVectors":true}'
{
"hits": [
{
"id": 1,
"title": "三体",
"author": "刘慈欣",
"_vectors": {
"default": [0.023451, -0.056789, 0.011234, "…共 512 个数…"]
}
}
],
"semanticHitCount": 6
}
验证:返回的 hit 里能看到 _vectors.default 一整个 512 长度的数组。看到这个,说明向量确确实实存在索引里,检索走的也是它。顺手记住这个组合(displayedAttributes + retrieveVectors),下次排查“向量到底存没存进去”就用它。
核心概念
下面这张图是整条调用链路的走向,从左到右就是一次纯语义检索的完整路径。

| 概念 | 是什么 | 类比 |
|---|---|---|
| embedder | 索引设置里声明“向量从哪来、多长”的配置项 | 快递柜上开好的储物格,声明了格子大小才能塞东西 |
| userProvided | embedder 的一种 source,表示向量全部由调用方生成并随文档传入 | 自备食材去下馆子,店只负责做不负责买菜 |
_vectors |
文档里存放预计算向量的保留字段,键名与 embedder 名一致 | 快递单上的体积尺寸标签,仓库按它分配货位 |
| semanticRatio | 检索参数,控制关键词与语义结果的比例,0.0 纯关键词、0.5 均衡、1.0 纯语义 | 调音台的音量旋钮,拧到哪头就听哪头 |
| hybrid search | 关键词检索与向量检索并行执行后合并排序的检索模式 | 查重名再查意思,两条路都走 |
| 向量(embedding) | 把文本编码成一串数字,语义相近的文本数字序列在空间中更靠近 | 城市里的坐标点,住得近的人生活习惯更像 |
常见问题
invalid_vector_dimensions
现象:上传带 _vectors 的文档或检索时报 400,错误码 invalid_vector_dimensions,官方 message 大意是“向量维度与 embedder 声明的维度不一致”。
原因:_vectors 里的向量维度跟第二步配置的 dimensions 对不上。最常见是换了嵌入模型(比如从 bge-small-zh-v1.5 换成 bge-base-zh-v1.5,输出从 512 维变 768 维)但 embedder 配置没跟着改,或者 embed.py 里向量算完没按 512 维输出。
相关文章:Meilisearch 向量维度与 embedder 配置不一致报 invalid_vector_dimensions
missing_search_hybrid
现象:userProvided 模式检索时返回 400,错误码 missing_search_hybrid 或 invalid_search_hybrid_query。
原因:带了 vector 参数却漏写 hybrid 对象(missing_search_hybrid),或者 hybrid 结构写错、键名拼错(invalid_search_hybrid_query)。userProvided 模式下 vector 和 hybrid 是成对出现的,缺一个都不行。
invalid_search_semantic_ratio
现象:检索时返回 400,错误码 invalid_search_semantic_ratio 或 invalid_search_embedder。
原因:semanticRatio 写出了 0.0 到 1.0 的范围(比如手滑写 1.5),或者 hybrid.embedder 写的名字跟第二步配置的不一致(比如写了 "Default" 而配置的是 "default")。
