别再用 LIKE 搜数据库了:20 行代码搭一个语义搜索引擎

传统 LIKE 搜索只匹配关键词,搜"退款"找不到"退钱"。本文介绍如何用 Sentence-Transformers 和 FAISS 搭建语义搜索引擎,20 行 Python 代码实现向量检索,支持从小规模到十亿级数据的完整方案。

你在文档里搜"退款",结果一个都没有。但文档里明明写着"我想退钱"。

先说结论

传统搜索靠关键词匹配,搜"退款"找不到"退钱",因为机器只认字不认意思。

向量检索把文字变成数字(向量),按语义相似度搜索。搜"退款"能命中"我想退钱",因为它俩意思一样。

Sentence-Transformers 负责编码,FAISS 负责搜索。

20 行 Python,搭一个比 MySQL LIKE 强 100 倍的语义搜索引擎。数据量从一千条到十亿条,都有对应的方案。

下面我从原理到代码,一步步带你跑通。

第一章:向量检索是什么(3 分钟搞懂原理)

文本变向量:让机器理解"意思"

embedding(嵌入)把一段文字编码成一串数字,比如 768 维的浮点数组。

这串数字就是文字的"语义指纹"。意思相近的文字,指纹也相近;意思无关的文字,指纹差很远。

“退款"和"退钱"的关键词不同,但编码出来的向量非常接近。“退款"和"天气预报"的向量则差了十万八千里。

相似度怎么算:余弦 vs L2

算两个向量有多接近,常用两种方式:

方式原理特点
余弦相似度算向量夹角的余弦值值域 [-1,1],越大越相似
L2 距离算欧氏距离值域 [0,∞),越小越相似

大多数语义搜索场景用余弦相似度。配合向量归一化(让每个向量的长度变成 1),余弦相似度等价于向量内积,计算更快。

为什么不用 MySQL LIKE

LIKE 做的是字面匹配。搜"退款”,数据库遍历每一条记录,看有没有"退"和"款"这两个字挨在一起。

  • “如何申请退款” → 命中 ✓
  • “我想把钱退回来” → 不命中 ✗
  • “退订服务” → 不命中 ✗

但这三条说的是同一件事。LIKE 的本质缺陷:它不理解语义,只匹配字符

向量检索的思路完全不同:先把所有文本编码成向量,搜索时把查询也编码成向量,然后找最近的几个。它匹配的是"意思”,不是"字面"。

第二章:Sentence-Transformers — 让文本变成向量

安装和一行代码上手

1
pip install sentence-transformers faiss-cpu

加载模型、编码文本,三行搞定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from sentence_transformers import SentenceTransformer

# 加载中文 embedding 模型
model = SentenceTransformer('BAAI/bge-small-zh-v1.5')

# 把文本编码成向量
texts = ["如何申请退款", "我想把钱退回来", "今天天气不错"]
embeddings = model.encode(texts)

print(embeddings.shape)  # (3, 512) — 3条文本,每条512维

输出 (3, 512) 表示 3 条文本各编码成了 512 维的向量。

中文模型怎么选

这是新手最纠结的问题。我直接给你结论:

模型维度大小适用场景
bge-small-zh-v1.5512~100MB入门首选,速度快
m3e-base768~210MB社区流行,中文适配好
bge-large-zh-v1.51024~1.3GB精度最高,生产推荐
BGE-m31024~2.3GB多语言+多功能,最强

我的建议:学习阶段用 bge-small-zh-v1.5,小快够用。上生产了换 bge-large-zh-v1.5 或 BGE-m3。

避坑:维度不一致不能混用

这是 90% 的新手会踩的坑。

bge-small 输出 512 维,m3e-base 输出 768 维。

如果你用 bge-small 建了索引,后来换成 m3e-base 编码查询,FAISS 直接报错——维度对不上。

规则:编码模型和建索引模型必须是同一个。换模型 = 重建索引,没有例外。

第三章:FAISS — 让向量搜起来飞快

安装

1
2
pip install faiss-cpu       # CPU 版,够用
# pip install faiss-gpu     # GPU 版,百万级以上考虑

3 分钟跑通第一个索引

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import faiss
import numpy as np

# 假设你已经有 1000 条文本的向量(512维)
embeddings = np.random.rand(1000, 512).astype('float32')

# 建立索引
dim = 512
index = faiss.IndexFlatL2(dim)
index.add(embeddings)

print(f"索引中有 {index.ntotal} 条向量")  # 1000

# 搜索:找最相似的 5 条
query = np.random.rand(1, 512).astype('float32')
distances, indices = index.search(query, k=5)

print("最相似的5条索引:", indices[0])
print("对应的L2距离:", distances[0])

四种索引类型决策树

这是这篇文章的核心价值。FAISS 有十几种索引,但 99% 的场景只需要搞懂这四种:

索引类型数据量级速度精度典型场景
IndexFlatL2<10万100%精确原型开发、小数据
IndexIVFFlat百万级近似(可调)中等规模生产
IndexHNSWFlat千万级最快高召回率低延迟在线搜索
IndexIVFPQ十亿级有损压缩超大规模+省内存

怎么选:别想太多,从 IndexFlatL2 开始。数据量到 10 万条以后再考虑换。

我把四种索引的适用场景画个决策路径:

1
2
3
4
5
6
7
8
9
你的数据有多少条?
├── < 10万 → IndexFlatL2(暴力搜索,100%精确,不用调参)
├── 10万~100万 → IndexIVFFlat(聚类分区,平衡速度和精度)
├── 100万~1000万 → IndexHNSWFlat(图索引,速度最快)
└── > 1000万 → IndexIVFPQ(乘积量化压缩,省内存)

索引的保存和加载

90% 的新手忘的一步。程序退出,内存里的索引就没了。

1
2
3
4
5
# 保存索引到磁盘
faiss.write_index(index, "my_index.faiss")

# 下次直接加载,不用重新编码
index = faiss.read_index("my_index.faiss")

注意:FAISS 只保存向量和索引结构,不保存原始文本。你需要自己维护一个"索引ID → 原始文本"的映射(比如存一个 JSON 或列表)。

第四章:实战 — 搭一个能用的中文语义搜索引擎

完整可运行代码

下面这段代码复制就能跑。我准备了 10 条模拟 FAQ 数据,展示语义搜索的效果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np
import time

# ========== 1. 准备数据 ==========
faqs = [
    "如何申请退款?请在订单页面点击申请退款按钮。",
    "我想把钱退回来,应该怎么操作?",
    "退款多久能到账?一般3-5个工作日。",
    "取消订单的步骤是什么?进入我的订单,选择取消。",
    "怎么取消已经付款的购买?联系客服处理。",
    "会员到期了怎么续费?在设置中点击续费。",
    "密码忘记了怎么找回?点击登录页的忘记密码。",
    "怎么修改绑定的手机号?在账户安全设置中修改。",
    "发票怎么开具?订单完成后在详情页申请。",
    "如何投诉服务质量?请联系客服热线反馈。",
]

# ========== 2. 编码 ==========
model = SentenceTransformer('BAAI/bge-small-zh-v1.5')
embeddings = model.encode(faqs, normalize_embeddings=True)
dim = embeddings.shape[1]

# ========== 3. 建索引(内积相似度 = 归一化后的余弦相似度)==========
index = faiss.IndexFlatIP(dim)
index.add(embeddings.astype('float32'))

# ========== 4. 搜索 ==========
def search(query, top_k=3):
    query_vec = model.encode([query], normalize_embeddings=True)
    scores, indices = index.search(query_vec.astype('float32'), top_k)
    
    print(f"\n搜索: '{query}'")
    print("-" * 50)
    for i, idx in enumerate(indices[0]):
        score = scores[0][i]
        print(f"  [{score:.4f}] {faqs[idx]}")
    return indices[0]

# ========== 5. 效果展示 ==========
search("退款")
search("怎么把钱退回来")
search("取消购买")
search("续费会员")

# ========== 6. 性能测试 ==========
start = time.time()
for _ in range(1000):
    query_vec = model.encode(["测试查询"], normalize_embeddings=True)
    index.search(query_vec.astype('float32'), 5)
elapsed = time.time() - start
print(f"\n1000次搜索耗时: {elapsed:.2f}s (含编码时间)")
print(f"平均每次(含编码): 约 {elapsed/1000*1000:.2f}ms")

运行效果

搜"退款"命中"如何申请退款"和"我想把钱退回来"——关键词完全不同,但语义命中。

搜"取消购买"命中"怎么取消已经付款的购买"和"取消订单的步骤"——“取消"和"购买"拆开了照样匹配。

这就是语义搜索的威力:它理解意思,不依赖关键词重合

性能数据

10 条数据的搜索时间几乎为 0(亚毫秒级)。扩展到 1000 条,纯索引搜索(不含编码)仍在 1ms 以内。

对比 MySQL LIKE 查询 1000 条记录,需要全表扫描 + 逐行字符匹配,通常在 5-50ms。向量检索在语义理解上有质的飞跃,速度也不逊色。

第五章:数据量增长后怎么办

索引迁移路径

数据量增长到一定程度,IndexFlatL2 的暴力搜索会变慢。这时候该换索引了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
IndexFlatL2 (暴力)
    │ 数据量 > 10万,搜索开始变慢
IndexIVFFlat (聚类分区)
    │ 数据量 > 100万,需要更高吞吐
IndexHNSWFlat (图索引)
    │ 数据量 > 1000万,内存吃紧
IndexIVFPQ (乘积量化)

切换时机:当你感觉搜索延迟不可接受(比如超过 100ms)的时候,就该考虑换索引了。不要过早优化。

FAISS vs 其他方案

FAISS 是一个搜索库,不是完整的数据库。它没有增删改查 API,没有持久化层,需要你自己管理。

如果不想自己管这些,可以考虑封装好的向量数据库:

方案类型适用规模特点
FAISS嵌入式库任意轻量无依赖,需自己管持久化
ChromaDB轻量数据库<100万开箱即用,内置持久化
Qdrant数据库千万级Rust 写的,高性能,API 友好
Milvus分布式数据库十亿级生产级,支持分布式

我的建议:原型阶段用 FAISS,够轻够快。产品化了再按需升级。别一上来就上 Milvus,运维成本会吃掉你。

核心建议

别过早优化。从 IndexFlatL2 开始,能跑就先跑着。

等数据量真到了瓶颈再换索引,迁移成本远低于你从第一天就过度设计的成本。这句话适用于 99% 的项目。

结论

向量检索不是黑魔法。

Sentence-Transformers 把文本编码成向量,FAISS 把向量搜得飞快。两个库,20 行代码,你就拥有了一个能理解语义的搜索引擎。

搜"退款"能找到"退钱”,搜"取消购买"能命中"取消订单"。这就是语义搜索和关键词搜索的本质区别。

记住三个要点:从 IndexFlatL2 开始、编码模型和索引维度必须一致、别忘了保存索引到磁盘。

剩下的,动手跑一遍代码就懂了。


关注 varkm,一起学习,一起成长