上一章讲了基础知识,文本可以转成向量,可以根据query进行召回。那在现实场景中怎么存储,怎么搜索呢?
向量数据库对比
下面是一些常用的向量数据库,我VikingDB用的多一些。
| 名称 | 类型 | 部署方式 | 规模上限 | 核心优势 | 核心劣势 | 适用场景 |
|---|---|---|---|---|---|---|
| Chroma | 轻量开源 | 本地 / 嵌入 | 百万级 | 5 行代码跑通,Python 友好,LangChain 标配 | 单机,功能少,性能一般 | 原型验证、小项目、个人开发 |
| Qdrant | 开源 | 自部署 / SaaS | 亿级 | Rust 写的,单机性能极强,混合检索好 | 分布式不如 Milvus 成熟,国内社区小 | 中大型项目,追求单机性能 |
| pgvector | PG 扩展 | 自部署 | 千万级 | 不用学新库,SQL 直接查,事务 / 权限全有 | 性能不如专用向量库,规模受 PG 限制 | 已有 PG 栈,小中型项目 |
| Weaviate | 开源 | 自部署 / SaaS | 亿级 | 知识图谱 + 向量一体化,GraphQL 灵活 | 纯向量性能一般,国内用户少 | 知识图谱 + RAG、复杂语义查询 |
| Milvus | 开源 | 自部署 | 十亿级 + | 分布式最强,功能最全,国内社区活跃 | 部署复杂,组件多,运维成本高 | 超大规模、企业级生产环境 |
| Pinecone | 闭源 SaaS | 全托管 | 十亿级 + | 零运维,自动扩容,稳定有 SLA | 贵,闭源,数据在海外有合规风险 | 不想运维、快速上线、海外项目 |
| VikingDB | 云服务 | 火山引擎 SaaS | 万亿级 | 字节自研,千亿级验证,混合检索强,内置豆包 embedding | 绑定火山生态,开源版能力有限 | 国内企业、大规模场景、火山生态 |
| Elasticsearch | 搜索引擎 | 自部署 / 云 | 亿级 | 全文 + 向量混合检索最强,已有 ES 栈零成本接入 | 8.0 才支持 ANN,纯向量性能一般 | 已有 ES、需要全文 + 语义混合检索 |
| ClickHouse | OLAP 数据库 | 自部署 | 亿级 | SQL 分析能力极强,向量化引擎快,和数仓集成好 | 向量功能新(25.8 才 GA),生态不如专用库 | 已有 CH 数仓、需要分析 + 向量检索 |
| Redis Vector | 缓存 / 数据库 | 自部署 / 云 | 千万级 | 速度极快,已有 Redis 栈直接用 | 规模有限,功能简单 | 缓存 + 轻量向量、已有 Redis 栈 |
| SQLite + sqlite-vec | 嵌入式扩展 | 十万级 | 零依赖、超轻量、全平台、WASM 支持 | 无 ANN 索引,规模大了慢 | 移动端、嵌入式、本地小工具、浏览器端 |
SQLite
安装
OpenClaw 选择 SQLite 作为存储引擎,那就试试怎么用吧。配合两个关键扩展
sqlite-vec:向量搜索扩展,支持余弦相似度计算,实现高效的 ANN(近似最近邻)检索。FTS5:SQLite 内置的全文搜索引擎,支持 BM25 排序算法。
OpenClaw用SQLite主要是为了Local First。
shell
#是否安装了,我mac上自带了
sqlite3 --version
#没有的话就安装一下
brew install sqlite
#安装 sqlite-vec 扩展。mac要在虚拟环境里安装。
pip3 install sqlite-vec
# 检查有没有fts5
sqlite3 :memory: "CREATE VIRTUAL TABLE t USING fts5(content);"
BM25排序算法
BM25(Best Match 25)是全文搜索里最经典的相关性排序算法,用来算"查询词和文档的相关程度",分数越低越相关(SQLite FTS5 里就是这样)。
📐 BM25 公式
核心公式长这样:

看着复杂,拆开来看就三部分:
🔍 拆解成大白话
第一部分:IDF(逆文档频率)

意思 :越稀有的词,权重越高。
- "的"、"是"这种到处都有的词 → IDF 低,几乎不加分
- "量子力学"这种很少见的词 → IDF 高,匹配上了加很多分
第二部分:词频(TF)的饱和曲线

意思 :词出现次数越多越相关,但有上限,不会无限涨。
- 出现 1 次 → 加很多分
- 出现 5 次 → 比 1 次分高,但不是 5 倍
- 出现 100 次 → 和出现 20 次差不多,饱和了
对比 TF-IDF:TF-IDF 是线性的,出现 100 次就是 100 倍分,不合理。BM25 是曲线,越往上越平。
第三部分:文档长度归一化

意思 :长文档要惩罚。
- 文档越长,词出现的概率天然就高,不公平
- 同样出现 5 次"苹果",在 100 字的短文里比在 10000 字的长文里更有意义
- 文档越长,分母越大,整体分数越低(惩罚)
⚙️ 两个关键参数
| 参数 | 常用值 | 作用 |
|---|---|---|
| k1 | 1.2 ~ 2.0 | 控制词频饱和速度。k1 越小,越快饱和 |
| b | 0.75 | 控制文档长度惩罚强度。b=0 不惩罚,b=1 全惩罚 |
SQLite FTS5 默认值:k1=1.2, b=0.75,是行业标准值,一般不用改。
📊 举个例子
假设查询是 "苹果",有三个文档:
| 文档 | 长度 | "苹果"出现次数 | BM25 分数 | 排名 |
|---|---|---|---|---|
| A(短文) | 100字 | 3次 | 1.2 | 🥇 最相关 |
| B(长文) | 5000字 | 10次 | 2.5 | 🥈 |
| C(中等) | 500字 | 1次 | 3.8 | 🥉 |
注意:SQLite FTS5 的 bm25() 返回的是距离,越小越相关。上面的分数是反过来的概念。
为什么 A 排第一?
- 虽然出现次数少,但文档短,"浓度"高
- BM25 认为短文档里出现 3 次,比长文档里出现 10 次更能说明相关
🆚 BM25 vs TF-IDF
| 维度 | TF-IDF | BM25 |
|---|---|---|
| 词频关系 | 线性(出现10次=10倍分) | 饱和曲线(有上限) |
| 文档长度 | 不考虑 / 简单归一化 | 专门的长度归一化公式 |
| 参数 | 无 | k1、b 可调 |
| 效果 | 基础可用 | 更精准,工业界标准 |
一句话:BM25 是 TF-IDF 的进化版,考虑了词频饱和和文档长度,排序更合理。
💡 在 SQLite FTS5 里怎么用
sql
-- 默认用法,按 bm25 排序(分数越小越相关)
SELECT title, bm25(docs_fts) as score
FROM docs_fts
WHERE docs_fts MATCH '苹果'
ORDER BY score;
-- 自定义参数(k1=1.5, b=0.8),一般不用改
SELECT title, bm25(docs_fts, 1.5, 0.8) as score
FROM docs_fts
WHERE docs_fts MATCH '苹果'
ORDER BY score;
🎯 总结
BM25 的核心思想就三句话:
- 稀有词权重高(IDF)
- 词频有上限(饱和曲线)
- 长文档要惩罚(长度归一化)
它是 20 多年前发明的算法,但至今仍是全文搜索的工业标准,Elasticsearch、SQLite FTS5、Lucene 等默认都是 BM25。
实战
python
import sqlite3
import sqlite_vec
import struct
import numpy as np
db = sqlite3.connect("hybrid_test.db")
db.enable_load_extension(True)
sqlite_vec.load(db)
db.enable_load_extension(False)
# 建表
db.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
title,
content,
content_rowid='id'
)
""")
db.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS docs_vec USING vec0(
id INTEGER PRIMARY KEY,
embedding float[8] -- 用8维演示,实际用1536维
)
""")
# ========== 写入测试数据 ==========
# 注意:FTS5 默认按空格分词,中文要手动分词后用空格隔开
# 这里先用英文+数字演示,确保能搜到
docs = [
{"id": 1, "title": "apple fruit", "content": "apple is a red fruit sweet"},
{"id": 2, "title": "banana fruit", "content": "banana is yellow fruit tasty"},
{"id": 3, "title": "car vegetable", "content": "car is not a vegetable"},
]
for doc in docs:
# 写入全文索引
db.execute("""
INSERT INTO docs_fts(rowid, title, content) VALUES (?, ?, ?)
""", (doc["id"], doc["title"], doc["content"]))
# 写入向量(随机生成8维向量)
vec = np.random.rand(8).astype(np.float32)
db.execute("""
INSERT INTO docs_vec(id, embedding) VALUES (?, ?)
""", (doc["id"], struct.pack("8f", *vec)))
db.commit()
# ========== 测试1:全文搜索 ==========
print("=== 全文搜索 'fruit' ===")
fts_results = db.execute("""
SELECT rowid, title, content, bm25(docs_fts) as score
FROM docs_fts
WHERE docs_fts MATCH 'fruit'
ORDER BY score
LIMIT 10
""").fetchall()
for r in fts_results:
print(f" ID:{r[0]} | {r[1]} | score:{r[3]:.4f}")
print(f" 共 {len(fts_results)} 条结果\n")
# ========== 测试2:向量搜索 ==========
print("=== 向量搜索(随机query) ===")
query_vec = np.random.rand(8).astype(np.float32)
vec_results = db.execute("""
SELECT id, distance as score
FROM docs_vec
WHERE embedding MATCH ?
ORDER BY distance
LIMIT 10
""", (struct.pack("8f", *query_vec),)).fetchall()
for r in vec_results:
print(f" ID:{r[0]} | distance:{r[1]:.4f}")
print(f" 共 {len(vec_results)} 条结果\n")
db.close()
print("✅ 测试完成!")
执行结果
shell
(sqlite) ➜ sqlite python3 test.py
=== 全文搜索 'fruit' ===
ID:2 | banana fruit | score:-0.0000
ID:1 | apple fruit | score:-0.0000
共 2 条结果
=== 向量搜索(随机query) ===
ID:3 | distance:0.7758
ID:1 | distance:1.1480
ID:2 | distance:1.5675
共 3 条结果
✅ 测试完成!
命令行执行
shell
# 第一步:终端里打开数据库,如果不存在会自动创建
$ sqlite3 docs_fts.db
# 第二步:进入 sqlite 后,看有哪些表
sqlite> .tables
docs_fts docs_vec
# 第三步:看表结构
sqlite> .schema docs_fts
CREATE VIRTUAL TABLE docs_fts USING fts5(content, content_rowid='id');
# 第四步:查数据
sqlite> .headers on
sqlite> .mode column
sqlite> SELECT * FROM docs_fts LIMIT 5;