上一篇把原始文档变成了带来源、版本和稳定 ID 的干净片段。本篇继续完成索引:选择能理解业务语言的嵌入模型,确定相似度与归一化方式,并依据数据规模、过滤能力和运维边界选择向量库。
一、痛点:排行榜第一不一定适合你的问题
嵌入模型将问题与片段编码为稠密向量,向量的几何距离代表语义相关性。但公开榜单混合多种语言与任务,企业查询可能充满中文缩写、型号、工单号和内部术语。一个通用模型在平均分上领先,不代表能区分“退款到账”和“退款申请”。选型必须用自己的问题—证据对验证。
先固定四个变量:模型版本、输入前缀、最大长度和相似度函数。一些模型要求查询与文档使用不同指令前缀;漏加前缀会直接降低效果。超长片段可能被静默截断,关键信息若位于尾部便永远无法召回。余弦相似度比较方向,点积还受向量长度影响;若模型文档要求归一化,就应在写入和查询两侧执行同一规则。
二、原理:向量距离只是候选排序信号
设查询向量为 q、文档向量为 d,余弦相似度是两者点积除以各自范数,范围通常为 -1 到 1。欧氏距离越小越相近,点积则越大越相近。不同向量库对score的方向和定义并不统一,迁移时不能沿用旧阈值。最保险的办法是用已知向量写一个契约测试,确认排序及返回分数。
下面实现无第三方依赖的余弦检索,演示归一化、元数据过滤与确定排序。生产模型只是替换向量生成过程,检索契约不变。
fromdataclassesimportdataclassfrommathimportsqrt@dataclass(frozen=True)classVectorRow:chunk_id:strdepartment:strvector:tuple[float,...]defnormalize(values:tuple[float,...])->tuple[float,...]:norm=sqrt(sum(value*valueforvalueinvalues))ifnorm==0:raiseValueError("zero vector")returntuple(value/normforvalueinvalues)defcosine(left:tuple[float,...],right:tuple[float,...])->float:a,b=normalize(left),normalize(right)returnsum(x*yforx,yinzip(a,b,strict=True))rows=[VectorRow("travel","finance",(0.9,0.1,0.0)),VectorRow("leave","hr",(0.1,0.9,0.1)),VectorRow("invoice","finance",(0.8,0.2,0.1)),]query=(1.0,0.0,0.0)allowed="finance"ranked=sorted(((cosine(query,row.vector),row)forrowinrowsifrow.department==allowed),key=lambdapair:(-pair[0],pair[1].chunk_id),)forscore,rowinranked:print(f"{row.chunk_id}\t{score:.4f}\t{row.department}")运行输出:
travel 0.9939 finance invoice 0.9631 finance注意权限过滤发生在候选集合内,而不是检索后再删。如果先取全库 Top-5 再移除无权限结果,用户可能只剩零条,且某些系统的日志或分数仍会泄露受限内容。向量库必须支持所需的布尔、集合和时间过滤,并验证过滤与近邻索引结合时的召回表现。
三、实现:用业务数据做两阶段选型
第一阶段离线筛模型。准备至少 50 个查询,每个标注一个或多个相关片段,并加入难负例:词面相似但答案不同的段落。比较 Recall@5、MRR、编码吞吐、向量维度、最大长度、许可证和中文表现。第二阶段用候选模型跑真实规模压测,测索引构建时间、单查询 p95、并发吞吐、内存与成本。
向量库选型看约束而非品牌。单机原型可以用 FAISS 或 SQLite 扩展;已有 PostgreSQL 团队可用 pgvector,减少系统数量;需要分布式扩展、复杂过滤和在线扩容时,再评估 Qdrant、Milvus、Weaviate 或托管服务。必须检查备份恢复、滚动升级、多租户隔离、删除一致性和监控,而不只是百万向量 QPS。
下例计算小型标注集的 Recall@K 和 MRR。候选列表可以直接替换为各模型的真实检索结果,从而得到可重复的选型表。
gold={"q1":{"c1"},"q2":{"c3","c4"},"q3":{"c8"},}retrieved={"q1":["c2","c1","c9"],"q2":["c4","c5","c3"],"q3":["c7","c6","c8"],}k=2recalls=[]reciprocal_ranks=[]forquery_id,relevantingold.items():ranking=retrieved[query_id]hit_count=len(set(ranking[:k])&relevant)recalls.append(hit_count/len(relevant))first=next((ifori,iteminenumerate(ranking,1)ifiteminrelevant),None)reciprocal_ranks.append(0.0iffirstisNoneelse1/first)print(f"Recall@{k}={sum(recalls)/len(recalls):.3f}")print(f"MRR={sum(reciprocal_ranks)/len(reciprocal_ranks):.3f}")运行输出:
Recall@2=0.500 MRR=0.611Recall@K 衡量相关证据是否进入前 K;MRR 更关心第一个相关结果出现多早。多证据问题还应看所有必要证据是否齐全。比较模型时保持切分、语料版本和 K 不变,并报告置信区间;只看十道题的百分点差异很可能是噪声。
四、踩坑:索引是有版本的数据产品
换模型意味着旧向量不可直接复用,因为维度和空间都可能改变。为索引记录embedding_model、修订号、维度、距离类型、归一化标志与语料哈希;新模型写入新集合,回填并验证后再切换别名。双写期间要防止新文档只进入一侧。删除请求也必须传播到所有版本及缓存。
近似最近邻参数会交换速度和召回。HNSW 的构建参数、查询ef,IVF 的聚类数量和探测范围都需在相同硬件上压测。不要把近似索引的漏召回误判为嵌入模型差。还要批量编码、限制重试、校验向量非零且数值有限;某批失败时保留 chunk ID,不能让“索引完成”掩盖缺失。
维度越高通常意味着更大的存储、网络和内存开销,却不保证业务准确率更高。估算容量时至少计入向量本体、近邻图、主键和过滤元数据,并给索引构建留临时空间。量化向量可降低成本,但要在业务集上比较精确索引与量化索引的召回差,而不是仅看压缩比例。
在线编码服务要固定批大小上限和输入长度,记录每批成功数量与耗时。查询嵌入和文档嵌入若由不同部署提供,启动时应核对模型修订号;版本不一致往往不会报错,却会让相似度整体失真。对典型句对保存预期排序,作为每次部署后的冒烟测试。
五、验证:先通过契约,再比较效果和成本
上线前验证同一文本重复编码结果稳定;查询与文档前缀正确;超长输入有显式告警;过滤不会越权;删除后查不到;备份可恢复;索引切换可回滚。随后在业务集上同时记录质量、延迟和价格,选择满足质量门槛后的最低总成本方案,而不是单指标冠军。
本篇完成了片段向量化和存储决策。下一篇会把加载、索引、检索与生成串成一个最小可用问答链路,并给出拒答和引用的端到端实现。
参考来源
- Sentence Transformers:Semantic Search
- pgvector:向量相似度与索引
- Qdrant:过滤检索
👍 觉得有用就点个赞 + 收藏,方便回头查阅;有疑问直接在评论区留言,我看到都会回。
🚀 本文属于《RAG 知识库问答实战》系列,持续更新,关注不迷路。
📌 文章里的代码都能直接跑。想要可直接 clone 的完整工程 + 配套部署脚本 / 踩坑清单?评论一声或发邮件到cj2664@qq.com,我免费发你。
如果你正好在做类似系统、或有工程化难题想找人做,也欢迎邮件聊一句——我按实际情况评估,能落地的就接单或出方案。评论和邮件都能直接找到我,不用跳别的平台。