openclawmemoryhybrid-searchbm25mmralgorithm

ๆทฑๅ…ฅๅˆ†ๆž Hybrid Search (ๅ‘้‡ + BM25) ็š„ๅฎž็ŽฐๅŽŸ็†ๅ’Œไผ˜ๅŒ–็ญ–็•ฅ

้—ฎ้ข˜่ƒŒๆ™ฏ

็บฏๅ‘้‡ๆœ็ดข็š„ๅฑ€้™

flowchart LR
    Q["Query: API authentication"] --> V[ๅ‘้‡ๆจกๅž‹]
    V -->|ๅตŒๅ…ฅ| E[Query Vector]
    E -->|ไฝ™ๅผฆ็›ธไผผๅบฆ| D1["Doc: API design doc"]
    E -->|ไฝ™ๅผฆ็›ธไผผๅบฆ| D2["Doc: OAuth guide"]
    E -->|ไฝ™ๅผฆ็›ธไผผๅบฆ| D3["Doc: ็”จๆˆทๆ‰‹ๅ†Œ"]
    
    D1 -->|0.85| R1[ๅŒน้…]
    D2 -->|0.82| R2[ๅŒน้…]
    D3 -->|0.45| R3[ไธๅŒน้…]

้—ฎ้ข˜:

  • ๆ— ๆณ•็ฒพ็กฎๅŒน้…ๅ…ณ้”ฎ่ฏ๏ผˆๅฆ‚ โ€œOAuthโ€ vs โ€œauthenticationโ€๏ผ‰
  • ๅฏ่ƒฝ้—ๆผๅŒ…ๅซ็ฒพ็กฎๆœฏ่ฏญไฝ†่ฏญไน‰็•ฅๆœ‰ๅทฎๅผ‚็š„ๆ–‡ๆกฃ

็บฏ BM25 ็š„ๅฑ€้™

flowchart LR
    Q["Query: how to implement authentication"] --> B[BM25]
    B -->|ๅŒน้…| D1["Doc: implementation guide"]
    B -->|ๅŒน้…| D2["Doc: authentication API"]
    B -->|ไธๅŒน้…| D3["Doc: auth ๆœ€ไฝณๅฎž่ทต"]
    
    D1 -->|0.9| R1[้ซ˜ๆŽ’ๅ]
    D2 -->|0.8| R2[้ซ˜ๆŽ’ๅ]
    D3 -->|0.2| R3[ไฝŽๆŽ’ๅ]

้—ฎ้ข˜:

  • โ€œauthenticationโ€ ๅ’Œ โ€œauthโ€ ่ขซ่ง†ไธบไธๅŒ่ฏ
  • ๆ— ๆณ•็†่งฃ่ฏญไน‰็›ธไผผๆ€ง

่งฃๅ†ณๆ–นๆกˆ๏ผšๆททๅˆๆœ็ดข

ๆžถๆž„่ฎพ่ฎก

flowchart TB
    Q[Query] --> E[Embed Query]
    
    subgraph "ๅŒ่ทฏๅฌๅ›ž"
        E --> V[Vector Search]
        Q --> K[Keyword Search]
    end
    
    subgraph "็ป“ๆžœ่žๅˆ"
        V -->|scores| M[Merge]
        K -->|scores| M
        M --> F[Fusion]
    end
    
    subgraph "ๅŽๅค„็†"
        F --> T[Temporal Decay]
        T --> R[MMR Rerank]
    end
    
    R --> Result

ๆ ธๅฟƒๅฎž็Žฐ

ๆ–‡ไปถ: src/memory/hybrid.ts

export function mergeHybridResults(
  vectorResults: VectorResult[],
  keywordResults: KeywordResult[],
  vectorWeight: number = 0.7,    // ๅฏ้…็ฝฎ
  textWeight: number = 0.3       // ๅฏ้…็ฝฎ
): HybridResult[] {
  const merged = new Map<string, HybridResult>();
  
  // 1. ๅฝ’ไธ€ๅŒ–ๅ‘้‡ๅˆ†ๆ•ฐๅˆฐ 0-1
  const maxVectorScore = Math.max(...vectorResults.map(r => r.score));
  
  for (const result of vectorResults) {
    merged.set(result.id, {
      ...result,
      vectorScore: result.score / maxVectorScore,
      textScore: 0,
      finalScore: (result.score / maxVectorScore) * vectorWeight
    });
  }
  
  // 2. ๅฝ’ไธ€ๅŒ– BM25 ๅˆ†ๆ•ฐ
  for (const result of keywordResults) {
    // BM25 rank ่ถŠไฝŽ่ถŠๅฅฝ๏ผŒ่ฝฌๆขไธบ 0-1 ๅˆ†ๆ•ฐ
    // rank 0 = 1.0, rank 10 = 0.09
    const textScore = 1 / (1 + Math.max(0, result.rank));
    
    if (merged.has(result.id)) {
      // ๅทฒๅœจๅ‘้‡็ป“ๆžœไธญ๏ผŒ็ดฏๅŠ ๅˆ†ๆ•ฐ
      const existing = merged.get(result.id)!;
      existing.textScore = textScore;
      existing.finalScore += textScore * textWeight;
    } else {
      // ไป…ๅ…ณ้”ฎ่ฏๅŒน้…
      merged.set(result.id, {
        ...result,
        vectorScore: 0,
        textScore,
        finalScore: textScore * textWeight
      });
    }
  }
  
  // 3. ๆŒ‰ๆœ€็ปˆๅˆ†ๆ•ฐๆŽ’ๅบ
  return Array.from(merged.values())
    .sort((a, b) => b.finalScore - a.finalScore);
}

่žๅˆๅ…ฌๅผ:

finalScore = vectorWeight * normalizedVectorScore + 
             textWeight * normalizedBM25Score

้ป˜่ฎค: 0.7 * vector + 0.3 * text

ๅ€™้€‰ๆฑ ๆ‰ฉๅฑ•็ญ–็•ฅ

// manager-search.ts
async search(query: string, options: SearchOptions) {
  const candidateMultiplier = 4;  // ๅฏ้…็ฝฎ
  
  // ๅฌๅ›ž 4 ๅ€็ป“ๆžœ
  const vectorResults = await this.searchVector(
    queryVector, 
    options.maxResults * candidateMultiplier  // 6 * 4 = 24
  );
  
  const keywordResults = await this.searchKeyword(query);
  // ไนŸ็ป™ๅŒๆ ทๆ•ฐ้‡็š„ๅ€™้€‰
}

ไธบไป€ไนˆ้œ€่ฆ 4x ๆ‰ฉๅฑ•๏ผŸ

  • ็ป™่žๅˆ็ฎ—ๆณ•ๆ›ดๅคš้€‰ๆ‹ฉ็ฉบ้—ด
  • ่ฎฉ MMR ๅคšๆ ทๆ€ง้‡ๆŽ’ๆœ‰่ถณๅคŸๅ€™้€‰
  • ๅนณ่กกๆ€ง่ƒฝๅ’Œๅฌๅ›ž็އ

MMR ๅคšๆ ทๆ€ง้‡ๆŽ’

้—ฎ้ข˜๏ผš็ป“ๆžœๅŒ่ดจๅŒ–

Query: "router configuration"

Top results:
1. "Configured Omada router..." (score: 0.95)
2. "Configured Omada router..." (score: 0.93)  โ† ้‡ๅค๏ผ
3. "Configured Omada router..." (score: 0.91)  โ† ้‡ๅค๏ผ

MMR ็ฎ—ๆณ•

ๆ–‡ไปถ: src/memory/mmr.ts

export function applyMMR(
  results: SearchResult[],
  queryVector: number[],
  lambda: number = 0.7,      // ็›ธๅ…ณๆ€งๆƒ้‡
  maxResults: number
): SearchResult[] {
  const selected: SearchResult[] = [];
  const candidates = [...results];
  
  while (selected.length < maxResults && candidates.length > 0) {
    let bestMMR = -Infinity;
    let bestIndex = -1;
    
    for (let i = 0; i < candidates.length; i++) {
      const candidate = candidates[i];
      
      // 1. ่ฎก็ฎ—ไธŽๆŸฅ่ฏข็š„็›ธๅ…ณๆ€ง
      const relevance = cosineSimilarity(queryVector, candidate.embedding);
      
      // 2. ่ฎก็ฎ—ไธŽๅทฒ้€‰็ป“ๆžœ็š„ๆœ€ๅคง็›ธไผผๅบฆ
      let maxSimToSelected = 0;
      for (const sel of selected) {
        const sim = jaccardSimilarity(candidate.text, sel.text);
        maxSimToSelected = Math.max(maxSimToSelected, sim);
      }
      
      // 3. MMR ๅˆ†ๆ•ฐ = ฮป * ็›ธๅ…ณๆ€ง - (1-ฮป) * ็›ธไผผๅบฆ
      const mmrScore = lambda * relevance - (1 - lambda) * maxSimToSelected;
      
      if (mmrScore > bestMMR) {
        bestMMR = mmrScore;
        bestIndex = i;
      }
    }
    
    selected.push(candidates.splice(bestIndex, 1)[0]);
  }
  
  return selected;
}

็ฎ—ๆณ•ๅŽŸ็†:

MMR = ฮป * relevance(query, doc) - (1-ฮป) * max(similarity(doc, selected))

ฮป = 0.7 (้ป˜่ฎค):
- ๆ›ด้‡่ง†็›ธๅ…ณๆ€ง
- ่ฝปๅพฎๆƒฉ็ฝš้‡ๅคๅ†…ๅฎน

ฮป = 0.5:
- ๅนณ่กก็›ธๅ…ณๆ€งๅ’Œๅคšๆ ทๆ€ง

ฮป = 0.3:
- ๆ›ด้‡่ง†ๅคšๆ ทๆ€ง
- ้€‚ๅˆ exploratory search

ๆ–‡ๆœฌ็›ธไผผๅบฆ่ฎก็ฎ—:

function jaccardSimilarity(text1: string, text2: string): number {
  const set1 = new Set(tokenize(text1));
  const set2 = new Set(tokenize(text2));
  
  const intersection = new Set([...set1].filter(x => set2.has(x)));
  const union = new Set([...set1, ...set2]);
  
  return intersection.size / union.size;
}

ไฝฟ็”จ Jaccard ่€Œ้žๅ‘้‡็›ธไผผๅบฆ๏ผŒๆ›ด็›ด่ง‚ๅๆ˜ ๆ–‡ๆœฌ้‡ๅค็จ‹ๅบฆใ€‚

MMR ๆ•ˆๆžœ็คบไพ‹

Query: "router configuration"

Before MMR:
1. "Configured Omada router..." (0.95)
2. "Configured Omada router..." (0.93)
3. "Configured Omada router..." (0.91)
4. "Set up AdGuard DNS..." (0.85)
5. "Router VLAN config..." (0.82)

After MMR (ฮป=0.7):
1. "Configured Omada router..." (0.95)        โ† ้€‰
2. "Set up AdGuard DNS..." (0.85 ร— 1.0 = 0.85) โ† diverse, ้€‰
3. "Router VLAN config..." (0.82 ร— 1.0 = 0.82) โ† diverse, ้€‰
4. "Configured Omada router..." (0.93 - 0.9 = 0.03) โ† ็›ธไผผๅบฆ้ซ˜๏ผŒๅผƒ
5. "Configured Omada router..." (0.91 - 0.9 = 0.01) โ† ็›ธไผผๅบฆ้ซ˜๏ผŒๅผƒ

ๆ—ถ้—ด่กฐๅ‡

้—ฎ้ข˜๏ผšๆ—งๆ–‡ๆกฃๆŽ’ๅ่ฟ‡้ซ˜

Query: "Rod standup time"

Without decay:
1. memory/2025-09-15.md - "Rod works Mon-Fri..." (score: 0.91)
2. memory/2026-02-10.md - "Rod has standup at 14:15..." (score: 0.82)

้—ฎ้ข˜: 2025-09 ็š„ๆ–‡ๆกฃๅทฒ็ป่ฟ‡ๆ—ถ๏ผŒไฝ†่ฏญไน‰ๅŒน้…ๅบฆๆ›ด้ซ˜๏ผ

ๆŒ‡ๆ•ฐ่กฐๅ‡ๅ…ฌๅผ

ๆ–‡ไปถ: src/memory/temporal-decay.ts

export function applyTemporalDecay(
  results: SearchResult[],
  halfLifeDays: number = 30
): void {
  const now = Date.now();
  const lambda = Math.log(2) / halfLifeDays;  // ่กฐๅ‡็ณปๆ•ฐ
  
  for (const result of results) {
    // ไปŽๆ–‡ไปถๅๆๅ–ๆ—ฅๆœŸ
    const fileDate = extractDateFromPath(result.path);
    if (!fileDate) continue;  // ้žๆ—ฅๆœŸๆ–‡ไปถไธ่กฐๅ‡
    
    const ageInDays = (now - fileDate.getTime()) / (1000 * 60 * 60 * 24);
    const decayFactor = Math.exp(-lambda * ageInDays);
    
    result.score *= decayFactor;
    result.temporalDecayApplied = true;
  }
}

่กฐๅ‡ๆ›ฒ็บฟ (ๅŠ่กฐๆœŸ 30 ๅคฉ):

Day 0:  score ร— 1.00 = 100%
Day 7:  score ร— 0.86 = 86%
Day 30: score ร— 0.50 = 50%
Day 90: score ร— 0.125 = 12.5%

็‰นๆฎŠๅค„็†: MEMORY.md ๅ’Œ memory/projects.md ็ญ‰้žๆ—ฅๆœŸๆ–‡ไปถไธ่กฐๅ‡๏ผŒ่ง†ไธบๅธธ้’ๆ–‡ๆกฃใ€‚

ๆ•ˆๆžœๅฏนๆฏ”

Query: "Rod standup time"

With decay (halfLife=30):
1. memory/2026-02-10.md - 0.82 ร— 1.00 = 0.82  โ† ไปŠๅคฉ๏ผŒๆœ€ๆ–ฐ
2. memory/2026-02-03.md - 0.80 ร— 0.85 = 0.68  โ† 7ๅคฉๅ‰
3. memory/2025-09-15.md - 0.91 ร— 0.03 = 0.03  โ† 5ไธชๆœˆๅ‰๏ผŒๅ‡ ไนŽๅฟฝ็•ฅ

ๅฎŒๆ•ดๆœ็ดขๆต็จ‹

flowchart TB
    Q[็”จๆˆทๆŸฅ่ฏข] --> E[ๅตŒๅ…ฅๅ‘้‡ๅŒ–]
    
    subgraph "ๅŒ่ทฏๅฌๅ›ž"
        E -->|Top 24| V[ๅ‘้‡ๆœ็ดข]
        Q -->|Top 24| K[BM25 ๆœ็ดข]
    end
    
    subgraph "่žๅˆ (Fusion)"
        V -->|normalized| F[ๅŠ ๆƒๆฑ‚ๅ’Œ]
        K -->|normalized| F
        F --> M[ๅˆๅนถๅŽป้‡]
    end
    
    subgraph "ๅŽๅค„็†"
        M --> T["ๆ—ถ้—ด่กฐๅ‡: score * decayFactor"]
        T --> R["MMR ้‡ๆŽ’: lambda * rel - (1-lambda) * sim"]
    end
    
    R -->|Top 6| Result[ๆœ€็ปˆ็ป“ๆžœ]

ๆ€ง่ƒฝไผ˜ๅŒ–

1. ๅนถ่กŒๆŸฅ่ฏข

// ๅ‘้‡ๆœ็ดขๅ’Œๅ…ณ้”ฎ่ฏๆœ็ดขๅนถ่กŒๆ‰ง่กŒ
const [vectorResults, keywordResults] = await Promise.all([
  this.searchVector(queryVector, topK * 4),
  this.searchKeyword(query)
]);

2. ๆ•ฐๆฎๅบ“็ดขๅผ•

-- ๅ‘้‡ๆŸฅ่ฏขไผ˜ๅŒ–
CREATE INDEX idx_chunks_path ON chunks(path);
CREATE INDEX idx_chunks_source ON chunks(source);
 
-- FTS5 ่‡ชๅŠจไผ˜ๅŒ–
-- ๆ— ้œ€้ขๅค–็ดขๅผ•

3. ๆ—ฉๅœ็ญ–็•ฅ

// MMR ๆๅ‰็ปˆๆญขๆกไปถ
if (selected.length >= maxResults) break;
if (bestMMR < threshold) break;  // ๅˆ†ๆ•ฐ่ฟ‡ไฝŽไธๅ†้€‰ๆ‹ฉ

ๅ‚ๆ•ฐ่ฐƒไผ˜ๅปบ่ฎฎ

ๅ‚ๆ•ฐ้ป˜่ฎคๅ€ผ่ฐƒๆ•ดๅปบ่ฎฎ
vectorWeight0.7่ฏญไน‰ๆœ็ดขไธบไธปไฟๆŒ 0.7๏ผŒๅ…ณ้”ฎ่ฏไธบไธป้™ๅˆฐ 0.5
textWeight0.3ไธŽ vectorWeight ไบ’่กฅ๏ผŒๅ’Œไธบ 1
candidateMultiplier4่ฟฝๆฑ‚้€Ÿๅบฆ้™ๅˆฐ 2๏ผŒ่ฟฝๆฑ‚่ดจ้‡ๅ‡ๅˆฐ 8
mmr.lambda0.7ๅคšๆ ทๆ€ง่ฆๆฑ‚้ซ˜้™ๅˆฐ 0.5
temporalDecay.halfLifeDays30ๅฟซ้€Ÿๅ˜ๅŒ–ไธป้ข˜้™ๅˆฐ 7๏ผŒ็จณๅฎš็Ÿฅ่ฏ†ๅ‡ๅˆฐ 90

่ฎพ่ฎกๆ€ๆƒณๆ€ป็ป“

  1. ไบ’่กฅๆ€ง: ๅ‘้‡ + ๅ…ณ้”ฎ่ฏไบ’่กฅๅ„่‡ช็š„็›ฒๅŒบ
  2. ๅ€™้€‰ๆฑ : ๅ…ˆๅนฟๆณ›ๅฌๅ›ž๏ผŒๅ†็ฒพๆŽ’ๆˆชๆ–ญ
  3. ๅคšๆ ทๆ€ง: MMR ้ฟๅ…็ป“ๆžœๅŒ่ดจๅŒ–
  4. ๆ—ถๆ•ˆๆ€ง: ๆ—ถ้—ด่กฐๅ‡่ฎฉๆ–ฐๅ†…ๅฎนไผ˜ๅ…ˆ
  5. ๅฏ้…็ฝฎ: ๆ‰€ๆœ‰ๅ‚ๆ•ฐๅฏ่ฐƒ๏ผŒ้€‚ๅบ”ไธๅŒๅœบๆ™ฏ

็›ธๅ…ณๆ–‡ๆกฃ: Memory ๆบ็ ๅˆ†ๆž, Memory ่ฎพ่ฎกๆ€ๆƒณ