真实编码、交叉重排与索引对照
前两章已解释距离和候选集,本篇固定真实文本embedding,隔离比较ANN搜索与cross-encoder重排。双编码器分别编码query和document,文档向量可以提前缓存;cross-encoder把一对文本共同编码,能在token层比较“opened”和“cannot”,但每个候选都要一次配对计算。
paraphrase-MiniLM-L3-v2输出384维向量,其pooling在token表示上使用attention mask排除padding,再取均值;最后L2归一化便可用内积代替余弦。仅取CLS或对padding也平均会改变模型训练时的表示契约。对比训练把query与正例拉近、与负例拉远;“七天内退款”和“开封不能退款”是词面相似的难负例,不能只用天气文本作负例并据此宣称理解限制条件。
首次完整实验
Python3.12新环境安装 torch==2.8.0 sentence-transformers==5.2.0 faiss-cpu==1.13.2 qdrant-client==1.16.2。先运行下面下载器,将两个固定revision保存为本地目录:
from huggingface_hub import snapshot_download
for repo,revision,folder in [
('sentence-transformers/paraphrase-MiniLM-L3-v2','4ca70771034acceecb2e72475f72050fcdde4ddc','embed'),
('cross-encoder/ms-marco-TinyBERT-L-2-v2','81d1926f67cb8eee2c2be17ca9f793c7c3bd20cc','rerank')]:
snapshot_download(repo,revision=revision,local_dir=folder,allow_patterns=['*.json','*.txt','*.safetensors','pytorch_model.bin'])完整实验保存为retrieval.py,执行 OMP_NUM_THREADS=1 TOKENIZERS_PARALLELISM=false EMBED_DIR=./embed RERANK_DIR=./rerank python retrieval.py。固定单线程也避免本机多个底层并行运行库抢占;未限制线程的环境曾出现原生崩溃,不能将进程异常当成空召回。
import os,tempfile
import numpy as np
import torch,faiss
from sentence_transformers import SentenceTransformer,CrossEncoder
from qdrant_client import QdrantClient,models
torch.set_num_threads(1)
embed=SentenceTransformer(os.environ['EMBED_DIR'],device='cpu',local_files_only=True)
reranker=CrossEncoder(os.environ['RERANK_DIR'],device='cpu',local_files_only=True)
texts=['Refunds are allowed within seven days.', 'Opened products cannot be refunded.',
'Refund requests after seven days are rejected.', 'Delivery takes two days.']
query='Can I return an opened product within seven days?'
v=embed.encode(texts,normalize_embeddings=True).astype('float32')
q=embed.encode([query],normalize_embeddings=True).astype('float32')
rank=np.argsort(-(q@v.T)[0]);scores=reranker.predict([(query,texts[i]) for i in rank])
reranked=[int(rank[i]) for i in np.argsort(-scores)]
print('embedding',rank.tolist(),'cross-encoder',reranked,'scores',scores.tolist())
# Keep true semantic vectors fixed for the search comparison; augment only for index stress.
rng=np.random.default_rng(7)
corpus=np.concatenate([v,rng.normal(size=(2000,v.shape[1])).astype('float32')])
faiss.normalize_L2(corpus)
exact=faiss.IndexFlatIP(v.shape[1]);exact.add(corpus)
expected=exact.search(q,3)[1][0]
ann=faiss.IndexHNSWFlat(v.shape[1],8,faiss.METRIC_INNER_PRODUCT)
ann.hnsw.efConstruction=40;ann.add(corpus)
for effort in [4,64]:
ann.hnsw.efSearch=effort
ids=ann.search(q,3)[1][0]
print('HNSW',effort,'recall',len(set(ids)&set(expected))/3)
ivf=faiss.IndexIVFFlat(faiss.IndexFlatIP(v.shape[1]),v.shape[1],16,faiss.METRIC_INNER_PRODUCT)
ivf.train(corpus);ivf.add(corpus)
for probes in [1,16]:
ivf.nprobe=probes;ids=ivf.search(q,3)[1][0]
print('IVF',probes,'recall',len(set(ids)&set(expected))/3)
if probes==16:assert set(ids)==set(expected)
with tempfile.TemporaryDirectory() as folder:
client=QdrantClient(path=folder)
client.create_collection('docs',vectors_config=models.VectorParams(size=v.shape[1],distance=models.Distance.COSINE))
client.upsert('docs',[models.PointStruct(id=i,vector=vector.tolist(),payload={'tenant':'A' if i<3 else 'B','text':texts[i],'version':1}) for i,vector in enumerate(v)])
only_a=models.Filter(must=[models.FieldCondition(key='tenant',match=models.MatchValue(value='A'))])
hits=client.query_points('docs',query=q[0].tolist(),query_filter=only_a,limit=10).points
assert {p.id for p in hits}=={0,1,2}
client.set_payload('docs',payload={'version':2},points=[0])
client.delete('docs',models.PointIdsList(points=[2]));client.close()
client=QdrantClient(path=folder)
assert client.retrieve('docs',[0])[0].payload['version']==2
assert client.retrieve('docs',[2])==[]
assert {p.id for p in client.query_points('docs',query=q[0].tolist(),query_filter=only_a,limit=10).points}=={0,1}
client.close()
print('persistent CRUD and tenant filter passed')此夹具实际向量排序为[0,3,1,2],交叉重排后为[1,2,0,3],开封例外由第三提升到第一。分数是该模型的相关性logit,不是概率,更不是跨模型通用阈值。样例改进不能代替保留集;增加一组无例外条件的查询,验证重排没有总是偏爱否定句。
FAISS实验加入随机干扰向量只为施加索引搜索压力,不能用它衡量语义质量。HNSW的M控制邻居连边,efConstruction控制建图候选搜索,efSearch控制查询候选探索;后者增大通常提高召回并增加工作量,不保证每个查询严格单调。本次efSearch=4与64得到0和1召回;这些不是性能承诺。IVF先训练16个粗聚类,再只访问nprobe个桶;nprobe=16在此无量化索引扫描全部桶,与精确top3一致。PQ另将子向量编码为码本ID,有量化损失,即使全桶也未必恢复精确排序。
Qdrant本地模式实际写盘、按tenant过滤、更新payload、删除、关闭并重载。它不模拟远端HNSW集群和多副本一致性,因此ANN对照另用FAISS。服务化分片还需明确按ID定位更新、读一致性与删除生效时刻;故障后用相同查询/版本/权限重新验证,不能由本地close/reopen通过推出集群容灾正确。
练习
将query改成“Can I return an unopened item after ten days?”,记录两阶段排序,不预设第一条是什么。把全部A租户点删除后重载,确保A过滤为空、B仍存在。
完成后查看本章答案。
返回完整学习路线。