向量检索最小可运行实现:HNSW索引参数与召回率/延迟权衡

大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。

上个月有个做企业知识库的朋友找我,说他的向量检索"能跑,但就是跑不对"。问答返回的结果,总差一口气,明明库里有正确的文档,它就是排不上来。我让他把整条链路从文档切分开始,一步步走一遍。他自己也承认,当时图快,切分那步直接按固定字数切了,检索效果也就没再测过。

向量检索,是把文本、图片这类内容转成高维向量,再用距离或夹角,找出与查询最接近的K条结果。它查的是"意思像不像",不是"字面对不对"。这篇不讲概念综述,只做一件事:用一个最小可运行的示例,把从文档切分到TopK的完整链路跑通,再把参数和评测补上。看完你能自己复现一遍。

一、先把整条链路摊开

很多人一上手就直奔"选哪个向量库""哪个索引快",其实向量检索落地,链路是这样的:

text 复制代码
原始文档 → 文档切分(chunk) → 向量化(Embedding) → 写入并建索引
        → 查询向量化 → 相似度检索+标量过滤 → TopK →(可选)重排

每一步都能单独让结果变差。切分决定"一段向量里塞了多少意思",Embedding决定"意思有没有被表达对",索引和参数决定"该召回的被没被捞到"。链路里缺一步,后面怎么调都补不回来。

图1 一条向量检索链路的完整走向:从原始文档到 TopK

二、精确检索和近似检索,先分清

在动手之前,先分清一件事:你接下来要建的索引,是精确KNN还是近似ANN。这决定了后面所有参数的调法。很多人把这俩混着讲,参数就调歪了。

对比维度 精确检索(暴力KNN) 近似检索(ANN)
原理 逐个比对全部向量 用索引先缩小候选集再算距离
召回率 100% 通常95%以上,可用参数调
时间复杂度 随数据量线性增长 亚线性,规模越大优势越明显
典型适用 十万级以内、精度基准 百万级以上生产检索

向量检索的生产环境,绝大多数走的是ANN这条路。原因很简单:百万条128维向量如果全量比对,一次查询就是上亿次浮点运算,延迟压不住。ANN用"牺牲一点点召回率,换大幅提速"的思路,把这件事变得可行。所以你后面看到的所有调参,本质都是在召回率和延迟之间找那个平衡点。

下面对应的最小示例,索引这一层用的就是ANN。

三、把示例跑起来:五段代码

环境说明:Python 3.10,Embedding用本地模型(示例用bge-small,768维),向量库以KES(KingbaseES V9)为例,它在关系引擎里直接带向量类型和向量索引,建表和查询走SQL就行,省掉一套独立向量库的运维。换其他向量库,SQL语法改一改,链路是一样的。

第1步,切分。 这一步别用固定字数硬切。

python 复制代码
from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,          # 单块目标长度
    chunk_overlap=80,        # 相邻块重叠,避免语义被切断
    separators=["\n\n", "\n", "。", "!", "?", ";", " "]
)
chunks = splitter.split_text(open("kb.txt", encoding="utf-8").read())

切分的关键不是chunk_size填多少,而是优先按语义边界(段落、句号)切,切不动了再退到字符。文档是问答对、是技术规范、还是政策文件,合理切法不一样。

第2步,生成向量。

python 复制代码
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-small-zh-v1.5")

vectors = model.encode(chunks, normalize_embeddings=True)  # 归一化后可用内积

normalize_embeddings=True这行别省。向量归一化之后,余弦相似度和内积等价,后面索引选择会更灵活。

第3步,建表并建向量索引。 这是KES里的写法:

sql 复制代码
CREATE TABLE kb_chunks (
    id         BIGSERIAL PRIMARY KEY,
    doc_id     BIGINT,
    category   VARCHAR(32),
    is_active  BOOLEAN DEFAULT TRUE,
    content    TEXT,
    embedding  VECTOR(768)
);

-- 建HNSW向量索引,指定距离度量
CREATE INDEX idx_kb_embedding ON kb_chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 200);

维度提醒:这里用的768维模型没问题,但KES的向量索引维度受8K数据页限制,FP32向量最高支持2000维,FP16最高4000维。模型输出超过这个维度,索引建不起来(表里存储不受影响),选模型前先确认维度。

向量索引和普通B+树索引可以共存,标量字段(category、is_active)照样能建普通索引。

第4步,写入。

python 复制代码
import psycopg2, numpy as np
conn = psycopg2.connect(dsn)   # 连接KES;dsn 形如 "host=xxx port=54321 dbname=xxx user=xxx password=xxx"
cur = conn.cursor()
for chunk, vec in zip(chunks, vectors):
    cur.execute(
        "INSERT INTO kb_chunks (doc_id, category, content, embedding) "
        "VALUES (%s, %s, %s, %s)",
        (doc_id, category, chunk, np.array(vec).tolist())
    )
conn.commit()

第5步,查询。 把向量检索和标量过滤放在一条SQL里。

sql 复制代码
SELECT id, content,
       1 - (embedding <=> :query_vec) AS similarity
FROM kb_chunks
WHERE is_active = TRUE
  AND category = 'product'
ORDER BY embedding <=> :query_vec
LIMIT 10;

注意WHERE里的标量过滤和ORDER BY里的向量距离,是在同一个执行计划里完成的。这就是关系库带向量能力的好处:过滤条件不必事后在应用层筛。如果向量和业务数据分在两个库里,你得先从向量库召回Top-K,再回业务库查有效性和分类,两次往返再加一次应用层拼装,过滤条件越苛刻,越容易凑不满K条。

四、参数不是拍脑袋:HNSW三个参数怎么调

示例里的m=16, ef_construction=200不是随手写的。HNSW有三个参数最影响效果,调之前得分清各自管什么。

参数 作用 调大 调小
M 每个节点的邻居数 召回率更高、内存占用更大 省内存、召回下降
ef_construction 建索引时的候选池 索引质量更好、建索引更慢 建得快、质量下降
ef_search 查询时的候选池 召回率高、延迟上升 快、可能漏召回

经验取值:M一般16到48,从16起,召回不够再加;ef_construction默认64,常用范围200到500,追求更高的索引质量可以调到400以上(示例里的200是偏质量优先的选择);ef_search最灵活,它可以在查询时临时调,想提召回就调大,对延迟敏感就调小,不用重建索引。这三个参数的取舍逻辑,和数据库里"索引建多密"是一回事,够用就行,堆满只是给自己加成本。

图2 HNSW 三个参数各管什么:M 和 ef_construction 定索引质量,ef_search 管查询

五、怎么证明"检索对了":召回率和延迟一起看

参数调完,得有个东西告诉你结果变好还是变差。固定一批测试集,用召回率和延迟两个指标,别靠感觉。

评测的做法:准备N条查询,先用精确KNN(暴力检索)跑一遍,把它的结果当成标准答案(ground truth),再拿ANN的结果跟它比对,看正确答案有没有落在返回的K条里,算出Recall@K;同时记录P50、P95延迟。

下面这组数据参考自金仓官方在固定环境下的测试,测试条件一并列出,方便你对照:

测试环境:4核8G服务器,SIFT-1M数据集(100万条128维向量),HNSW参数M=16、ef_construction=200。(来源:金仓官方技术博客)

补一句前提:SIFT-1M是图像特征数据集,用在这里只当作HNSW索引性能的基准参考。中文文本场景的实际召回率,得拿自己的语料重新评测。

指标 结果
召回率 95%以上
查询响应时间 稳定在50ms以内
索引与标量过滤组合查询 一条SQL完成

这里补一句:这组数字来自特定数据集和特定硬件,你换套数据、换个维度,结果未必一样。真要下结论,就用自己的数据、带上真实的过滤条件跑一遍。生产查询几乎都带部门、租户、时间这类条件,纯向量的干净测试说明不了问题,数字再漂亮也不作数。

知道了怎么评测,接下来聊几个动手前就容易踩的坑。

六、避坑清单

链路跑通了、指标也测过了,但这几处坑我见得太多次,趁热说清楚,省得你回头返工。

第一,别跳过切分评测。切太大,一段向量混了好几个意思,检索就模糊;切太小,一句话被拆开,语义也散了。切分策略要和你的文档类型绑着调,不是设一个固定值就完事。

第二,别拿干净数据集下结论。ANN-Benchmarks这类公开基准面向算法库对比,数据集偏旧、用例偏简单,能看趋势,不能当生产结论。要测就带真实过滤条件和并发模式测分位数延迟。

第三,这条是我自己踩的。我第一次建向量索引,为了追求召回率把ef_construction拉到了800,结果建索引等了快两个小时,事后才知道,这个参数只影响建索引阶段,和查询召回不直接挂钩,真正该调的是ef_search。建索引慢,白白占了我一个下午。

坑说完了,回头还是那个老问题:索引到底该怎么选。下面这张表,按你的数据规模和更新频率给个方向。

七、按规模选索引:一个简单的决策框架

数据规模 向量维度 更新频率 索引倾向
十万级以内 任意 任意 精确检索够用,先别上ANN
百万到千万级 512-1024 较低 HNSW,重召回率
百万到千万级 512-1024 很高 IVF_Flat,重写入吞吐
亿级及以上 高维 低 图索引+量化正交

索引没有"更强的",只有"更合适的"。数据更新频繁就别死磕需要频繁重建的索引,查询对延迟敏感就别只盯召回率。

八、小结

向量检索落地,"能跑"和"跑得对"之间,隔着切分、参数、评测三件事。补上这三件,检索质量才站得住。

我跑完这一圈,最大的感受是:向量要和业务数据一起过滤、排序时,用关系库自带的向量能力(比如KES Vector)最省事,一条SQL就完事,不用在两个库之间倒数据。信创项目里,飞腾、鲲鹏、海光、龙芯这些芯片,统信、麒麟这些系统,适配也是现成的。

你在做向量检索的时候,卡在切分、参数还是评测?评论区聊聊,让后来的人少走一段。

我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋

相关推荐
姜鱼问生1 小时前
Docker Volume 备份与恢复实战:不停机迁移数据
后端·算法·架构
suhan1 小时前
【数据结构】三、栈和队列:5.顺序队列(循环队列)(初始化,判空判满,入队,出队,实例)
数据结构·后端·算法
看浪的路人1 小时前
第7讲:大模型后端服务化——Go 实现高并发 LLM Proxy / Router / Fallback
开发语言·后端·golang
suhan1 小时前
【数据结构】三、栈和队列:3.链栈(链栈栈的初始化,判空,进栈,出栈,读取栈顶,链栈实例)
数据结构·后端·算法
邱杰4701 小时前
数智人事系统(十一):Spring Boot + MyBatis-Plus 的 CRUD 落地
后端·github
姜鱼问生1 小时前
服务器磁盘满了?从告警到清理的完整排查
后端·算法·架构
邱杰4701 小时前
数智人事系统(十三):考勤打卡与出勤日历
后端·github
suhan1 小时前
【数据结构】三、栈和队列:4.栈的应用(括号匹配,四则运算表达式求值,进制转换,递归)
数据结构·后端·算法
用户072367066601 小时前
用 MutatingWebhook 做一个通用 Agent 注入器:给任意业务 Pod 无侵入挂上探针/Agent(Java 实战)
后端