空间语义检索优化方案
弹药库:Qdrant 向量数据库,BGE-M3 Embedding 模型(1024d)。 前置阅读:空间语义管理 PRD §12 向量化策略。
0. 背景与数据现状
0.1 空间树与别名数据
当前系统纳管的空间树结构:
金螳螂西环路
└─ 总部大楼
├─ 5F
│ ├─ 501 (别名:"金朵云大会议室"、"5楼大会议室")
│ ├─ 502 (别名:"金朵云小会议室"、"5楼小会议室")
│ └─ 503 (别名:"金朵云小会议室"、"5楼小会议室")
└─ 8F
├─ 801 (别名:"数科小会议室"、"8楼小会议室")
└─ 802 (别名:"数科大会议室"、"8楼大会议室")0.2 当前入库与检索策略
入库:完整路径 + 别名 拼接为一条文本
┌─────────────────────────────────────────────────────────────────┐
│ 金螳螂西环路 | 总部大楼 | 5F | 501 | 金朵云大会议室 │
│ 金螳螂西环路 | 总部大楼 | 5F | 501 | 5楼大会议室 │
│ 金螳螂西环路 | 总部大楼 | 5F | 502 | 金朵云小会议室 │
│ ...(共 10 条,每个别名一条) │
└─────────────────────────────────────────────────────────────────┘
检索:纯向量检索 → 按 score 降序返回 → score > 0.7 固定阈值过滤0.3 真实检索问题
以下数据来自 Qdrant 实际检索结果,详见 调试.md。
Case 1:搜"金朵云小会议室"
| rank | 空间 | 分数 | 是否期望 |
|---|---|---|---|
| 1 | 503 | 1.0 | ✅ 期望(别名匹配) |
| 2 | 502 | 1.0 | ✅ 期望(别名匹配) |
| 3 | 501 | 0.81 | ❌ 噪声("金朵云大会议室"语义接近) |
| 4 | 801 | 0.75 | ❌ 噪声 |
| 5 | 802 | 0.64 | ❌ 噪声 |
Case 2:搜"数科大会议室"
| rank | 空间 | 分数 | 是否期望 |
|---|---|---|---|
| 1 | 802 | 0.76 | ✅ 期望(别名匹配,但分数偏低) |
| 2 | 501 | 0.63 | ❌ 噪声 |
| 3 | 801 | 0.62 | ❌ 噪声 |
| 4 | 503 | 0.59 | ❌ 噪声 |
| 5 | 502 | 0.58 | ❌ 噪声 |
核心问题:
- 信号稀释:路径前缀"金螳螂西环路 | 总部大楼"出现在每条记录中,占用了 embedding 注意力预算,导致别名本身("数科大会议室")的信号被削弱(top score 仅 0.76)
- 语义混淆:纯向量检索无法区分"大会议室"与"小会议室"的精确差异
- 固定阈值一刀切:
score > 0.7对 Case 2 勉强可用,对 Case 1 则引入大量噪声
0.4 业务问法全景
用户表达会议室意图时,输入形式千差万别。汇总如下:
A 类:精确别名(用户说的恰好是已维护的别名)
| 用户输入 | 期望结果 | 当前表现 |
|---|---|---|
| "金朵云小会议室" | 502, 503 | ✅ 1.0 命中,但混入 501(0.81) |
| "金朵云大会议室" | 501 | ✅ 预期可命中 |
| "数科大会议室" | 802 | ⚠️ 命中但分数低(0.76) |
| "数科小会议室" | 801 | ✅ 预期可命中 |
| "5楼大会议室" | 501 | ✅ 预期可命中 |
B 类:空间定位 + 泛类型(包含楼层/区域等定位词)
| 用户输入 | 期望结果 | 说明 |
|---|---|---|
| "8楼会议室" | 801, 802 | 别名中不一定包含"8楼",需路径通道补充 |
| "5楼会议室" | 501, 502, 503 | 同上 |
| "西环路的会议室" | 全部 | 园区级定位,路径通道可覆盖 |
C 类:纯定位词(不带"会议室"后缀)
| 用户输入 | 期望结果 | 说明 |
|---|---|---|
| "8楼" | 801, 802 | 需从路径文本中匹配 |
| "5楼" | 501, 502, 503 | 同上 |
D 类:业务自定义别称(需运营主动维护)
| 用户输入 | 期望结果 | 说明 |
|---|---|---|
| "东面的会议室" | 某个特定会议室 | 运营需在此会议室的别名中加上"东面会议室" |
| "主楼的会议室" | 总部大楼下全部 | 运营需在每个会议室别名中加上"主楼X会议室" |
| "VIP房间" | 某个特定会议室 | 运营需维护"VIP房间"别名 |
E 类:虚拟分区(空间树非标准楼栋-楼层结构)
| 用户输入 | 期望结果 | 说明 |
|---|---|---|
| "行政区会议室" | 空间树"行政区"节点下所有会议室 | 路径文本自然包含"行政区",或运营在别名中维护 |
| "礼堂A区" | 礼堂A区内的会议室 | 同上 |
F 类:纯模糊语义(无精确别名可匹配)
| 用户输入 | 期望结果 | 说明 |
|---|---|---|
| "那个大房间" | 容量最大的会议室 | 仅靠向量语义相似度 |
| "靠窗的那间" | 靠窗会议室 | 仅靠向量语义相似度(需提前在别名中维护) |
关键约束:
- 运营无法保证别名中一定包含空间定位信息(如"8楼"),维护成本过高
- "东面的会议室"这类业务定制叫法,可以要求运营在对应会议室别名中主动维护
- 空间树结构不一定是标准楼栋-楼层-房间,可能包含虚拟分区(行政区、访客区、礼堂A/B/C区)
- LLM slot filling 只提取一个整体
room_name,不拆分"位置"和"名称",也无法感知空间树结构
方案一:快速优化(低改动,在现有入库策略基础上只改过滤逻辑)
1.1 适用场景
在不改变入库策略(路径+别名拼接)的前提下,仅优化检索结果的过滤阈值。
1.2 算法:双层相对阈值
输入:向量检索结果 candidates(按 score 降序)
输出:过滤后的候选集
Step 1: 计算相对阈值
top_score = candidates[0].score
relative_threshold = top_score * 0.85 // 85% of top score
Step 2: 取 max(相对阈值, 绝对阈值下限)
effective_threshold = max(relative_threshold, 0.60)
Step 3: 过滤
filtered = [c for c in candidates if c.score >= effective_threshold]
Step 4: 兜底
if filtered 为空:
filtered = candidates[:1] // 至少返回 top 11.3 效果推演
Case 1:搜"金朵云小会议室"
candidates: [503:1.0, 502:1.0, 501:0.81, 801:0.75, 802:0.64]
top_score = 1.0, relative = 0.85
effective = max(0.85, 0.60) = 0.85
→ 过滤结果: [503, 502] ✅ 干净Case 2:搜"数科大会议室"
candidates: [802:0.76, 501:0.63, 801:0.62, 503:0.59, 502:0.58]
top_score = 0.76, relative = 0.646
effective = max(0.646, 0.60) = 0.646
→ 过滤结果: [802] ✅ 干净边界 Case 2':搜"数科大会议室"但 top score 更低
candidates: [802:0.55, 501:0.52, ...]
top_score = 0.55, relative = 0.468
effective = max(0.468, 0.60) = 0.60
→ 过滤结果: [] → 兜底返回 [802]1.4 改动范围
| 项 | 说明 |
|---|---|
| 改动点 | 检索结果后增加一个过滤函数(~10 行代码) |
| 不改动 | 入库 ETL、向量库 schema、Embedding 模型 |
| 风险 | 极低,纯后处理逻辑 |
1.5 局限性
- 不解决"路径前缀稀释别名信号"导致的 top score 偏低(Case 2 只有 0.76)
- 不解决"大/小"近义词语义混淆(Semantic Leakage)
- 本质是画一条更聪明的噪声线,未提升召回质量
- 无法覆盖 B/C 类问法("8楼会议室"、"8楼"),因为"8楼"如果不在别名中,向量检索根本拉不到
方案二:双通道混合检索(架构级改造)
2.1 核心思想
"语义匹配"(别名向量)和"空间定位"(路径全文检索)拆成两条独立链路,各用最匹配的检索方式,RRF 融合排序。
2.2 入库策略改造
一个空间实体产出两类入库文本:
| 通道 | 文本示例 | 记录数 | 检索方式 | 定位 |
|---|---|---|---|---|
| A 通道(语义别名) | 数科大会议室、8楼大会议室 | N条(别名数) | 混合检索(向量 + 全文) | 覆盖 A/D/F 类问法 |
| B 通道(空间定位) | 总部大楼 8楼 802 | 1条 | 全文检索 | 覆盖 B/C 类问法 |
关键设计决策:
- A 通道 = 纯别名:仅包含
aliases数组中的每条值,不拼接任何路径 - B 通道 = 短路径:
- 取空间树路径的
楼栋 | 楼层 | 房号,去掉园区级公共节点 - 格式归一化:
8F→8楼、5F→5楼(纯正则机械替换) - 不包含会议室名称/别名
- 取空间树路径的
- A/B 共享 payload:
space_id、entity_type、cap_tags一致,通过channel字段区分 - 空间节点别名不展开(如"总部大楼"的别名"主楼"、"1号楼"),路径仅存规范化原始名。若业务需要"主楼"可搜到,由运营在对应会议室的别名中维护(如加"主楼大会议室")。避免排列组合爆炸。
- 虚拟分区透明处理:空间树是什么结构就存什么路径文本(如路径含"行政区"节点即存"行政区"),无需特殊逻辑
2.3 Qdrant Collection 设计
共用 1 个 collection,channel 字段区分:
// A 通道记录
{
"id": "uuid-502-alias-0",
"vector": [0.123, -0.456, ...], // BGE-M3 1024d
"payload": {
"space_id": "a99e0a22-...",
"channel": "alias",
"text": "金朵云小会议室",
"entity_type": "SPACE",
"cap_tags": ["meet"]
}
}
// B 通道记录(vector 为空或占位,不参与向量检索)
{
"id": "uuid-502-path",
"vector": null,
"payload": {
"space_id": "a99e0a22-...",
"channel": "path",
"text": "总部大楼 5楼 502",
"entity_type": "SPACE",
"cap_tags": ["meet"]
}
}A、B 通道均在 text 字段上建 Qdrant 全文索引(full_text index)。A 通道同时保留向量用于混合检索,B 通道仅用于全文检索。
2.4 两段式检索流程
用户输入: room_name = "8楼会议室"
┌──────────────────────────────────────┐
│ Stage 1: A 通道内部混合检索(Qdrant 原生)│
│ ┌──────────┐ ┌──────────┐ │
│ │ 向量检索 │ │ 全文检索 │ │
│ │ vec(q) │ │ text(q) │ │
│ │ ch=alias │ │ ch=alias │ │
│ │ top_k=10 │ │ top_k=10 │ │
│ └────┬─────┘ └────┬─────┘ │
│ └──────┬───────┘ │
│ ▼ │
│ Qdrant 内置 RRF │
└──────────────┬───────────────────────┘
▼
A 通道 Top-K
[801, 802, 501, ...]
│
┌──────────────┴───────────────────────┐
│ Stage 2: 外层 RRF 融合 │
│ │
│ A 通道结果 ─┐ │
│ ├─ RRF ── 按 spaceId │
│ B 通道结果 ─┘ 去重排序 → top5 │
└──────────────┬───────────────────────┘
│
┌──────────┴──────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ B 通道 │ │ │
│ 全文检索 │ │ │
│ text(q) │ │ │
│ ch=path │ │ │
│ top_k=10 │ │ │
└─────┬────┘ │ │
▼ │ │
B 通道 Top-K │ │
[802, 801, ...] │ │
│ │ │
└────────────┘ │
│ │
▼ │
┌────────────┐ │
│ 最终候选集 │ │
└────────────┘ │Stage 1(Qdrant 原生 hybrid search):同时传
vector和query_text,内部自动 RRF 融合。全文检索在此阶段天然承担"字符精确匹配"职责——"金朵云小会议室"不会命中"金朵云大会议室"。 Stage 2(应用层 RRF):融合 A 通道(内部已融合好的)和 B 通道结果,最终按 spaceId 去重排序。
2.5 RRF 融合算法
Stage 1: A 通道内部混合检索(Qdrant 原生 Hybrid Search)
Qdrant.query_points(
prefetch=[
{ using: "vector_idx", query: vec(user_input), filter: channel="alias", limit: 10 },
{ using: "text_idx", query: user_input, filter: channel="alias", limit: 10 },
],
fusion = Fusion.RRF // Qdrant 内置 RRF,k=60
)
→ 输出: A 通道已融合的 top_k 结果(向量+全文内部 RRF 排序完成)
Stage 2: 外层 RRF(A 通道结果 + B 通道结果)
输入:
A_ranked: [{space_id, rank}, ...] -- Stage 1 输出
B_ranked: [{space_id, rank}, ...] -- B 通道纯全文检索
k: 60 (平滑常数)
对于每个唯一的 space_id:
rrf_score = 0
if space_id in A_ranked: rrf_score += 1 / (k + A_rank)
if space_id in B_ranked: rrf_score += 1 / (k + B_rank)
按 rrf_score 降序 → top_n = 5 截断关键点:全文检索的"字符精确匹配"能力在 Stage 1 的 A 通道内部就已生效,天然区分"金朵云小会议室"和"金朵云大会议室"(大≠小),不需要额外的 boost 步骤。
2.6 效果推演
Case 1:搜"金朵云小会议室"(精确别名)
Stage 1: A 通道内部 Hybrid
┌─ 向量检索 ────────────────────┐
│ rank 1: 502 (1.0) "金朵云小会议室" │
│ rank 2: 503 (1.0) "金朵云小会议室" │
│ rank 3: 501 (0.85) "金朵云大会议室" │ ← 语义接近,"大"≠"小"混淆
└──────────────────────────────┘
┌─ 全文检索 ────────────────────┐
│ rank 1: 502 "金朵云小会议室" ✅ │ ← 精确子串命中
│ rank 2: 503 "金朵云小会议室" ✅ │
│ 501 不命中 ("金朵云中无"小") │ ← 全文天然区分的"大""小"
└──────────────────────────────┘
Qdrant 内部 RRF 融合后:
rank 1: 502 (1/(60+1) + 1/(60+1) = 0.0328)
rank 2: 503 (1/(60+2) + 1/(60+2) = 0.0323)
rank 3: 501 (1/(60+3) + 0 = 0.0159) ← 被拉开 50%
Stage 2: 外层 RRF
B 通道:空 ("金朵云"不在路径中)
→ 502: 0.0164, 503: 0.0161, 501: 0.0159
→ 最终: [502, 503] ✅ 干净Case 2:搜"数科大会议室"(精确别名 + 低分)
Stage 1: A 通道内部 Hybrid
向量:802 (0.95), 501 (0.72)
全文:802 命中("数科大会议室"), 501 不命中
内部 RRF:802 >> 501
Stage 2: 外层 RRF
B 通道:空 ("数科"不在路径中)
→ 最终: [802] ✅ 干净。全文检索在 Stage 1 就把 501 拉开了Case 3:搜"8楼会议室"(空间定位 + 泛类型)
Stage 1: A 通道内部 Hybrid
向量:801 (0.9), 802 (0.88), 501 (0.75)
全文:"8楼"+"会议室" 命中 "8楼小会议室"(801), "8楼大会议室"(802)
内部 RRF:801 ≈ 802 >> 501
Stage 2: 外层 RRF
A 输出:801(1), 802(2), 501(3)
B 输出:802(1), 801(2) -- "8楼"命中路径"总部大楼 8楼 801/802"
801: 1/(60+1) + 1/(60+2) = 0.0325 ✅
802: 1/(60+2) + 1/(60+1) = 0.0325 ✅
501: 1/(60+3) + 0 = 0.0159 ❌ 被 A 内部 + B 缺失 双重降权
→ 最终: [801, 802] ✅ 完美Case 4:搜"8楼"(纯定位词)
Stage 1: A 通道内部 Hybrid
向量:"8楼" 语义泛,可能拉到 "5楼" 条目
全文:命中所有含"8楼"的别名 → 801, 802 排名靠前
Stage 2: 外层 RRF
B 通道:"8楼" 直接命中路径 → 802(1), 801(2)
801, 802 双通道都命中 → 极高置信度 ✅Case 5:搜"主楼大会议室"(别名未维护"主楼")
A 通道 Hybrid → "主楼"不在别名中,全文不命中,仅靠向量弱匹配
B 通道 → "主楼"不在路径中(存的是"总部大楼")
→ 效果不佳,属于数据治理边界。运营需在对应会议室别名中加"主楼大会议室"2.7 问法全景覆盖表
| 问法分类 | 典型输入 | A 通道(Hybrid 向量+全文) | B 通道(全文路径) | 覆盖情况 |
|---|---|---|---|---|
| A 精确别名 | "金朵云小会议室" | ⭐ 全文排除"大"+向量高分 | — | ✅ 好 |
| A 精确别名 | "数科大会议室" | ⭐ 全文精确命中 | — | ✅ 好 |
| B 定位+类型 | "8楼会议室" | ⭐ 全文命中别名 | ⭐ 路径命中 | ✅ 好(双通道互补) |
| C 纯定位 | "8楼" | △ 全文命中含"8楼"的别名 | ⭐ 路径命中 | ✅ 好(B 通道兜底) |
| D 业务别名 | "东面的会议室" | ⭐ 别名(需维护) | — | ⚠️ 依赖运营维护 |
| D 业务别名 | "主楼会议室" | ⚠️ 别名未维护则差 | — | ⚠️ 依赖运营维护 |
| E 虚拟分区 | "行政区会议室" | △ 别名中可能无 | ⭐ 路径含"行政区" | ✅ 好(B 通道兜底) |
| F 模糊语义 | "那个大房间" | ⭐ 向量语义相似 | — | △ 依赖向量泛化能力 |
2.8 改动范围
| 改动项 | 工作量 | 说明 |
|---|---|---|
| 入库 ETL 改造 | 中 | A 通道存纯别名 + vector;B 通道存短路径(不存 vector) |
| Qdrant 全文索引 | 低 | A、B 通道 text 字段均建 full_text index |
| F→楼 归一化 | 低 | ETL 中增加一条正则替换 |
| A 通道 Hybrid Search | 中 | 检索时 A 通道同时传 vector + text,Qdrant 内部 RRF 融合 |
| 外层 RRF 融合 | 低 | A 通道结果 + B 通道结果 → 应用层 RRF → top5 |
| Collection schema | 低 | 新增 channel 字段 |
整体预估:3-5 人天(不含历史数据迁移)。
2.9 与方案一对比
| 维度 | 方案一(双层阈值) | 方案二(双通道混合检索) |
|---|---|---|
| 改动量 | 极小(10 行) | 较大(入库 + 检索) |
| A 类(精确别名)噪声 | ✅ 可过滤 | ✅ A 内 Hybrid 全文区分 |
| B/C 类(空间定位) | ❌ 无能力覆盖 | ✅ B 通道直击 |
| top score 偏低 | ❌ 无法改善 | ✅ A 内全文提升信号 |
| 语义混淆(大/小) | ❌ 仅被动过滤 | ✅ A 内全文天然区分 |
| 风险 | 低(纯后处理) | 中(架构变更需回归) |
建议推进路径
- 先上线方案一(1 小时内完成),快速验证过滤效果,同时收集 B/C 类问法的未命中 case
- 积累数据:持续记录"用户输入 → 检索结果 → 期望结果"的三元组,为后续评测打底
- 当方案一的 B/C 类覆盖缺口足够大时,启动方案二,彻底解决空间定位词的检索问题
