Skip to content

空间语义检索优化方案

弹药库: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空间分数是否期望
15031.0✅ 期望(别名匹配)
25021.0✅ 期望(别名匹配)
35010.81❌ 噪声("金朵云大会议室"语义接近)
48010.75❌ 噪声
58020.64❌ 噪声

Case 2:搜"数科大会议室"

rank空间分数是否期望
18020.76✅ 期望(别名匹配,但分数偏低)
25010.63❌ 噪声
38010.62❌ 噪声
45030.59❌ 噪声
55020.58❌ 噪声

核心问题

  1. 信号稀释:路径前缀"金螳螂西环路 | 总部大楼"出现在每条记录中,占用了 embedding 注意力预算,导致别名本身("数科大会议室")的信号被削弱(top score 仅 0.76)
  2. 语义混淆:纯向量检索无法区分"大会议室"与"小会议室"的精确差异
  3. 固定阈值一刀切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 类:纯模糊语义(无精确别名可匹配)

用户输入期望结果说明
"那个大房间"容量最大的会议室仅靠向量语义相似度
"靠窗的那间"靠窗会议室仅靠向量语义相似度(需提前在别名中维护)

关键约束

  1. 运营无法保证别名中一定包含空间定位信息(如"8楼"),维护成本过高
  2. "东面的会议室"这类业务定制叫法,可以要求运营在对应会议室别名中主动维护
  3. 空间树结构不一定是标准楼栋-楼层-房间,可能包含虚拟分区(行政区、访客区、礼堂A/B/C区)
  4. 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 1

1.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楼 8021条全文检索覆盖 B/C 类问法

关键设计决策

  1. A 通道 = 纯别名:仅包含 aliases 数组中的每条值,不拼接任何路径
  2. B 通道 = 短路径
    • 取空间树路径的 楼栋 | 楼层 | 房号,去掉园区级公共节点
    • 格式归一化:8F8楼5F5楼(纯正则机械替换)
    • 不包含会议室名称/别名
  3. A/B 共享 payloadspace_identity_typecap_tags 一致,通过 channel 字段区分
  4. 空间节点别名不展开(如"总部大楼"的别名"主楼"、"1号楼"),路径仅存规范化原始名。若业务需要"主楼"可搜到,由运营在对应会议室的别名中维护(如加"主楼大会议室")。避免排列组合爆炸。
  5. 虚拟分区透明处理:空间树是什么结构就存什么路径文本(如路径含"行政区"节点即存"行政区"),无需特殊逻辑

2.3 Qdrant Collection 设计

共用 1 个 collection,channel 字段区分:

json
// 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):同时传 vectorquery_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. 先上线方案一(1 小时内完成),快速验证过滤效果,同时收集 B/C 类问法的未命中 case
  2. 积累数据:持续记录"用户输入 → 检索结果 → 期望结果"的三元组,为后续评测打底
  3. 当方案一的 B/C 类覆盖缺口足够大时,启动方案二,彻底解决空间定位词的检索问题

Released under the Private License.