2023 年我做一个公司内部的知识库问答系统——也就是现在很常见的 RAG。思路很直接:把公司的文档切成一段段,每一段用 embedding 模型转成一个向量;用户提问时,把问题也转成向量,再从所有文档向量里找出最相似的几段,塞给大模型当参考。第一版我做得很省事:文档向量我全存在一个列表里,查询时写个循环,把问题向量和每一条文档向量挨个算一遍余弦相似度,排序,取最高的 5 段。本地一测——飞快:几百段文档,每次查询几毫秒就回来了,答得又准又快。我心里很踏实:"向量检索嘛,不就是算个相似度、挑最大的几个。"可等系统真正铺开,把全公司的 wiki、产品文档、历史工单全喂进去之后,文档段落涨到了三百多万条。问题立刻就爆了:用户问一个问题,要等十几秒才出结果;服务一启动,光是把三百万条向量加载进内存就吃掉了十几个 G,内存几次被撑爆。我盯着这个慢查询想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"向量检索,就是把问题向量和所有文档向量都算一遍相似度,挑出最大的几个"。这句话本身没错——它精确地描述了"最近邻"的定义。可它漏掉了一件最致命的事:"和所有文档向量都算一遍",这是一个 O(N) 的全表扫描。几百条时,N 很小,你感觉不到;可一旦 N 涨到几百万,每一次查询都要做几百万次浮点运算,它必然慢。真正能扛住海量向量的检索,靠的从来不是"算得快",而是压根不去算大部分向量——这就是 ANN(近似最近邻)和它背后的向量索引。这篇文章就把向量检索和向量数据库梳理一遍:为什么暴力检索到百万级必崩、HNSW 图索引怎么把 O(N) 降到近似 O(log N)、IVF 和量化怎么在速度内存召回之间权衡、带元数据过滤和混合检索怎么做、向量数据库到底怎么选型,以及距离度量、归一化、召回率评测这些把向量检索真正做对要避开的坑。
问题背景
先把那次慢查询的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个 RAG 知识库,文档段落涨到三百多万条后,每次检索要等十几秒,服务加载全部向量吃掉十几 G 内存、几次被撑爆。第一版用的是"把问题向量和全部文档向量挨个算余弦相似度、再排序取 top 5"的暴力检索。
我当时的错误认知:"向量检索就是算个相似度、挑最大的几个,几百条能跑通,几百万条无非慢一点。"
真相:暴力检索的复杂度是 O(N)——每次查询都要扫遍全部向量。N 一大,耗时和内存线性飙升,这不是"慢一点",是不可用。海量向量检索靠的是 ANN(近似最近邻):用 HNSW、IVF 这类向量索引,让一次查询只触碰极少数向量,把复杂度降到近似 O(log N)。代价是结果从精确变近似——所以召回率必须被量出来。
要把向量检索做对,需要几块认知:
- 为什么暴力检索到百万级必崩——它是 O(N) 的全量扫描;
- HNSW——用图索引把检索从 O(N) 降到近似 O(log N);
- IVF 与量化——另一条索引路线,以及速度、内存、召回的三角权衡;
- 过滤与混合检索——带元数据条件的查询、向量与关键词融合;
- 向量数据库选型、距离度量、召回评测这些工程坑怎么处理。
一、为什么暴力检索到百万向量就崩了
先把这件最根本的事钉死:暴力检索(精确最近邻)的复杂度是 O(N),每次查询都要和全部 N 条向量算一遍相似度;N 小时它快得让你毫无察觉,N 涨到百万级时,它必然慢到不可用——这不是优化能救的"常数问题",是算法复杂度的"量级问题"。
下面这段代码,就是我那个"几百条飞快、几百万条卡死"的第一版——它挨个算相似度:
import numpy as np
def brute_force_search(query_vec: np.ndarray,
all_vecs: np.ndarray, top_k: int = 5) -> list:
# 反面教材:暴力检索 —— 把 query 和【每一条】向量都算一遍。
sims = []
for i in range(len(all_vecs)):
v = all_vecs[i]
# 余弦相似度:两个向量的点积 / 各自模长之积
sim = np.dot(query_vec, v) / (
np.linalg.norm(query_vec) * np.linalg.norm(v))
sims.append((i, sim))
sims.sort(key=lambda x: x[1], reverse=True)
return sims[:top_k]
# 破绽:复杂度是 O(N)。几百条向量时是毫秒级,
# 涨到几百万条,每次查询要算几百万次点积 —— 慢到十几秒。
有人会说:这循环写得太笨,用 numpy 向量化一下不就快了?向量化确实能快——但它救不了根本问题:
def brute_force_vectorized(query_vec: np.ndarray,
all_vecs: np.ndarray, top_k: int = 5) -> list:
# 即使用 numpy 向量化把 Python 循环消掉,复杂度【依然是 O(N)】。
q = query_vec / np.linalg.norm(query_vec)
mat = all_vecs / np.linalg.norm(all_vecs, axis=1, keepdims=True)
sims = mat @ q # 一次矩阵乘,内部仍是 N 次点积
idx = np.argpartition(-sims, top_k)[:top_k]
return sorted(((int(i), float(sims[i])) for i in idx),
key=lambda x: x[1], reverse=True)
# 向量化只是把【常数】变小(快几十倍),但 N 一大,
# 内存(要把 N 条向量全装进 mat)和耗时照样【线性飙升】。
这两段代码没有任何语法错误,算出来的结果也百分之百精确——它们返回的,就是定义上"最相似的 top 5"。它们的问题不在代码本身,而在一个错误的规模假设:我默认"能在几百条上跑通的逻辑,放到几百万条上无非慢一点"。可 O(N) 这个复杂度意味着:数据量翻 10000 倍,单次查询的耗时也翻 10000 倍。几百条时单次查询 1 毫秒,你毫无感觉;涨到三百万条,单次查询就是十几秒。而且不只是慢:暴力检索每次都要把全部向量读进内存参与计算,三百万条 768 维的 float32 向量,光裸数据就是 300万 × 768 × 4 字节,接近 9 个 G——内存被撑爆,一点都不冤。问题的根子清楚了:要扛住海量向量,不能在"怎么把全部向量算得更快"上打转,必须换一个根本不去算大部分向量的办法。这个办法,就是给向量建索引。
二、HNSW:用图索引把 O(N) 降到近似 O(log N)
暴力检索慢,是因为它没有索引——就像一本没有目录的书,你想找某个词,只能从第一页翻到最后一页。数据库的 B+ 树索引,让"按值查找"不必扫全表;向量也需要它自己的索引。但向量索引有个根本的妥协:它放弃精确,改求近似——这就是 ANN(Approximate Nearest Neighbor,近似最近邻)。最主流的 ANN 索引之一是 HNSW(分层可导航小世界图):它把所有向量组织成一张图,相近的向量之间连上边;查询时,从某个入口点出发,每一步都贪心地走向"离查询向量更近"的邻居,几十跳之内就能逼近目标区域。
import hnswlib
def build_hnsw_index(vecs: np.ndarray, dim: int) -> hnswlib.Index:
"""用 HNSW 图索引为向量建索引:查询从 O(N) 降到近似 O(log N)。"""
index = hnswlib.Index(space="cosine", dim=dim)
# M:每个节点连多少个邻居;ef_construction:建图时的搜索宽度。
# 这两个值越大,图质量越高、召回越好,但建索引越慢、越占内存。
index.init_index(max_elements=len(vecs),
ef_construction=200, M=16)
index.add_items(vecs, np.arange(len(vecs)))
index.set_ef(50) # 查询时的搜索宽度,越大越准但越慢
return index
索引一旦建好,查询就不再是扫全表,而是沿着图走:
def hnsw_search(index: hnswlib.Index, query_vec: np.ndarray,
top_k: int = 5) -> list:
"""在 HNSW 索引上查询:不再扫全表,只沿着图走几十跳。"""
labels, distances = index.knn_query(query_vec, k=top_k)
return [(int(i), float(d))
for i, d in zip(labels[0], distances[0])]
# 和暴力检索相比:百万级向量下,查询从十几秒降到【毫秒级】。
# 代价:结果是【近似】的 —— 极小概率漏掉个别真正最近的邻居。
HNSW 的精髓,是把"找最近邻"从一个"逐个比对"的问题,变成了一个"在图上导航"的问题。它有分层结构:上层稀疏、边长,负责大跨度地快速接近目标区域;下层稠密、边短,负责在目标区域里精细定位。这和你在地图上找一家店一模一样:先看省级地图跳到城市,再看市区图跳到街道,最后看街景找到门牌——没有人会把全国每一栋楼都看一遍。但要清醒地认识它的代价:第一,结果是近似的,有极小概率漏掉真正的最近邻;第二,它建索引慢、占内存——那张图本身要存大量的邻居指针。M、ef_construction、ef 这几个参数,本质上都是在同一个天平上滑动:往"准"的一头调,就往"慢"和"费内存"的一头牺牲。HNSW 召回高、查询快,但内存是它的软肋。于是有了另一条索引路线。
三、IVF 与量化:另一条路线,以及速度内存召回的三角权衡
HNSW 之外,另一大类索引是 IVF(倒排文件索引)。它的思路更朴素:先用聚类把整个向量空间切成若干个簇(比如 256 个),每个向量归属一个簇;查询时,先找出离查询向量最近的几个簇,然后只在这几个簇内部做检索,其余簇看都不看。
import faiss
def build_ivf_index(vecs: np.ndarray, dim: int,
nlist: int = 256) -> faiss.Index:
"""IVF 索引:把向量空间聚成 nlist 个簇,查询只搜最近的几个簇。"""
quantizer = faiss.IndexFlatIP(dim)
index = faiss.IndexIVFFlat(quantizer, dim, nlist,
faiss.METRIC_INNER_PRODUCT)
index.train(vecs) # 训练:用数据聚出 nlist 个簇心
index.add(vecs)
index.nprobe = 8 # 查询时搜 8 个最近的簇(调大→更准更慢)
return index
IVF 把检索范围从 N 缩小到了 N 的几分之一,但每条向量还是原始的 float32,内存问题没解决。这时再叠加量化(Quantization)——其中最常用的是 PQ(乘积量化):它把每条向量切成若干小段,每段用一个很短的编码来近似表示,从而把一条向量从几千字节压缩到几十字节。
def build_ivfpq_index(vecs: np.ndarray, dim: int,
nlist: int = 256, m: int = 16) -> faiss.Index:
"""IVF + PQ:在 IVF 之上叠加乘积量化,把每条向量压成几十字节。"""
quantizer = faiss.IndexFlatIP(dim)
# m:把向量切成 m 段分别量化;8:每段用 8 bit 编码
index = faiss.IndexIVFPQ(quantizer, dim, nlist, m, 8)
index.train(vecs)
index.add(vecs)
index.nprobe = 16
return index
# 量化是【用精度换内存】:原始向量一条要 dim*4 字节,
# 压缩后只要 m 字节 —— 千万级向量从几十 GB 降到几个 GB。
到这里,向量索引的全貌就清楚了:它永远是一个速度、内存、召回率三者之间的三角权衡,你不可能三者全要。HNSW Flat(不量化)——召回最高、查询最快,但最吃内存;IVF Flat——内存中等、速度中等;IVF+PQ——最省内存,能在单机塞下上亿向量,但量化损失了精度,召回率会掉。没有一个"最好"的索引,只有"最适配你这一档数据量和资源约束"的索引。百万级、内存充裕,优先 HNSW;亿级、内存吃紧,就得上 IVF+PQ。选错索引,要么内存爆,要么召回烂。但光有索引还不够——真实业务的查询,往往不只是"找最相似的",还带着条件。
四、过滤与混合检索:带条件的查询怎么做
真实的 RAG 查询很少是"在全部文档里找最相似的"。它常常带着元数据条件:"只在『产品文档』这个分类里找"、"只在『2024 年之后』的工单里找"。这里藏着一个极隐蔽的坑——后过滤(post-filter):先做向量检索取 top 50,再从这 50 条里筛掉不满足条件的。它的问题是:如果满足条件的文档很稀疏,那 top 50 里可能一条都不满足,过滤完结果为空。正确做法是预过滤(pre-filter):把过滤条件下推到检索里,只在满足条件的子集上做近邻搜索。
def search_with_filter(index, query_vec: np.ndarray,
top_k: int, allowed_ids: list) -> list:
"""带元数据过滤的向量检索:把过滤下推,只在合规子集里搜。"""
# 陷阱:若先取 top_k 再过滤(后过滤),过滤完可能【一条不剩】。
# 正确:用 IDSelector 把检索范围限定在 allowed_ids 这个子集。
selector = faiss.IDSelectorBatch(allowed_ids)
params = faiss.SearchParametersIVF(sel=selector)
distances, labels = index.search(query_vec, top_k, params=params)
return [(int(i), float(d))
for i, d in zip(labels[0], distances[0]) if i != -1]
另一个常被忽略的真相是:向量检索并不万能。它擅长语义近似("怎么退款"能匹配到"退货流程"),却不擅长精确命中——用户搜一个具体的产品型号、一个专有名词、一个错误码,这些字面精确的需求,传统的关键词检索(BM25)反而更稳。所以生产级的 RAG 往往用混合检索:向量检索和关键词检索各跑一遍,再把两个结果融合。融合最常用的是 RRF(倒数排名融合):
def hybrid_search(vec_hits: list, kw_hits: list,
top_k: int = 5) -> list:
"""混合检索:用 RRF 把向量检索和关键词检索的结果融合排序。"""
scores = {}
for rank, (doc_id, _) in enumerate(vec_hits):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (60 + rank)
for rank, (doc_id, _) in enumerate(kw_hits):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (60 + rank)
# 关键:RRF 只看【排名】不看原始分数 —— 于是不必纠结
# 余弦相似度和 BM25 分数量纲不同、没法直接相加的问题。
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return ranked[:top_k]
混合检索的精髓,是承认单一检索方式有盲区:向量检索丢不掉语义,关键词检索抓得住字面,RRF 让它们两条腿走路。RRF 只用排名算分,巧妙地绕开了"余弦相似度和 BM25 分数量纲不同、没法直接相加"的难题。索引、过滤、混合检索都理顺了,接下来是一个绕不开的工程决策:这一切,我该用什么来承载?
五、向量数据库选型:库、插件还是专用引擎
把向量检索落地到生产,你面前有三条路,对应三种不同量级的需求。第一条:嵌入式向量库,比如 faiss、hnswlib 本身,或 Chroma。它们就是一个库,跟着你的应用进程跑,无需独立部署。适合:数据量不大(百万级以内)、原型验证、单机应用。软肋:数据持久化、增量更新、横向扩展都得自己操心。第二条:数据库的向量插件,比如 PostgreSQL 的 pgvector。适合:你的业务数据本就在这个数据库里,向量只是多出来的一列,不想为它再引入一个新系统。软肋:在超大规模向量上,性能和专用引擎有差距。第三条:专用向量数据库,比如 Milvus、Qdrant、Weaviate。它们从底层就是为向量检索设计的,原生支持分布式、增量更新、元数据过滤、多种索引。适合:亿级以上向量、高并发、要长期演进的生产系统。代价:多一个重型组件要部署和运维。下面这段代码,展示选型里最容易被低估的一个能力——增量更新:
class VectorStore:
"""演示选型关注点:一个能【增量增删】的向量存储抽象。"""
def __init__(self, dim: int):
self.dim = dim
self.index = hnswlib.Index(space="cosine", dim=dim)
self.index.init_index(max_elements=100000,
ef_construction=200, M=16)
self.deleted = set()
def upsert(self, doc_id: int, vec: np.ndarray):
"""新增或更新一条向量 —— 文档会改,索引必须能跟着改。"""
self.index.add_items(vec.reshape(1, -1), [doc_id])
def delete(self, doc_id: int):
"""删除:HNSW 不能真删,只能打标记,查询时再过滤掉。"""
# 关键:很多索引【删除代价很高】甚至不支持真删 ——
# 选型时若业务文档频繁增删,这一点比"查询快不快"更要命。
self.deleted.add(doc_id)
self.index.mark_deleted(doc_id)
def search(self, query_vec: np.ndarray, top_k: int) -> list:
labels, dists = self.index.knn_query(query_vec, k=top_k * 2)
hits = [(int(i), float(d))
for i, d in zip(labels[0], dists[0])
if int(i) not in self.deleted]
return hits[:top_k]
选型时最容易犯的错,是只盯着"查询有多快"这一个指标。但生产系统里,文档是会变的——新增、修改、下线每天都在发生。一个查询飞快但增量更新极其笨重(每次都要全量重建索引)的方案,在真实业务里会拖垮你。选型要同时看四件事:查询性能、增量更新能力、元数据过滤能力、运维成本。别盲目上 Milvus——如果你的数据量就十几万,pgvector 或一个 hnswlib 文件,简单、够用、零运维。也别用一个 faiss 文件硬扛亿级生产。索引和数据库都选定了,最后还有几个把向量检索做扎实的工程坑。
六、工程坑:距离度量、归一化与召回率评测
四块设计之外,还有几个工程坑,不处理就会在生产上出事。坑 1:距离度量必须和 embedding 模型对齐。向量索引支持多种度量——余弦相似度、内积、欧氏距离。但你不能随便挑:必须用和你的 embedding 模型训练时一致的那一种。大多数文本 embedding 模型用的是余弦相似度;而余弦相似度,等价于"先把向量归一化到单位长度、再算内积"。少了归一化这一步,索引建得再快,排序也是错的。
def normalize(vecs: np.ndarray) -> np.ndarray:
"""归一化:把向量缩放到单位长度,让内积等价于余弦相似度。"""
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
norms[norms == 0] = 1e-12 # 防止零向量除以 0
return vecs / norms
# 关键:多数文本 embedding 模型用余弦相似度。先归一化、
# 再用内积索引(IndexFlatIP),才和模型对齐。漏掉归一化,
# 索引再快,你拿到的"最相似"也是错的。
坑 2:ANN 是用召回率换速度的,召回率必须被量出来。HNSW 的 ef、IVF 的 nprobe,调小了快但召回低、调大了准但慢。这个参数到底该调到哪一档,不能拍脑袋——要用召回率这个硬指标来量。做法是:留一批查询,用暴力检索算出精确答案当"标准答案",再看 ANN 的结果和它重合多少。
def evaluate_recall(ann_search_fn, brute_force_fn,
queries: np.ndarray, top_k: int = 10) -> float:
"""评测 ANN 召回率:ANN 结果与暴力精确结果的重合比例。"""
hit, total = 0, 0
for q in queries:
truth = {i for i, _ in brute_force_fn(q, top_k)} # 精确答案
got = {i for i, _ in ann_search_fn(q, top_k)} # ANN 答案
hit += len(truth & got)
total += len(truth)
recall = hit / total if total else 0.0
# 关键:上线前必须量出召回率掉了多少,再决定 ef / nprobe
# 调到哪一档 —— 召回率太低,RAG 会漏掉关键文档而答错。
return recall
坑 3:向量维度一旦定了,极难再改。embedding 模型决定了向量的维度(768、1024、1536……),索引是按这个维度建的。哪天你想换一个更好的 embedding 模型,新模型维度不同,意味着全部历史文档要重新 embedding、索引要全量重建——这是一次大工程。所以 embedding 模型要在第一天就慎重选。坑 4:文档切分(chunking)的质量,常常比索引更影响效果。一段切得太长,一个 chunk 里混了好几个主题,embedding 就不聚焦;切得太短,语义不完整。RAG 答不准,很多时候根子不在检索,而在 chunk 切得烂。坑 5:别忘了向量也要持久化和备份。一个跑在内存里的 hnswlib 索引,进程一重启就没了;百万级向量重新 embedding 一遍,既慢又烧钱(embedding API 是按量收费的)。索引必须落盘,而且要纳入备份。下面这张图,把一次带过滤和混合检索的查询串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 暴力检索 | 和全部向量逐一算相似度,结果精确但复杂度 O(N),百万级必崩 |
| ANN 近似最近邻 | 放弃精确换速度,靠向量索引让查询只触碰极少数向量 |
| HNSW 图索引 | 把向量连成分层图,查询沿图导航,复杂度近似 O(log N),召回高但吃内存 |
| IVF 倒排索引 | 把向量空间聚成若干簇,查询只搜最近的几个簇,其余簇不看 |
| PQ 乘积量化 | 把向量切段压成短编码,一条向量从几千字节降到几十字节,用精度换内存 |
| 三角权衡 | 速度、内存、召回率三者不可兼得,选索引就是在三角里取舍 |
| 预过滤 | 把元数据条件下推到检索里,先圈合规子集再搜,避免后过滤结果为空 |
| 混合检索 | 向量检索抓语义、关键词检索抓字面,用 RRF 按排名融合两路结果 |
| 距离度量对齐 | 索引的距离度量必须和 embedding 模型一致,余弦相似度要先归一化 |
| 召回率评测 | 用暴力检索的精确结果当标准答案,量出 ANN 漏了多少,据此调参数 |
避坑清单
- 暴力检索复杂度是 O(N),几百条飞快不代表几百万条能用,这是量级问题不是优化问题。
- numpy 向量化只能把常数变小,救不了 O(N),海量向量必须建向量索引。
- 百万级且内存充裕优先选 HNSW,召回高查询快,但它最吃内存。
- 亿级且内存吃紧要上 IVF 加 PQ 量化,用精度换内存,代价是召回率会掉。
- 没有最好的索引,只有最适配你这一档数据量和资源约束的索引,选错要么内存爆要么召回烂。
- 带条件的查询要用预过滤把条件下推,后过滤可能筛到一条不剩。
- 向量检索抓不住专有名词和错误码,要配关键词检索做混合检索,用 RRF 融合。
- 索引的距离度量必须和 embedding 模型对齐,用余弦相似度就必须先归一化。
- ANN 是用召回率换速度,上线前必须用暴力检索结果量出召回率再调参数。
- 选型别只看查询快不快,增量更新能力、元数据过滤、运维成本同样要看;数据量小别盲目上重型引擎。
总结
回头看那次"知识库涨到三百万段、每次检索卡十几秒"的事故,以及我后来在向量检索上接连踩的坑,最该记住的不是某一个索引的参数,而是我动手前那个想当然的判断——"向量检索,就是算个相似度、挑最大的几个"。这句话错在它只描述了"要什么结果",却完全没考虑"用什么代价拿到这个结果"。"最相似的 top 5"是一个定义,而"怎么在三百万条里高效地找到这 top 5"是一个工程问题——我把定义直接翻译成了代码,于是写出了一个语义完全正确、却 O(N) 的灾难。向量检索这件事想清楚的,正是这个:它表面上是"算相似度",本质上却是一个"如何在高维空间里,用远低于 O(N) 的代价,找到近邻"的问题。而解决它的核心思路,和现实里查字典完全相通——你从不会把字典从头翻到尾,你利用的是它按部首、按拼音组织好的"索引结构"。
所以做向量检索,真正的工程量不在"np.dot"那一行点积上。那一行,任何教程的第一页就教完了。真正的工程量,在于你要承认精确是有代价的,然后在"近似"这条路上,做一连串清醒的权衡:你要根据数据量和内存,在 HNSW 和 IVF+PQ 之间选一个索引;你要明白每一个 ef、nprobe 参数,都是在速度、内存、召回这个三角里挪动一个砝码;你要为带条件的查询想清楚预过滤,为抓不住的专有名词配上混合检索;你还要为换模型那天可能到来的全量重建提前做好心理准备。这篇文章的几节,其实就是顺着这条思路展开的:先想清楚暴力检索为什么必崩,再看 HNSW 和 IVF 两条索引路线如何把 O(N) 降下来,接着是过滤、混合检索、选型,最后是距离度量、召回评测这几个把向量检索真正做扎实的工程细节。
你会发现,向量检索的思路,和现实里怎么管理一座巨型图书馆完全相通。一座只有几百本书的小书房,你要找一本书,一架一架扫过去,几分钟就找到了——这就是暴力检索,数据量小,怎么找都行。可一座藏书几百万册的国家图书馆,你绝不可能这么找。一个有经验的馆长会怎么做?他会先把书按学科分区(这是 IVF 的聚类分簇),你找一本物理书,直接去物理区,别的区看都不用看;他会建一套层层下钻的索引卡,从大类到小类到具体书架(这是 HNSW 的分层导航);藏书实在太多、书库塞不下时,他会把不常用的书做成缩微胶片,用一点清晰度换来巨大的空间(这是 PQ 量化);他还知道,有人是凭印象找"一本讲黑洞的书"(这是向量检索的语义匹配),有人是拿着精确的书号来取(这是关键词检索),好的馆长两种检索台都留着(这是混合检索)。图书馆的检索效率,从来不取决于管理员跑得多快,而取决于这套索引体系建得多好。
最后想说,向量检索做没做对,差距永远不会在数据量小的时候暴露——demo 阶段几百段文档,暴力检索和精心建好索引的系统,你测不出任何区别,甚至暴力检索因为结果精确还显得更"好"。它只在真实的、文档日积月累涨到百万千万级、用户并发提问的生产环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会像我一样,看着用户对着一个"转圈十几秒"的输入框失去耐心,看着服务因为把几个 G 的向量往内存里塞而反复崩溃;而做对了,无论知识库涨到多大,每一次提问都在几十毫秒内返回最相关的几段,内存平稳,召回率心里有数。所以别等检索慢成一片、等内存爆掉,在你写下第一行"算相似度"的代码之前就该想清楚:我的向量量级会涨到多大?到那个量级,O(N) 还扛得住吗?我该用哪种索引?我愿意用多少召回率去换速度和内存?这几个问题都有了答案,你的向量检索才不只是一个"能算出相似度"的玩具,而是一套数据涨到海量也查得快、稳得住的真正的检索引擎。
—— 别看了 · 2026