读完这篇,你能做什么
能先用精确搜索建立对照,再用相同查询比较近似索引找回了多少近邻、耗时多少,以及是否正确过滤无权读取的资料。
学习理由、准备要求与知识点
为什么学它
向量怎样生成、按什么距离比较、索引会漏掉哪些候选,要一起检查,才能解释为什么搜得准或快。
先读这些内容
知识点清单
- 编码模型与维度配置
- 欧氏距离、点积与余弦
- 距离排序方向
- 精确近邻搜索
- HNSW 近似索引
- 索引参数与召回代价
- 权限过滤后结果不足
- 向量版本重建和切换
运行前先确认完整算法使用 Java 17 标准库;JDK 源码基线为 OpenJDK 17 GA。示例是内存中的机制模型,外部服务接入需要另外配置和验证。
01先弄懂它是什么
文本变成向量后,检索就是从许多向量里找最接近查询的几项。先看清用什么标准:欧氏距离越小越近,点积或余弦一般越大越相似,而不同库返回的可能是距离,也可能是换算后的分数。如果排序方向写反,仍能返回 k 条记录,但拿到的可能恰好是最不相关的内容。
最容易核对的方法,是比较所有允许候选,找出真正最近的前 k 项,这叫精确近邻,也能作为测试基准。近似索引用图或聚类等方式减少搜索量,速度可能更快,但也可能漏掉真实最近项。因此要同时测耗时和召回。HNSW 的表现还受参数、数据分布和过滤条件影响,不能保证任意查询都变成对数时间。
索引还绑定编码模型、维度、距离度量和归一化方式。权限过滤必须在授权候选范围内生效,不能先把跨用户结果送入重排或生成再隐藏引用。过滤后不足k条是合法结果,应说明资料不足或调整授权范围内搜索,而不是放宽权限补齐数量。
文中这几个词,先认一认
- 精确近邻
- 按既定距离逐一比较所有允许的候选,再确定最接近的前 k 项。
- 近似召回
- 近似搜索找回了多少精确搜索的近邻,需要在固定的一组查询上比较。
- 有界堆
- 最多保留当前最好的 k 项,并把其中最差的一项放在堆顶,便于新候选到来时替换。
02看看它怎么工作
本题要从 n 个候选里选出距离最近的 k 个,距离用欧氏距离,距离相同时再按 ID 决定顺序。可以维护一份最多 k 项的保留名单,把名单里最差的候选放在最容易取出的位置;这样的结构叫最大堆。新候选更好,就替换当前最差者,否则略过。所有候选检查完后,只需把留下的 k 项排好,不必给全部 n 项排序。不过每个向量的距离仍然都算过,因此这里做的是精确查找,没有为了省事跳过部分候选。
比较器先按距离升序,再按ID升序定义“更好”,堆使用该比较器的反向次序。若只反转距离却忘记并列ID,边界上的保留对象可能不稳定。结果排序也必须使用同一个正向比较器,确保选择和展示规则一致。
距离逐维用hypot累计,减少直接平方的溢出风险;有限坐标相减仍可能溢出,程序对此明确拒绝。ID重复、维度不一致与非有限坐标在选择前检查,即使k为零也不让错误数据悄悄通过。本题是内存精确检索,不声称实现向量数据库或HNSW。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart LR
Q["查询向量与版本"] --> A["授权候选集合"]
A --> D["精确距离计算"]
D --> H["最多 k 项最大堆"]
H --> S["按距离和 ID 排序"]
S --> R["可复核近邻基线"]按顺序看,每一步做了什么
- 固定模型版本、维度和距离排序方向。
- 先在可信身份下确定允许候选集合。
- 建立精确Top-k基线并定义并列规则。
- 用有界最大堆保留当前最佳候选,再正向排序输出。
- 比较近似索引召回、过滤后数量与尾延迟。
03对照实现代码
OpenJDK 17 GA,jdk-17+35 · PriorityQueue.offer / poll / siftUp / siftDown
下方为原创 Java 教学片段,调用已核验的 JDK API 说明局部机制;不是该类原样源码,也不实现神经网络。完整程序见算法模块。
Comparator<Hit> bestFirst = Comparator.comparingDouble(Hit::distance)
.thenComparing(Hit::id); // 距离相等时,ID给出稳定全序。
PriorityQueue<Hit> worstFirst = new PriorityQueue<>(bestFirst.reversed());
worstFirst.offer(candidate); // 内部上浮维持堆序。
if (worstFirst.size() > k) worstFirst.poll(); // 删除当前最差者,保留最好k项。PriorityQueue以数组堆维护局部顺序,offer插入后上浮,poll移除根并下沉调整。默认根是比较器意义上的最小元素,所以本题反转比较器让最差候选成为根。
堆序只保证根与子树关系,不保证迭代器输出全局排序。不能直接遍历堆就声称结果从近到远,必须再排序或逐个按相应顺序弹出。
有界堆减少的是排序与保留成本,不能省略向量距离计算。本篇精确扫描每个允许候选,不把O(log k)堆操作错写成整体检索复杂度。
真实向量库还包含持久存储、事务、并发与近似索引。本例只用JDK集合实现应用层基线,源代码桥梁与数据库内核实现语言无关。
代码先调用谁,再调用谁
- 查询与候选向量校验
- 计算每项精确距离
- offer后用poll淘汰最差
- 保留最多k项
- 按距离和ID正向排序
04动手写一个例子
精确近邻的有界最大堆
输入查询向量、唯一ID候选和k,返回欧氏距离最小的最多k个ID,距离并列按ID升序。非法维度或非有限中间值拒绝;Java17程序运行固定自测。
- 输入 1
查询[0],a=[2],b=[1],c=[-1],k=2- 输出 1
b,c
- 输入 2
空候选,k=3- 输出 2
空
每次插入一个候选后,如果大小超过k就移除最差项。已经被移除的项至少有k个不差于它的已见候选,因此不可能进入当前最佳k项。
当新候选到来,只需与当前集合共同重新保留最佳k项,历史被丢弃项无需恢复。由归纳可知扫描完全部候选后,堆中正是全局最佳k项。
输出需要单独排序,因为堆只维护最差根。k大于候选数时自然返回全部;k为零返回空,但输入合法性仍检查,便于保持一致接口契约。
为什么这样写是对的处理前i个候选后,堆保存其中按距离升序、ID升序排列的前min(i,k)项,根是这些保留项中最差者。
d为维度,n为候选数;本程序为拒绝重复ID额外使用O(n)集合,堆本身O(k)。若上游已经保证唯一ID,可省去该集合。ID比较另有字符串长度成本。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
record Point(String id,double[] v){} record Hit(String id,double distance){}
static double distance(double[] a,double[] b){
if(a.length==0||a.length!=b.length)throw new IllegalArgumentException("dimension");
double r=0;for(int i=0;i<a.length;i++){
if(!Double.isFinite(a[i])||!Double.isFinite(b[i]))throw new IllegalArgumentException("coordinate");
double delta=a[i]-b[i];if(!Double.isFinite(delta))throw new IllegalArgumentException("difference overflow");
r=Math.hypot(r,delta); // 逐维稳定累计欧氏长度。
}
if(!Double.isFinite(r))throw new IllegalArgumentException("distance overflow");return r;
}
static List<String> top(double[] q,List<Point> points,int k){
if(k<0)throw new IllegalArgumentException("k");distance(q,q);
Comparator<Hit> order=Comparator.comparingDouble(Hit::distance).thenComparing(Hit::id);
PriorityQueue<Hit> heap=new PriorityQueue<>(order.reversed());Set<String> ids=new HashSet<>();
for(Point p:points){
if(p.id()==null||!ids.add(p.id()))throw new IllegalArgumentException("duplicate id");
Hit hit=new Hit(p.id(),distance(q,p.v()));
heap.offer(hit);if(heap.size()>k)heap.poll(); // 根是最差者,淘汰后仍保留最佳k项。
}
List<Hit> sorted=new ArrayList<>(heap);sorted.sort(order); // 堆迭代不是排序结果。
return sorted.stream().map(Hit::id).toList();
}
static void eq(List<String> e,List<String> a){if(!e.equals(a))throw new AssertionError(a);}
public static void main(String[] args){
List<Point> p=List.of(new Point("a",new double[]{2}),new Point("c",new double[]{-1}),new Point("b",new double[]{1}));
eq(List.of("b","c"),top(new double[]{0},p,2));
eq(List.of(),top(new double[]{0},List.of(),3));
eq(List.of(),top(new double[]{0},p,0));
eq(List.of("b","c","a"),top(new double[]{0},p,10));
try{top(new double[]{0},List.of(p.get(0),p.get(0)),1);throw new AssertionError();}catch(IllegalArgumentException expected){}
try{top(new double[]{0,1},p,1);throw new AssertionError();}catch(IllegalArgumentException expected){}
System.out.println("PASS 6 tests");
}
}这些特殊情况也要试一试
- 距离相同必须在选择和输出两处使用一致ID规则。
- k为零仍校验数据,不把非法向量隐藏。
- 有限输入相减或最终长度仍可能溢出,明确拒绝。
进阶选读 · 后续再读:HNSW参数与过滤召回
场景数据量增大,精确扫描无法满足延迟预算。
做法用精确结果作为gold近邻,对不同构建和搜索参数测Recall@k与延迟;加入真实租户过滤,观察候选不足,避免只测无过滤理想数据。
复杂度查询复杂度依图结构、搜索宽度和数据分布而变;经验加速不等于任意数据的对数时间最坏界。
好处与代价搜索宽度提高通常增加计算并可能改善召回;内存、构建时间、更新行为与业务质量需要共同权衡。
05项目里怎么用
迁移编码模型时建立独立索引版本,抽样核对维度和归一化,再用同一查询集比较。新查询编码器与旧语料混用时,系统可能仍返回看似正常分数,因此上线前不能只依赖无异常启动或健康接口。
权限过滤应贯穿候选检索、重排和引用展示。若近似检索后再过滤导致不足k条,可在授权条件内增加搜索范围,但不能删除租户约束。缓存键也应包含身份范围和索引版本,避免把别人的Top-k直接复用。
报告性能时区分Embedding生成、数据库检索、重排与总耗时,保留失败与尾延迟。精确堆基线让错误更容易定位:若两种方式向量和过滤一致却差异大,先检查距离方向、索引参数与并列处理。
容易踩的坑
- 把距离越小越好与相似度越大越好写反。
- 直接迭代
PriorityQueue当作有序结果。 - 只报告堆操作
O(log k),忽略全部距离计算。 - 过滤不足时放宽权限以填满k条。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 精确扫描加堆 | 结果可证明,适合对照 | 每次读取全部候选 | 小语料与质量基线 |
| 近似索引 | 大数据下通常更快 | 可能漏近邻且需调参 | 有召回与延迟评测的服务 |
| 全量排序 | 实现直观,可得到全部次序 | 多余排序与内存成本 | 需要完整排名或小规模调试 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01堆为什么反转比较器?
展开参考答案与推导
希望保留项中最差者在根,超限时可以快速淘汰;最终展示再用正向比较器排序。
02近似召回与答案正确率相同吗?
展开参考答案与推导
不同。近似召回衡量与精确近邻的覆盖,精确近邻本身也可能缺乏回答证据;还需任务相关性和答案支持评估。
03维度检查通过能证明编码兼容吗?
展开参考答案与推导
不能,还要核对模型、预处理、归一化和版本;数值形状一致只是必要条件。
换个情况,还会处理吗
01结果顺序偶尔变化,应先查什么?
展开参考答案与推导
检查并列距离是否有稳定ID规则、堆是否直接迭代、上游数据是否重复;先固定这些确定性因素再看近似索引波动。
02过滤后只有两条而要求五条,怎么办?
展开参考答案与推导
返回实际授权结果并标记不足,或在同一授权范围内提高搜索宽度;不越权补齐。
03更换距离后质量下降如何定位?
展开参考答案与推导
确认向量训练目标、归一化和索引操作符匹配,再用精确基线复核排序方向与数值范围。
写代码试一试
01k超过数据量时应返回什么?
- 输入 / 条件
三项候选,k=10- 输出 / 预期
全部三项按规则排序
提示 无需制造占位候选。
展开参考答案与推导
堆不会超过候选数量,最终排序全部已保留项;结果长度为min(n,k)。
02为什么重复ID要拒绝?
- 输入 / 条件
同ID两个不同向量- 输出 / 预期
非法输入
提示 ID决定结果和引用身份。
展开参考答案与推导
重复会让结果身份不明确,也可能让同一资料占多个名额;先在导入或本函数边界统一去重策略。
03如何核对算法与全量排序?
- 输入 / 条件
小型固定随机向量集- 输出 / 预期
Top-k完全一致
提示 建立朴素对照。
展开参考答案与推导
对同一允许集合计算全部距离并按同一比较器排序取前k,与堆结果比较;这能发现方向和并列处理错误。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能先用精确搜索建立对照,再用相同查询比较近似索引找回了多少近邻、耗时多少,以及是否正确过滤无权读取的资料。
本篇重点加练
过滤与版本切换会改变可用候选,重点验证质量报告中的查询资格和语料边界。
- 权限过滤后结果不足
- 向量版本重建和切换
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
为同一组二维向量写出编码配置和三种距离的计算任务,说明比较前必须固定的归一化与版本条件。
- 提交内容
- 一份独立书面回答或标注图,包含自造例子、概念边界及判断依据。
- 怎样算完成
- 逐项回应题目要求,区分事实、假设与个人判断。
- 能用自己的例子解释适用条件,记录尚未弄懂的点。
练到的知识点编码模型与维度配置 · 欧氏距离、点积与余弦
- 作业 02代码推演
自造5个点和1个查询,分别排序距离与相似度,记录排序方向;实现穷举基线并与手算逐项对照。
- 提交内容
- 手工推演记录,写明输入约定、中间状态、最终判断;可运行时附程序对照记录。
- 怎样算完成
- 推演包含中间过程,数据形状、单位、顺序或状态前后一致。
- 主动检查至少一个边界或异常条件,能解释手算与运行差异。
练到的知识点距离排序方向 · 精确近邻搜索
- 作业 03原理问答
设计一次索引切换的查询资格表,同时记录用户过滤条件、语料版本与查询编码版本,说明哪些运行可以纳入同一份质量报告。
- 提交内容
- 概念或合同说明、关系图与自造案例,明确事实、假设和适用条件。
- 怎样算完成
- 关键概念有明确边界,能用案例检验声明。
- 未获证据支持的假设单独标注。
练到的知识点权限过滤后结果不足 · 向量版本重建和切换
综合应用
- 作业 04面试场景
数据量扩大后准备采用HNSW。提出一个参数对照实验,明确召回基准、构建成本、查询延迟和重复运行条件。
- 提交内容
- 场景分析与决策记录,包含信息缺口、约束、备选方案和验证计划。
- 怎样算完成
- 方案回应给定约束,列出至少一种替代方案及代价。
- 每项关键结论说明所需证据,不能核实的部分明确标注。
练到的知识点HNSW 近似索引 · 索引参数与召回代价
- 作业 05编程实践
用两个用户的微型向量库模拟过滤后结果不足,再切换编码版本;实现查询与索引版本校验,记录补足候选和拒绝不兼容请求的策略。
- 提交内容
- 可复现的最小程序或实验材料、自写测试、环境说明与运行记录;题目允许纸面实验时提交完整案例表。
- 怎样算完成
- 提交物按说明可以重现;测试覆盖正常、边界与失败场景。
- 运行证据与断言能核验声明的行为,注明模拟与真实执行范围。
练到的知识点权限过滤后结果不足 · 向量版本重建和切换
- 作业 06排错实验
故意把“越小越近”和“越大越近”的排序规则对调,构造最小反例并核验检索排名,提交故障定位过程。
- 提交内容
- 最小故障输入、复现步骤、观测记录、定位依据及回归用例。
- 怎样算完成
- 其他人可按记录复现故障,并区分现象与已验证原因。
- 定位过程有观测依据,回归用例可以再次暴露原问题。
练到的知识点欧氏距离、点积与余弦 · 距离排序方向 · 精确近邻搜索
- 作业 07编程实践
实现双索引的本地查询路由模拟器,给不同用户设置不同可见文档;在切换过程中记录每次实际命中的版本、过滤后候选数与拒绝原因。
- 提交内容
- 可复现的小程序或题目允许的离线实验、自写测试、输入和运行记录。
- 怎样算完成
- 输入、环境和运行过程可重现,测试覆盖声明的重点条件。
- 验证目标由合同确定,模拟结果不冒充真实服务证据。
练到的知识点权限过滤后结果不足 · 向量版本重建和切换
- 作业 08排错实验
将一份权限删除事件只应用到新索引、另一份版本更新只应用到旧索引,用固定查询回放检验切换验收能否识别差异。
- 提交内容
- 最小故障序列、观测记录、原因核对与可再次触发原问题的测试。
- 怎样算完成
- 故障可以稳定重现,现象与已验证原因分开。
- 观测记录足以判断声明的合同是否被违反。
练到的知识点权限过滤后结果不足 · 向量版本重建和切换
- 作业 09面试场景
切换后某类用户的结果数量骤降,整体召回指标却未变化。提出按用户权限范围与版本分层的排查方案,区分语料变化与检索策略变化。
- 提交内容
- 场景评审记录,包含约束、备选方案、未决信息和验证计划。
- 怎样算完成
- 方案回应题目约束,取舍能对应到可核验的事实。
- 给出失败或条件变化时的检查方式。
练到的知识点权限过滤后结果不足 · 向量版本重建和切换
进阶挑战 · 掌握前置后选做
- 作业 10设计评审
设计百万量级索引升级方案,但只在本地小数据验证切换流程;分别列出已验证逻辑与尚需真实规模测量的性能项。
- 提交内容
- 设计评审文档或对照实验报告,包含前置条件、方案取舍、验收证据与尚未验证的范围。
- 怎样算完成
- 比较使用一致条件与事先声明的标准,记录失败与未知结果。
- 说明方案的适用前提、未覆盖风险及后续验证方法。
练到的知识点编码模型与维度配置 · HNSW 近似索引 · 向量版本重建和切换
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 反例构造
构造“新旧索引对全量语料的召回相同,所以切换对所有用户等价”的反例,给出最小权限与语料配置。
- 提交内容
- 具体反例、原主张的适用前提、复现或逻辑证明及结论边界。
- 怎样算完成
- 反例满足原主张所声称的前提,确实针对其结论。
- 说明一个反例能推翻什么、不能进一步证明什么。
练到的知识点权限过滤后结果不足 · 向量版本重建和切换
- 作业 12思维拓展 · 条件变化
若索引更新由批量停机切换改为持续增量同步,重新定义查询一致性、候选完整性与回滚验收,说明哪些保证需要放宽或增加成本。
- 提交内容
- 变更前后条件表、重新评审的方案、检验用例与新增代价。
- 怎样算完成
- 只改变明确列出的条件,比较口径保持一致。
- 修改方案说明收益、代价与仍未验证的范围。
练到的知识点权限过滤后结果不足 · 向量版本重建和切换
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
pgvector:向量检索与索引说明 (新标签页)↗
先读距离运算符和精确搜索,再读 HNSW、过滤与索引参数,使用同一数据比较速度和召回。
- 实践参考文档英文
Spring AI:Vector Databases (新标签页)↗
阅读向量存储、相似度搜索及元数据过滤的抽象,核对嵌入配置和实际数据库能力。
- 深入教程中文
Google:嵌入入门 (新标签页)↗
先看编码、向量与嵌入空间的图示,建立相似度直觉;涉及训练的部分可在应用练习后再读。
- 深入参考文档英文
OpenJDK 17 PriorityQueue 源码 (新标签页)↗
按指定版本定位
PriorityQueue.offer/ poll /siftUp/siftDown;对照源码分析理解实际调用边界。