向量与语义搜索 — CodeRoadMap向量与语义搜索
Vector、semantic、hybrid。
向量与语义搜索:Embedding、kNN 与混合检索
这一课你将能做到
- 说清楚「词项匹配」与「语义匹配」的差别,以及 embedding 向量为什么能表达语义
- 建一个含 dense_vector 字段的题库索引,写入向量并用 8.x 的 kNN 查询做语义搜题
- 写出向量 + 关键词的混合检索(hybrid),知道 RRF 融合排序解决什么问题
为什么要学
关键词搜索有一道天花板:用户的词必须和原文「对得上词项」。学员搜「方法自己调自己事务还管用吗」,关键词分词后和题干「Spring 事务自调用失效」对不上——意思完全一样,词没有一个重合。语义搜索不看字面,把文本变成高维空间里的一个点(向量),意思相近的文本在空间里距离就近,于是「怎么问都能搜到」。8.x 已经把向量搜索做成原生功能,不需要外挂 Faiss/Milvus。
人话:embedding 模型像一个「语义坐标定位器」,送进一句话,吐出一串浮点数(如 512 维),你可以理解为这句话在 512 维语义地图上的坐标。两句话意思接近,坐标就接近。kNN(k Nearest Neighbors,k 近邻)就是「在地图上找离我最近的 k 个点」。本站增强搜索的路线是:关键词检索保底(精准、可解释),向量检索补召回(同义、换说法),两者融合。
先建立图像
两种检索对比:
BM25 关键词:
「自调用 事务」 → 查倒排词项 → 字面重合才命中
问「方法自己调自己」→ 0 命中
kNN 语义:
问题 → embedding 模型 → [0.12, -0.88, ... 共512维]
题库每道题 → 同模型 → 各自坐标
余弦相似度最高的 k 道题 → 即使无一字重合也命中
向量索引不是暴力算距离:ES 底层用 HNSW(分层可导航小世界图)近似最近邻结构,把候选组织成图,搜索沿图快速逼近最近点,用可控的少量精度损失换百倍速度。
跟着做
步骤 1:生成 embedding
向量必须由同一个模型生成问题向量和文档向量,维度才一致。两种方式。
方式 A:外部模型(学习最常用)。用 Python + sentence-transformers 跑中文小模型:
# pip install sentence-transformers
from sentence_transformers import SentenceTransformers
import json
model = SentenceTransformers("BAAI/bge-small-zh-v1.5") # 输出 512 维
def embed(text):
return model.encode(text, normalize_embeddings=True).tolist()
print(json.dumps(embed("Spring 事务自调用为什么失效")))
把输出的浮点数组用于下面的写入与查询。批量导题时对题干(或题干+解析拼接)逐题生成向量,随 Bulk 一起写入。
方式 B:用 ES 8.11 的 inference API,先在集群部署文本嵌入模型,再调用:
curl -u elastic:abcd1234 -X POST "http://localhost:9200/_ml/inference/my-bge/_infer" \
-H "Content-Type: application/json" \
-d'{"docs":[{"text_field":"Spring 事务自调用为什么失效"}]}'
生产环境也可调用自研/云端 embedding 服务,应用层负责把向量写进 ES。
步骤 2:建含向量字段的索引
curl -u elastic:abcd1234 -X PUT "http://localhost:9200/questions_vec" -H "Content-Type: application/json" -d'
{
"mappings": {
"properties": {
"stem": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
},
"lessonTitle": { "type": "text", "analyzer": "ik_max_word" },
"tags": { "type": "keyword" },
"courseId": { "type": "keyword" },
"difficulty": { "type": "byte" },
"published": { "type": "boolean" },
"embedding": {
"type": "dense_vector",
"dims": 512,
"index": true,
"similarity": "cosine"
}
}
}
}'
参数解释:dims 必须与模型输出维度一致;index: true(默认)表示为向量构建 HNSW 图索引;similarity 选 cosine(余弦相似度,向量归一化后最常用),也可选 dot_product、l2_norm。
步骤 3:写入带向量的文档
curl -u elastic:abcd1234 -X PUT "http://localhost:9200/questions_vec/_doc/1" -H "Content-Type: application/json" -d'
{
"stem": "Spring 事务在什么情况下会失效?自调用为什么不生效?",
"tags": ["spring", "事务"],
"courseId": "C_JAVA",
"difficulty": 2,
"published": true,
"embedding": [0.0123, -0.0881, 0.231]
}'
实际数组要写满 512 维,示例只写前 3 维示意。批量场景仍走 Bulk:向量放在数据行里一起导入,注意带向量的单体会明显变大(512 维 float 约 2KB),Bulk 批次大小相应调小。
步骤 4:kNN 语义搜索
curl -u elastic:abcd1234 -X GET "http://localhost:9200/questions_vec/_search" -H "Content-Type: application/json" -d'
{
"knn": {
"field": "embedding",
"query_vector": [0.0131, -0.0792, 0.228],
"k": 10,
"num_candidates": 100
},
"_source": ["stem", "tags", "difficulty"],
"size": 10
}'
参数含义:k 是最终返回的最近邻数量;num_candidates 是每个分片在 HNSW 图上探索的候选池大小——值越大越准但越慢,经验上取 k 的 5–10 倍。hit 的 _score 是余弦相似度换算的相关性分(越接近 1 越相似)。
步骤 5:kNN 加过滤条件
语义搜索同样要限定课程、上下架。在 knn 里直接带 filter(8.x 支持在向量检索阶段下推过滤,而不是先取再过滤):
curl -u elastic:abcd1234 -X GET "http://localhost:9200/questions_vec/_search" -H "Content-Type: application/json" -d'
{
"knn": {
"field": "embedding",
"query_vector": [0.0131, -0.0792, 0.228],
"k": 10,
"num_candidates": 100,
"filter": {
"bool": {
"must": [
{ "term": { "courseId": "C_JAVA" } },
{ "term": { "published": true } }
]
}
}
}
}'
步骤 6:混合检索——关键词与向量各取所长
同一个请求里同时给 query 和 knn,8.x 会融合两路结果(分数合并):
curl -u elastic:abcd1234 -X GET "http://localhost:9200/questions_vec/_search" -H "Content-Type: application/json" -d'
{
"query": {
"bool": {
"must": [
{ "multi_match": {
"query": "事务自调用",
"fields": ["stem^3", "lessonTitle"]
}}
],
"filter": [
{ "term": { "published": true } }
]
}
},
"knn": {
"field": "embedding",
"query_vector": [0.0131, -0.0792, 0.228],
"k": 10,
"num_candidates": 100
},
"size": 10,
"highlight": {
"fields": { "stem": {} }
}
}'
两路分数尺度不同(BM25 无上界、余弦在 0–1),直接相加容易失衡。8.8 起支持 RRF(Reciprocal Rank Fusion,倒数排名融合):不看具体分,只看两路各自的名次,按 1/(k+rank) 融合,尺度无关、调参极少:
"retriever": {
"rrf": {
"retrievers": [
{ "standard": { "query": { "match": { "stem": "事务自调用" } } } },
{ "knn": {
"field": "embedding",
"query_vector": [0.0131, -0.0792, 0.228],
"k": 10,
"num_candidates": 100
}}
],
"rank_constant": 20,
"rank_window_size": 50
}
}
RRF retriever 写法在较新的 8.x 小版本中可用;若你的版本不支持 retriever,就用上面的 query+knn 融合写法并接受默认加权。本站落地策略:默认混合检索,关键词路保证「专有名词、代码片段」精准命中,向量路兜住「口语化提问」。
讲清楚要点
- embedding 的质量决定语义搜索上限:中文题库要用中文(或多语)模型;文档端输入用「题干 + 解析」拼接能让语义更完整,但会让「解析里提到的无关知识」干扰定位,需要实测权衡。
- HNSW 是近似索引:可调参数在建索引时设置(
m 控制图连接数、ef_construction 控制建图质量),查询时用 num_candidates(对应 ef)调精度/速度。默认值对多数场景够用。
- 维度不是越高越好:高维向量存储和图索引成本线性上升,512/768 维的小模型在题库场景性价比最高;模型升级换维度必须建新索引 reindex。
- 向量路怕「硬过滤后候选太少」:先过滤再 kNN 时,若某课程只有几十道题,HNSW 优势丧失但结果仍正确;num_candidates 不用盲目调大。
- 成本意识:embedding 生成(GPU/API 费用)、向量存储(每题多 2–4KB)、图构建都是新增成本。本站 5 万题规模完全可承受;纯关键词够用的小站不必为了「AI 感」上向量。
你可能会懵的地方
- 向量搜索会不会取代 BM25?
不会。专有名词、错误码、代码标识符(
OOM、NPE、409)上 BM25 精确且可解释;向量可能「语义漂移」召回似是而非的结果。主流生产系统是混合检索。
- 相似度为什么选 cosine?
余弦衡量方向差异、对向量模长不敏感,适合「语义方向」;向量已归一化时 cosine 与 dot_product 等价,dot_product 计算略快。l2(欧氏距离)适合需要绝对距离含义的场景。
- embedding 存在
_source 里是不是很浪费?
向量在 _source 里占传输体积,搜索接口一律通过 _source excludes 排除它;索引本身为打分独立存储向量值。
- 换了 embedding 模型,旧向量怎么办?
新旧向量不在同一语义空间,不能混用。必须用新模型全量重新生成并 reindex;所以模型选择在项目初期要做评测。
- kNN 的 k 和 size 有什么不同?
k 是每分片向量检索返回的近邻数;size 是最终响应条数。多分片下每分片各取 k,协调节点再合并取 size,k 通常 ≥ size。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 问题和文档用不同模型生成向量 | 语义空间不一致,结果全乱 | 全链路同一模型/版本 |
| dense_vector 忘设 dims 或与模型不符 | 建索引/写入报错 | 与模型输出严格对齐 |
| num_candidates 设得比 k 还小 | 候选不足、结果退化 | ≥k,常取 5–10 倍 |
| 向量分与 BM25 分直接相加不管尺度 | 一路被另一路淹没 | RRF 融合或做归一化 |
| 搜索结果返回 embedding 字段 | 响应膨胀 | _source excludes |
| 用脚本_score 暴力算向量距离 | 全量计算、极慢 | 用原生 knn + HNSW |
卡点急救
- kNN 返回的题明显不相关:先确认问题向量与文档向量来自同一模型;检查 similarity 设置是否与模型建议一致(归一化向量用 dot_product/cosine);再调大 num_candidates 排除近似误差。
- 写入报维度不匹配:
dims 是 512 就必须每条 512 个浮点数,不多不少。检查 embedding 服务输出维度和数组 JSON 是否被截断。
- 混合检索一路独大:默认加权不合理时优先改用 RRF;或在应用层分别请求两路、按业务规则(关键词命中数、相似度阈值)自行融合,可控性最高。
面试常问
-
什么是向量搜索?和传统全文检索什么区别?
用 embedding 模型把文本映射为高维向量,语义相近的文本向量距离就近,检索时用 kNN(ES 底层 HNSW 近似索引)找最近邻,不依赖字面词项重合;全文检索基于倒排索引和 BM25,要求词项匹配并按词频/罕见度打分。前者能听懂换说法的提问,后者在专有名词上更精准,生产中常混合使用。
-
ES 的 kNN 查询参数 k 和 num_candidates 怎么理解?
k 是每个分片最终要的近邻条数;num_candidates 是在 HNSW 图上实际跟踪探索的候选数,候选池越大召回越准但延迟越高,通常取 k 的 5–10 倍。多分片结果由协调节点合并。
-
什么是混合检索?为什么需要它?
同一次搜索并行执行关键词(BM25)与向量(kNN)两路再融合结果:关键词路保证标识符、精确术语命中,向量路补充语义召回。两路分数量纲不同,常用 RRF 按排名倒数融合,避免分数尺度问题。
自测三问
- 用户搜「bean 自己调自己事务咋没了」零关键词命中,向量路为什么能救场?
- dense_vector 的 dims、index、similarity 各管什么?换模型时哪个会成为障碍?
- 为什么向量分和 BM25 分直接相加不公平?RRF 利用什么信息绕开了这个问题?
小结一句话
语义搜索把文本变成坐标、把搜索变成找点:dense_vector 存向量、HNSW 加速 kNN,而生产答案是混合检索——关键词管精准、向量管召回、RRF 管融合。
延伸