项目:离线混合检索

项目目标

在不安装模型、不访问网络和不使用密钥的条件下,构建一条可解释的第一阶段检索实验:

strict JSON fixture
    ↓
tenant + ACL + status + language/product filter
    ↓
BM25 通道 ─┐
            ├─ RRF ─ graded Recall/MRR/nDCG + security gate
toy dense ──┘

重点是管线契约、融合和评测,不是模拟真实模型性能。

项目文件

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@3Macro MRR@3Macro nDCG@3
BM250.7500000.8333330.819553
Toy dense1.0000001.0000000.951635
Hybrid RRF1.0000001.0000001.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:迁移真实组件

列出迁移清单:

  1. analyzer/搜索引擎与 index revision;
  2. 真实 query/document encoder、batch/cache 与 space contract;
  3. exact ground truth 和 ANN 参数;
  4. 真实 tenant/ACL 身份来源;
  5. 生产 query/qrels 与 blind pool;
  6. p95/p99、成本、新鲜度、恢复和回滚。

项目验收

  • 能解释 BM25 每个主要项及 analyzer 的影响。
  • 能证明过滤在两路评分前生效。
  • 能指出 toy dense 的手工语义与真实 Embedding 差距。
  • RRF 保留 source ranks,不直接相加原始分数。
  • Recall、MRR、nDCG 的分母与无答案处理明确。
  • 普通、-O、-W error 与 -O -W error 四种模式各 34 项测试通过。
  • Fixture、脚本输出和测试不包含凭据、缓存或网络依赖。
  • 不把当前指标推广成产品性能结论。

自测

  1. 为什么 q-e042 的 dense nDCG 低于 hybrid?
  2. 为什么 q-double-charge 的 BM25 为空不代表 BM25 无用?
  3. 为什么 q-ops-guest 不应被记为 Recall=0?
  4. 为什么 must_not_return 要分别检查每个召回通道的完整 rank window,而不是只看最终 top-k?
  5. 为什么重复 document ID 可能让 nDCG 超过 1?
  6. 为什么 rank fusion 仍受通道内 tie-break 影响?
  7. 用真实 Embedding 替换 toy vector 后,哪些数据必须重新生成?

完成后返回 语义搜索目录,并进入 Reranking。

参考资料

来源获取/复核日期:2026-07-22。教学行为以当前 fixture、脚本和测试为准;动态产品能力需按锁定版本复查。