项目:离线混合检索
项目目标
在不安装模型、不访问网络和不使用密钥的条件下,构建一条可解释的第一阶段检索实验:
strict JSON fixture
↓
tenant + ACL + status + language/product filter
↓
BM25 通道 ─┐
├─ RRF ─ graded Recall/MRR/nDCG + security gate
toy dense ──┘重点是管线契约、融合和评测,不是模拟真实模型性能。
项目文件
- semantic-search-fixture.json:10 条虚构文档与 7 条 query;
- toy_semantic_search.py:严格解析、过滤、两路召回、RRF 和指标;
- test_toy_semantic_search.py:34 项验证。
Fixture 顶层包含 schema_version、representation、documents 和 queries。解析器拒绝重复 JSON key、NaN/Infinity、会在转换为 float 时溢出的超大整数、未知/缺失字段、错误类型、重复 ID、不兼容维度、零/非单位向量,以及不可访问的 qrels。
Fixture 设计
Representation
7 维手工单位向量分别代表退款、重复扣款、上传、网络、会员、账号和内部运维。它只保证教学 fixture 内的 deterministic mapping:
- 不是训练过的 Embedding;
- 没有 tokenizer、模型泛化或跨语言能力;
- exact cosine 不代表 ANN;
- 当前 macro 指标不能与真实模型比较。
Document
每条记录具有稳定 ID、title/text、tenant、排序去重的 ACL、status、language、product、source revision 和 vector。Fixture 特意包含:
- beta 租户的高相似私有文档;
- alpha 租户的 draft 支付规则;
- 仅 platform 群组可见的内部 runbook;
- 两条同向量的上传文档,用于演示 dense 平局。
Query
每条 query 包含 fixture-supplied 的 tenant、subject groups、allow-list filters、query vector、1~3 级 qrels 和 must_not_return。正 qrels 必须满足该 query 的所有权限与过滤;must_not_return 必须存在且当前不可访问,防止测试写成永远无法触发的假门禁。它们是离线 oracle/audit 输入,不是运行时调用方可声明的可信身份:生产 tenant、groups 与授权快照必须由宿主 IAM 派生。
两路召回
BM25
教学 analyzer 做 NFKC/casefold、保留 ASCII 字母数字串,并把连续中文切成重叠二字符。BM25 使用词频饱和、文档频率和长度归一化;只返回正分数文档。
这不是生产中文分词器。它故意让“钱被扣了两次”与“重复扣款”没有共享二字符词项,从而展示词面漏召回;E042 保持整体,展示精确标识符优势。
Toy dense
对已过滤文档做 exact cosine/dot/negative-Euclidean,并用 document ID 稳定打破平局。“钱被扣了两次”与“重复扣款”共享手工主题维度,因此 dense 找到正确文档;E042 与通用上传文档同分,因此 dense 无法仅靠向量识别精确错误码。
RRF 与指标
每路只贡献 rank window 内的名次,默认 constant 60。报告明确标记 report_schema_version=semantic-search-offline-audit-v1 与 visibility=protected_audit,并保留:
- 每路 document ID、rank、score;
- hybrid 的 BM25/dense source ranks;
- 每条 query 的 Recall、MRR、nDCG;
- macro metrics;
- security_violations:审计每路完整 candidate window,记录 query、通道、阶段、候选排名、文档和原因,而不是只看最终 top-k。
无正 qrels 的 guest 内部运维 query 不进入 macro relevance 平均,但三个通道都必须返回空,内部文档仍由 must_not_return 审计。
protected_audit 表示输出含有 query 文本、完整 candidate window、qrels、禁返集与授权测试资料;它只能进入受控的离线评测/审计通道,不能直接作为 public search 或 RAG 用户响应。该标签本身不执行访问控制;面向用户的投影必须另行设计,并排除上述 oracle 和候选窗字段。
运行
从项目根目录(同时包含 docs/ 与 .website/)运行,不创建虚拟环境或缓存:
$env:PYTHONDONTWRITEBYTECODE = '1'
$script = '.\docs\语义搜索\examples\toy_semantic_search.py'
$fixture = '.\docs\语义搜索\examples\semantic-search-fixture.json'
python -B -W error $script `
--fixture $fixture `
--top-k 3 `
--rank-window 5 `
--rrf-constant 60
if ($LASTEXITCODE -ne 0) {
throw "语义搜索实验失败,退出码:$LASTEXITCODE"
}当前可复现结果
在原始 fixture、top-k=3、rank-window=5、RRF constant=60 下:
| 通道 | Macro Recall@3 | Macro MRR@3 | Macro nDCG@3 |
|---|---|---|---|
| BM25 | 0.750000 | 0.833333 | 0.819553 |
| Toy dense | 1.000000 | 1.000000 | 0.951635 |
| Hybrid RRF | 1.000000 | 1.000000 | 1.000000 |
还应满足:
- document_count=10,query_count=7;
- q-double-charge 的 BM25 为空,dense/hybrid 首位是 d-04-double-charge;
- q-e042 的 dense 首位是通用格式文档,hybrid 首位是精确 E042 文档;
- q-ops-guest 三路均为空;
- security_violations 为空数组;
- beta 私有文档和 draft 文档从不进入 alpha guest 的候选。
这些数字只证明当前代码与 fixture 的确定性行为,不证明 hybrid 在真实语料总是优于 dense。
运行测试
四种解释器模式都应通过;项目正确性不得依赖会被 python -O 移除的 bare assert,也不得在默认模式忽略 warning:
$env:PYTHONDONTWRITEBYTECODE = '1'
$project = '.\docs\语义搜索'
Push-Location $project
try {
python -B -m unittest discover -s .\examples -p 'test_toy_semantic_search.py' -v
if ($LASTEXITCODE -ne 0) {
throw "普通模式测试失败,退出码:$LASTEXITCODE"
}
python -B -O -m unittest discover -s .\examples -p 'test_toy_semantic_search.py' -v
if ($LASTEXITCODE -ne 0) {
throw "优化模式测试失败,退出码:$LASTEXITCODE"
}
python -B -W error -m unittest discover -s .\examples -p 'test_toy_semantic_search.py' -v
if ($LASTEXITCODE -ne 0) {
throw "warnings-as-errors 模式测试失败,退出码:$LASTEXITCODE"
}
python -B -O -W error -m unittest discover -s .\examples -p 'test_toy_semantic_search.py' -v
if ($LASTEXITCODE -ne 0) {
throw "优化且 warnings-as-errors 模式测试失败,退出码:$LASTEXITCODE"
}
}
finally {
Pop-Location
}测试覆盖 strict JSON、representation/vector(含超大整数的受控拒绝)、文档/查询 schema、qrels 与权限一致性、analyzer、三种 vector metric、BM25、RRF、过滤、graded metrics、重复 ranked ID、完整候选窗口泄漏、受保护审计 envelope、无答案、CLI 错误和四种解释器模式输出一致性。
动手任务
任务 A:观察词面与语义互补
新增一个错误码文档/query,再新增一个不共享字面的同义 query。分别预测 BM25/dense/hybrid 排名,再运行验证。若结果与预测不同,从 analyzer tokens 和 source ranks 解释。
任务 B:建立 hard filter 门禁
新增另一 tenant 的同向量文档、一个 archived 文档和空 subject groups query。测试它们不出现在 BM25、dense 或 hybrid 任一通道,而不是只检查最终结果。
任务 C:改变 rank window
将每路 window 改为 1、2、5,比较 q-e042 的 source ranks 与 nDCG。说明候选在进入融合前被截断时,RRF 为什么无法补回。
任务 D:实现 Hit@k
在保持 Recall 定义不变的前提下增加 Hit@k,写一个拥有两个 qrels、只命中一个的测试,证明 Hit=1 而 Recall=0.5。
任务 E:迁移真实组件
列出迁移清单:
- analyzer/搜索引擎与 index revision;
- 真实 query/document encoder、batch/cache 与 space contract;
- exact ground truth 和 ANN 参数;
- 真实 tenant/ACL 身份来源;
- 生产 query/qrels 与 blind pool;
- p95/p99、成本、新鲜度、恢复和回滚。
项目验收
- 能解释 BM25 每个主要项及 analyzer 的影响。
- 能证明过滤在两路评分前生效。
- 能指出 toy dense 的手工语义与真实 Embedding 差距。
- RRF 保留 source ranks,不直接相加原始分数。
- Recall、MRR、nDCG 的分母与无答案处理明确。
- 普通、
-O、-W error与-O -W error四种模式各 34 项测试通过。 - Fixture、脚本输出和测试不包含凭据、缓存或网络依赖。
- 不把当前指标推广成产品性能结论。
自测
- 为什么 q-e042 的 dense nDCG 低于 hybrid?
- 为什么 q-double-charge 的 BM25 为空不代表 BM25 无用?
- 为什么 q-ops-guest 不应被记为 Recall=0?
- 为什么 must_not_return 要分别检查每个召回通道的完整 rank window,而不是只看最终 top-k?
- 为什么重复 document ID 可能让 nDCG 超过 1?
- 为什么 rank fusion 仍受通道内 tie-break 影响?
- 用真实 Embedding 替换 toy vector 后,哪些数据必须重新生成?
参考资料
- Robertson & Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond
- Cormack, Clarke & Buettcher, Reciprocal Rank Fusion
- Elasticsearch: Reciprocal rank fusion
- Qdrant: Hybrid and Multi-Stage Queries
来源获取/复核日期:2026-07-22。教学行为以当前 fixture、脚本和测试为准;动态产品能力需按锁定版本复查。