ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

向量数据库不一定要上:8513个向量的本地语义检索实践

向量数据库不一定要上:8513个向量的本地语义检索实践

1. 先聊清楚“8,513 个向量”的规模,再聊要不要引专用库

1.1 8513个向量占多少内存?可能就是几十MB

我这次要处理的是本地一批知识库文档,按段落拆完之后正好是8513个文本片段。每个片段用一个768维的浮点向量表示,拼成一个二维矩阵,就是(8513, 768)的形状,按float32存储:

8513 × 768 × 4 字节 ≈ 24.9MB

也就是说,全量索引放进内存都不到25MB,这还没有某些图片资源大。很多人一听到“语义检索”就默认要上向量数据库,但先冷静看一下数据规模:8513条记录,一次搜索的最坏情况就是把每条记录都比一遍,而一次点积是768维的乘加运算,整体算下来不过千万次浮点操作。在这种量级上,外部索引结构带来的速度收益几乎感知不到,复杂度却是实打实的。

那么“0.21毫秒”是怎么来的?我用查询向量和这个矩阵做了一次矩阵乘,拿到8513个相似度分数,再取topK排序。整个过程只算搜索阶段,不含文本向量化的耗时。模型把查询句子变成向量的时间,通常在几十毫秒以上,搜索部分反而快了一个数量级。所以在整个链路里,搜索根本不是瓶颈,真正决定效果和体验的是前面两步:文本分块和向量质量。

1.2 不引向量数据库,省下的不止是部署成本

引入一个专用向量数据库意味着什么?至少要做这几件事:装服务、建索引、写客户端连接、配置数据同步、考虑备份和升级。如果你只是在本地维护几千条笔记或文档片段,这些全部属于“为了偶尔炒个菜,先建一座中央厨房”。业务代码里多一个外部依赖,排查问题时就多一个变量:索引没刷新、服务没起来、端口冲突、连接池耗尽,哪个都比矩阵乘本身难缠。

那到底什么规模才需要认真考虑专用库?我的经验是个很宽泛的区间,仅供参考:

  • 几万条以内:内存矩阵加线性扫描完全够用,省心。
  • 几十万到几百万条:线性扫描可能到几十毫秒,开始考虑近似最近邻索引。
  • 上千万条或高并发接口:再讨论分布式向量库。

这不是定理,是成本判断。8513个向量连索引都算不上,它就是一块普通的内存数组。与其叫“不引向量数据库”,不如叫“当前规模下不需要”。后面数据真涨上去了,再按后面的迁移路线走,本文第五节会聊我怎么规划这条路。

2. 检索链路拆开看:切块、向量化、相似度计算

2.1 文本切块:先决定“被检索的基本单元”长什么样

很多人做完语义检索后觉得效果差,回头想是嵌入模型不行,其实问题往往出在索引单元切得太随意。你的检索最终返回的是一段文本,那这段文本就必须是语义上尽量独立的一个单元。如果用一个固定字符长度硬切,很可能把一个完整观点切成两半,或者把两个无关内容凑在一起,向量被平均成一个“什么都是、什么都不像”的混合体。

我的做法是:先按段落拆,段落超长的再按窗口切,窗口之间留一点重叠。

import re def split_text(text, chunk_size=400, overlap=80): text = re.sub(r"\n{3,}", "\n\n", text) paragraphs = [p.strip() for p in re.split(r"\n+", text) if p.strip()] chunks = [] for para in paragraphs: if len(para) <= chunk_size: chunks.append(para) continue start = 0 while start < len(para): end = start + chunk_size chunks.append(para[start:end]) if end >= len(para): break start = end - overlap return chunks

400字符的窗口,对中文来说差不多200字左右,信息密度足够。80字符的重叠能避免一句关键结论刚好被切在分界点上。这个参数不是硬标准,如果你文档里全是技术说明书,300字符更锐利;如果是问答对,直接按一条问答一个单元,根本不用窗口切。

2.2 文本到向量的那一步,到底在算什么

这一步称为嵌入(embedding),本质是把一段文本映射成一个固定维度的数值向量。现在的开源句向量模型基本都能在本地跑,无需把文本传到外部服务,这在处理私有知识库时很重要。

对小白读者可以这样理解:每个文本片段被压缩成了一张“简历”,里面虽然不直接存原文,但把意思编码进了数值分布里。内容越接近的文本,向量在空间里的方向越接近。所以后面比较相似度时,我们不看原始文字,只看两个向量靠不靠近。

选模型时我一般关注三点:

  • 支持的语言:中文语料优先用多语言版本的模型,或者专门在中文语料上调过的变体。
  • 输出维度:常见有384、512、768维,维度越高信息容量越大,但存储和计算成本也线性上涨。
  • 最大输入长度:很多模型默认只能吃512个token,超长会被截断。这也是分块窗口不宜过大的原因。

具体代码上,我习惯把向量化封装成一个函数,先用占位实现,等选好模型直接替换内部逻辑。

import numpy as np # 替换成你的本地嵌入模型调用 def to_embedding(text: str) -> np.ndarray: # 这里应该返回一个一维向量,例如 shape = (768,) # 由你的嵌入模型产生 raise NotImplementedError

2.3 相似度不是“距离越短越好”,这里是余弦相似度

向量空间里的“语义距离”不是直线距离,更常用的是余弦相似度:两个向量方向越一致,值越接近1。公式写作:

cos(a, b) = (a · b) / (|a| × |b|)

这是因为文本向量的绝对值长度受很多无关因素影响。最典型的场景是:同一个意思写短句和长句,比如“猫坐在垫子上”和“猫坐在垫子上,安静地坐着,时不时看看窗外”,如果只看直线距离,两个向量会被长度差拉远;但余弦相似度只看方向,语义相近的文本哪怕长度差很多,方向也基本一致。

落实到代码上有个偷懒技巧:向量做好归一化,也就是让每个向量的模等于1,那么余弦相似度就直接等于内积。这样检索时一次矩阵乘就搞定了,不用每一步都重新算分母。

def normalize(vec): norm = np.linalg.norm(vec) + 1e-12 return vec / norm

那个1e-12是防御分母为零,实际不会出现,但加了更稳。

3. 一套可运行的本地语义检索代码与耗时实测

3.1 建索引:从txt/md目录到matrix.npy

下面是建索引的完整流程。输入是某个目录下的文本文件,输出两个文件:一个matrix.npy向量矩阵,一个meta.json元信息,用来记录每个向量对应的原文出处和文本内容。

import os import json import numpy as np CORPUS_ROOT = "./corpus" def load_corpus(root_dir): chunks = [] for fname in sorted(os.listdir(root_dir)): path = os.path.join(root_dir, fname) if not os.path.isfile(path): continue with open(path, "r", encoding="utf-8") as f: text = f.read() for piece in split_text(text): chunks.append({"source": fname, "text": piece}) return chunks chunks = load_corpus(CORPUS_ROOT) vecs = [] for idx, chunk in enumerate(chunks): vec = to_embedding(chunk["text"]) vecs.append(normalize(vec).astype("float32")) if (idx + 1) % 1000 == 0: print(f"embedded {idx + 1}") X = np.vstack(vecs) np.save("matrix.npy", X) with open("meta.json", "w", encoding="utf-8") as f: json.dump(chunks, f, ensure_ascii=False, indent=2)

8513条文本在全量生成向量时也就几十秒到一两分钟,取决于你的嵌入模型速度。这里有一个容易被忽略的细节:所有向量都要统一float32。float64占内存翻倍,但精度对相似度检索没有任何可感知的提升,纯属浪费。

3.2 查询:一次矩阵乘加一次topK

查询阶段的所有逻辑可以浓缩成下面这个函数:

def search(query_text, top_k=5): X = np.load("matrix.npy") meta = json.load(open("meta.json", "r", encoding="utf-8")) q = normalize(to_embedding(query_text)).astype("float32") scores = X @ q idx = np.argpartition(scores, -top_k)[-top_k:] idx = idx[np.argsort(scores[idx])[::-1]] return [(meta[i]["source"], meta[i]["text"], float(scores[i])) for i in idx]

X @ q这一步返回一个长度8513的数组,里面就是查询向量和每个索引向量的内积。因为两边都已经归一化,这份内积就是余弦相似度。

排序用np.argpartition而不是np.argsort,这里藏着一个性能细节:如果你只需要topK个最大项,不需要知道剩余的8000多个向量谁前谁后。全排序是O(N log N),而部分选择可以做到接近O(N)。当向量数只有8513时差别不大,但这个习惯能让你以后平滑扩展到更大规模。

3.3 如何稳定复现“0.21毫秒”的延迟

测延迟最忌讳的是把冷启动时间和检索时间混在一起。我做过一次错测:第一次查询耗时300多毫秒,后来发现绝大部分时间花在了NumPy库加载和模型推理上。正确做法是先把模型加载好、矩阵也读进内存,预热几次,再用循环算平均值。

import timeit # 预热 for _ in range(3): search("示例查询") t = timeit.timeit( lambda: search("给本地知识库搭一套语义检索"), number=200 ) / 200 print(f"average: {t * 1000:.3f} ms")

注意这里的时间统计对象是整个search函数,它内部还包含了一次文本向量化,如果嵌入模型慢,测出来就不是0.21毫秒。这是合理的,因为你真的在跑完整流程。我在基准测试时,会先把查询向量算好,单独统计matrix @ q和topK的部分,这样能明确看到检索本身的开销。

实测环境是普通办公级CPU、Python3.10、NumPy默认配置,多线程BLAS处于开启状态。8513个向量取top5,稳定在0.21毫秒左右。topK改成20也几乎没变化,因为排序占比很小。

3.4 两个能再快一点的细节

第一,矩阵文件加载后不要反复np.load,全局变量只加载一次。上面的search函数每次都在加载文件,实际项目绝不会这么写。应该把矩阵和元数据放到模块级或缓存里,查询只做矩阵乘和排序。

第二,如果结果不要求实时,可以考虑加一层缓存:相同或相似的查询直接返回上次结果。这在知识库工具里很常见,毕竟用户反复问同一类问题的概率很高。

4. 从0.21毫秒到百万向量:暴力检索的边界与升级路径

4.1 复杂度账:线性扫描为什么能撑这么久

暴力检索的复杂度是O(M × D),M是索引向量数,D是向量维度。维度固定为768时,耗时基本随M线性增长。8513条是0.21毫秒,那么理论上:

  • 1万条:约0.25毫秒
  • 10万条:约2.5毫秒
  • 100万条:约25毫秒
  • 1000万条:约250毫秒

不过这只是粗略外推,实际还要看内存带宽、CPU缓存和BLAS实现。但趋势很明确:在十万级以下,单次检索都能保持在个位数毫秒级别,这对本地知识库工具完全够用。

还有一点容易被忽略:如果你用Python列表逐个算点积,而不是一次性矩阵乘,复杂度同样是线性,但常数会大几十倍。8513条可能直接变成几十毫秒。所以“暴力检索慢”这句话经常是没用向量化计算造成的误判。先把数据堆成连续矩阵,让底层BLAS做批量运算,比上任何索引都立竿见影。

4.2 数据涨上去之后,先加索引还是直接换库

如果哪一天索引向量涨到几十万,甚至上百万,我的建议不是立刻上专用数据库,而是先做两件事:

一是考虑是否需要索引。你可以继续用暴力扫描,100万条25毫秒,很多内部工具都扛得住。先问自己“延迟预算到底是多少”。如果一次人工查询可以接受50毫秒,那暴力扫描还能再用很久。

二是如果延迟预算吃紧,再上近似最近邻索引。这类索引的核心思路是“不跟全部向量比较,只跟一小部分比较”。常见的HNSW结构,把向量组织成一张多层图,查询时从顶层快速向下,每层只看少量节点;IVF则先把向量聚类成几百个桶,查询时先定位最近的几个桶,再桶内精排。两者都会引导出一种“先用索引缩小候选集,再对候选集做精确打分”的流程。

到这一步,你仍然不需要引入完整向量数据库,一个开源ANN索引库就能搞定。换库的成本在于:多一个服务要部署、多一套配置要维护、索引更新策略要重新设计。做之前先把收益算清楚。

4.3 一个低成本中间方案:先粗筛后精排

如果你担心未来数据量涨上去,又不想立刻换基础设施,可以尝试聚类粗筛。原理很简单:给全部向量做一次KMeans聚类,比如分成256组,每个组有一个中心向量。查询时先算一下查询向量和256个中心的相似度,挑出最相近的几个组,只在这些组内部做精确检索。

这一步本质上就是IVF索引的朴素实现,但代码量很小。8513条向量,分32个组,查询时可能只需要比较其中几百条,延迟能从0.21毫秒降到更低,当然这个量级已经无所谓了。真正的价值在于:当你把数据换成20万条时,这种粗筛结构能让你继续维持低延迟,而不用马上请出专用库。

代价是召回率会打一点折扣,因为最相似的那个向量可能落在你没选的组里。粗筛分组越多,召回损失越大;分组越少,精排的压力越大。需要在自己数据上跑一下调分组数,属于典型的“用少量召回换速度”的工程取舍。

5. 落地时会遇到的几个坑与调优经验

5.1 固定长度切块很省事,但对长文不友好

前面给的split_text是按“先段落、再窗口”的逻辑。如果偷懒直接按固定400字符从头切到尾,很容易切碎上下文。我踩过很典型的坑:一篇技术文档里的“注意事项”被切到下一条的开头,查询“配置文件在哪里”时,返回的内容只有半句话。

后来我把切块逻辑改成“标题感知”的:文档先按Markdown标题或段落拆分,每个标题下的小节作为基本单元;基本单元过长才用窗口二次切。这个改动让检索命中率明显提升,因为索引单元和语义单元的边界重合了。

一个简单版本是:先按\n拆段落,段落长度超过阈值的再按窗口切。千万别把一个长段落直接整段喂给模型,超过模型最大长度后,超出的部分会被截断,等于你索引的内容和原文不是同一段话。

5.2 别把相似度分数直接当置信度

很多人做好检索后,习惯定一个阈值,比如“分数低于0.7就判定为不相关”。这个习惯在语义检索里很危险,原因有两个:

第一,不同嵌入模型的输出分数分布差异很大。同一个语料,换一个模型后,原本最低0.6的相似度可能整体降到0.3。阈值跟着模型走,不能跨模型套用。

第二,语义相似度和“事实正确”完全是两回事。用户问“报销流程是什么”,知识库里有一条“报销需先提交申请单”的片段,语义上高度相似,分数可能很高,但它回答不了完整流程。所以检索结果需要结合业务规则再判断,相似度只是一个排序信号。

我现在的做法是:先跑一批真实查询,把分数分布打出来,确认自己的模型在哪些区间比较可信。比如看到绝大多数正确答案的分数在0.55到0.8之间,就用0.5作为“值得展示给用户”的下限,但不会只凭分数做硬过滤。

5.3 增量更新:多数小项目最优解就是全量重建

本地知识库是会变的,新增一篇文档、修改一段话,索引就要更新。很多人会想复杂:增量嵌入、局部索引刷新、后台任务。实际上你这点数据量,全量重建是最省心的方案。

全量重建指的是:重新加载所有文档,重新切块,重新生成向量,重新覆盖matrix.npy和meta.json。8513条向量,嵌入计算加上矩阵存储,在普通机器上可能不到一分钟。这个频率如果一天只跑几次,完全没有任何压力。

你觉得全量重建太笨,是因为你拿大数据量系统的思路套小场景。小型知识库的核心诉求是“结果可复现、出错好排查”,全量重建天然满足:只要文档目录是确定的,索引结果就是确定的。增量更新则必须处理“哪条文本变了、哪些向量要删掉、哪些要重插”这些状态问题,出错了很难追溯。

5.4 语义召回之外,建议保留一层关键词兜底

语义检索擅长处理“换了个说法但其实同一个意思”的查询,比如用户说“怎么重置密码”,知识库里写的是“修改登录凭证流程”,两者都能通过语义关联起来。但它也有短板:遇到精确的专有名词、代码标识符、错误码,语义向量可能会把关键token稀释掉。

例如用户搜“ERR_403”,如果语义向量模型没见过这个token,它很可能把它当成不重要的乱码,返回一堆包含“错误”“权限”的通用内容。这种情况用关键词检索反而更准。

我现在会在语义结果旁边同时跑一层简单的关键词匹配,比如用BM25或甚至就是in判断,只要索引片段里包含查询中的精确词,就给一个加分。融合规则可以很简单:关键词命中直接排到前面,或者给命中项提高一定分数。这个“混合检索”思路很成熟,实现也便宜,强烈建议在语义检索系统里保留。

还有一个冷启动的经验:嵌入模型通常要加载权重,首次加载可能需要几百毫秒甚至更久。不要把模型加载逻辑放在查询函数内部,应该进程启动时加载为全局变量。否则每次查询都会把模型重新初始化一遍,延迟会高到让人误以为系统有问题。实测延迟一定要预热后测,这也是为什么很多人在本地跑和压测场景跑结果差很多的原因。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进