第八章:混合搜索深度解析(Hybrid Search Deep Dive)#

本章对 core/search/ 目录下各模块做技术深度解析,涵盖向量搜索、关键词搜索、RRF 融合、去重策略、搜索增强和参数调优。




8.3 RRF 融合算法#

8.3.1 公式#

Reciprocal Rank Fusion(倒数排名融合)将多个排名列表合并为一个:

RRF_score(d) = Σ 1 / (k + rank_i(d))
  • d:文档(Chunk)

  • k:平滑参数(通常 k = 60)

  • rank_i(d):文档 d 在第 i 个排名列表中的排名(从 0 开始)

8.3.2 为什么用 RRF 而不是加权平均?#

假设关键词搜索和向量搜索各返回 Top 10,如果用简单的加权平均:

// ❌ 加权平均的问题
const blended = 0.5 * keywordScore + 0.5 * vectorScore;

问题在于:

  1. 不同引擎得分分布不同:关键词的 BM25 分数和向量的 cosine similarity 量纲完全不同

  2. 排名比分数更可靠:第一名和第二名的差距可能远大于 0.01 分差

  3. 无法跨列表比较:第 3 名的关键词结果和第 100 名的向量结果无法直接对比分数

RRF 只依赖排名,不依赖分数绝对值天然解决了这些问题。

8.3.3 k 参数的作用#

k = 60 时:
- rank=0(第1名)→ 1/(60+0) = 0.0167
- rank=1(第2名)→ 1/(60+1) = 0.0164
- rank=59(第60名)→ 1/(60+59) = 0.0084
- rank=100 → 1/160 = 0.00625

k 值越大,RRF 对排名的敏感度越低(不同排名之间的差距被压缩)。GBrain 默认 k=60,经过调优适合知识库搜索场景。

8.3.4 RRF 示意图#

        flowchart LR
    KW1["关键词 #1<br/>Chunk A"]
    KW2["关键词 #2<br/>Chunk B"]
    KW3["关键词 #3<br/>Chunk C"]
    KW4["关键词 #4<br/>Chunk D"]

    Vec1["向量 #1<br/>Chunk A"]
    Vec2["向量 #2<br/>Chunk E"]
    Vec3["向量 #3<br/>Chunk B"]
    Vec4["向量 #4<br/>Chunk F"]

    RRF["RRF 融合<br/>k=60"]
    Final["最终排名"]

    KW1 & KW2 & KW3 & KW4 --> RRF
    Vec1 & Vec2 & Vec3 & Vec4 --> RRF

    RRF --> Final

    style RRF fill:#fff3e0
    

8.3.5 rrfFusion() 实现#

// search/hybrid.ts
export function rrfFusion(lists: SearchResult[][], k: number, applyBoost = true): SearchResult[] {
  const scores = new Map<string, { result: SearchResult; score: number }>();

  for (const list of lists) {
    for (let rank = 0; rank < list.length; rank++) {
      const r = list[rank];
      const key = `${r.slug}:${r.chunk_id ?? r.chunk_text.slice(0, 50)}`;
      const rrfScore = 1 / (k + rank);  // ← 倒数排名

      if (existing) {
        existing.score += rrfScore;    // 累加跨列表得分
      } else {
        scores.set(key, { result: r, score: rrfScore });
      }
    }
  }

  // 归一化到 0-1 + compiled_truth boost
  const maxScore = Math.max(...entries.map(e => e.score));
  for (const e of entries) {
    e.score = e.score / maxScore;
    if (applyBoost && e.result.chunk_source === 'compiled_truth') {
      e.score *= COMPILED_TRUTH_BOOST; // 2.0x
    }
  }

  return entries.sort((a, b) => b.score - a.score).map(({ result, score }) => ({ ...result, score }));
}

8.4 结果去重(Deduplication)#

去重是混合搜索管线的关键一环——既要把重复内容去掉,又不能丢失重要信息(如 compiled_truth 保障)。

8.4.1 四层去重策略#

dedup.ts 实现了 4 层过滤:

        flowchart TD
    Input["原始结果"]
    L1["Layer 1<br/>dedupBySource<br/>每页保留 Top3 Chunk"]
    L2["Layer 2<br/>dedupByTextSimilarity<br/>Jaccard > 0.85 → 去除"]
    L3["Layer 3<br/>enforceTypeDiversity<br/>同类型不超过 60%"]
    L4["Layer 4<br/>capPerPage<br/>每页最多 2 个 Chunk"]
    GT["Guarantee<br/>compiled_truth 保障"]
    Output["最终结果"]

    Input --> L1 --> L2 --> L3 --> L4 --> GT --> Output

    style L1 fill:#e8f5e9
    style L4 fill:#fff3e0
    style GT fill:#fce4ec
    

8.4.2 各层详解#

Layer 1 - 按来源分页(dedupBySource

function dedupBySource(results: SearchResult[]): SearchResult[] {
  const byPage = new Map<string, SearchResult[]>();
  for (const r of results) {
    byPage.getOrCreate(pageKey(r), []).push(r);
  }
  return Array.from(byPage.values())
    .flatMap(chunks => chunks.sort((a,b) => b.score - a.score).slice(0, 3));
}

每页(source_id + slug 组合键)最多保留 3 个最高分 Chunk。

Layer 2 - 文本相似度(dedupByTextSimilarity

使用 Jaccard 相似度(词集合交集/并集)作为 cosine similarity 的近似:

function jaccard(a: Set<string>, b: Set<string>): number {
  const intersection = new Set([...a].filter(w => b.has(w))).size;
  const union = new Set([...a, ...b]).size;
  return intersection / union;
}
// Jaccard > 0.85 → 认为是重复

Layer 3 - 类型多样性(enforceTypeDiversity

防止结果被同一页面类型(如 fenced_code)占满。任何单一类型不超过总结果的 60%。

Layer 4 - 每页上限(capPerPage

默认每页最多 2 个 Chunk。两遍检索时放宽到 min(10, walkDepth × 5)

8.4.3 复合页面键#

v0.18.0 引入多源支持后,页面键改为复合键 (source_id, slug)

function pageKey(r: SearchResult): string {
  const source = r.source_id ?? 'default';
  return `${source}:${r.slug}`;
}

避免不同源下相同 slug 的页面被错误合并。

8.4.4 Compiled Truth 保障#

这是去重管线的最后一道保险:

function guaranteeCompiledTruth(results: SearchResult[], preDedup: SearchResult[]): SearchResult[] {
  for (const [key, pageChunks] of byPage) {
    const hasCompiledTruth = pageChunks.some(c => c.chunk_source === 'compiled_truth');
    if (!hasCompiledTruth) {
      // 从 pre-dedup 中找回该页最好的 compiled_truth Chunk
      const candidate = preDedup
        .filter(r => pageKey(r) === key && r.chunk_source === 'compiled_truth')
        .sort((a, b) => b.score - a.score)[0];
      if (candidate) {
        // 替换该页得分最低的 Chunk
        const lowestIdx = output.findIndex(r => pageKey(r) === key && r.score === minScore);
        output[lowestIdx] = candidate;
      }
    }
  }
}

8.5 搜索增强(Search Enrichment)#

8.5.1 Resolver 系统概述#

GBrain 的 Resolver 系统为搜索结果提供「外部数据补充」能力。当搜索命中文档中的外部引用(如 Twitter 句柄、URL),Resolver 自动补全最新信息。

        flowchart LR
    SearchResult["搜索结果<br/>含外部引用"]
    Resolver["ResolverRegistry"]
    URL["URL Reachable<br/>检查链接是否有效"]
    XAPI["X API<br/>查找推文"]
    Brain["Brain Local<br/>脑内slug查找"]
    Enriched["富化后的结果"]

    SearchResult --> Resolver
    Resolver --> URL
    Resolver --> XAPI
    Resolver --> Brain
    URL & XAPI & Brain --> Enriched
    

8.5.2 Resolver 接口设计#

每个 Resolver 实现统一的接口(core/resolvers/interface.ts):

export interface Resolver<I, O> {
  readonly id: string;           // slug-cased, e.g. "x_handle_to_tweet"
  readonly cost: ResolverCost;   // 'free' | 'rate-limited' | 'paid'
  readonly backend: string;       // "x-api-v2", "brain-local", "head-check"

  // 判断 resolver 是否可用(检查 env、API key 等)
  available(ctx: ResolverContext): Promise<boolean>;

  // 核心解析方法
  resolve(req: ResolverRequest<I>): Promise<ResolverResult<O>>;
}

export interface ResolverResult<O> {
  value: O;
  confidence: number;    // 0.0-1.0,1.0=确定性结果
  source: string;       // "x-api-v2"
  fetchedAt: Date;
  costEstimate?: number;
  raw?: unknown;        // 保留原始响应
}

8.5.3 内置 Resolver 示例#

URL Reachablebuiltin/url-reachable.ts

const urlReachable: Resolver<string, boolean> = {
  id: 'url_reachable',
  cost: 'free',
  backend: 'head-check',
  async available() { return true; },
  async resolve({ input }) {
    const response = await fetch(input, { method: 'HEAD', timeout: 5000 });
    return {
      value: response.ok,
      confidence: 1.0,
      source: 'head-check',
      fetchedAt: new Date(),
    };
  },
};

X (Twitter) Handle 查找builtin/x-api/handle-to-tweet.ts

查找特定 X 句柄的最新推文,用于补充人物页面的社交媒体信息。

8.5.4 Resolver 注册#

// core/resolvers/registry.ts
class ResolverRegistry {
  private resolvers = new Map<string, Resolver<any, any>>();

  register<I, O>(resolver: Resolver<I, O>): void {
    this.resolvers.set(resolver.id, resolver);
  }

  async resolve<I, O>(
    id: string,
    input: I,
    ctx: ResolverContext,
  ): Promise<ResolverResult<O>> {
    const resolver = this.resolvers.get(id);
    if (!resolver) throw new ResolverError('not_found', `Unknown resolver: ${id}`);
    if (!(await resolver.available(ctx))) {
      throw new ResolverError('unavailable', `Resolver ${id} is unavailable`);
    }
    return resolver.resolve({ input, context: ctx });
  }
}

8.6 搜索参数调优#

8.6.1 核心参数一览#

参数

默认值

说明

limit

20

返回结果上限

offset

0

分页偏移

detail

auto

'low'编译真相 / 'medium'全部 / 'high'时间线优先

expansion

true

是否启用查询扩展

rrfK

60

RRF 平滑参数,越大排名差异越被压缩

walkDepth

0

两遍检索跳数,0=关闭,1-2=启用

nearSymbol

-

符号名锚点,直接定位到代码定义

8.6.2 Dedup 参数#

参数

默认值

说明

cosineThreshold

0.85

Jaccard 相似度阈值,超过视为重复

maxTypeRatio

0.6

单一类型最大占比

maxPerPage

2

每页最大 Chunk 数

8.6.3 场景化建议#

日常笔记搜索

hybridSearch(engine, query, {
  detail: 'medium',    // 默认
  expansion: true,     // 启用同义扩展
  walkDepth: 0,        // 不需要代码感知
});

代码定位(使用两遍检索)

hybridSearch(engine, query, {
  detail: 'medium',
  walkDepth: 2,                    // 开启两遍检索
  nearSymbol: 'BrainEngine.searchKeyword',  // 锚定符号
  symbolKind: 'function',          // 只看函数
  language: 'typescript',
});

实体查询(who is / what is)

hybridSearch(engine, query, {
  detail: 'low',        // 只要 compiled_truth
  expansion: false,    // 短查询不需要扩展
});

时间线查询(when / history)

hybridSearch(engine, query, {
  detail: 'high',      // 需要 timeline chunk 全文
  rrfK: 60,            // 默认即可
});

8.6.4 两遍检索流程图#

        flowchart TB
    Q["查询: useState hook"]

    subgraph P1["== 第一遍:锚点检索 =="]
        K1["关键词搜索 Top N"]
        V1["向量搜索 Top N"]
        Merge["RRF 融合"]
        Anchor["锚点集: Top 10 Chunks"]
        K1 & V1 --> Merge --> Anchor
    end

    subgraph P2["== 第二遍:图扩展 =="]
        Walk["沿 code_edges 遍历"]
        CE1["code_edges_chunk: to_chunk_id"]
        CE2["code_edges_symbol: 符号反向查找"]
        Neighbor["结构邻居: score × 1/(1+hop)"]
        Merge2["与锚点集合并,按 score 排序"]
        Walk --> CE1 --> Neighbor
        Walk --> CE2 --> Neighbor
        Merge2 --> Neighbor
    end

    subgraph P3["== 去重 =="]
        Dedup["四层去重 + compiled_truth 保障"]
    end

    P1 --> Anchor --> Walk --> P2 --> Dedup --> P3

    style Q fill:#e3f2fd
    style Anchor fill:#e1f5fe
    style Walk fill:#fff3e0
    style Dedup fill:#f3e5f5
    

本章小结#

GBrain 的混合搜索管线精心平衡了多种检索策略:

模块

技术基础

解决的问题

向量搜索

text-embedding-3-large + pgvector

语义相似召回

关键词搜索

pg_trgm trigram + BM25/tsvector

精确关键词匹配

RRF 融合

倒数排名融合(k=60)

多引擎排名合并

余弦重打分

cosine similarity re-blend

语义精细调整

反链增强

log(1 + backlink_count)

权威性信号

来源 Boost

slug 前缀权重

内容质量先验

去重

4层过滤 + compiled_truth保障

结果多样性

两遍检索

代码调用图遍历

代码结构感知

这套管线的设计哲学是互补优于单一:每个搜索引擎都有盲区,通过多引擎融合 + 多阶段增强,最大化各类查询的成功率。