# Hybrid Search

URL: https://cogitator.app/docs/memory/hybrid-search

> Combine BM25 keyword search with vector similarity using Reciprocal Rank Fusion for accurate, recall-maximizing retrieval.

## Why Hybrid Search

Vector search is great at finding semantically similar content, but it can miss results that match on exact terms. Keyword search finds exact matches but misses paraphrases. Hybrid search combines both to maximize recall and precision.

Cogitator's hybrid search pipeline:

```
Query ──┬── Vector Search (cosine similarity) ──┐
        │                                        ├── RRF Fusion ── Ranked Results
        └── Keyword Search (BM25) ──────────────┘
```

## HybridSearch Class

The `HybridSearch` class orchestrates vector and keyword search, then fuses results using Reciprocal Rank Fusion (RRF).

```typescript
import {
  HybridSearch,
  PostgresAdapter,
  OpenAIEmbeddingService,
  unwrap,
} from '@cogitator-ai/memory';

const embeddings = new OpenAIEmbeddingService({
  apiKey: process.env.OPENAI_API_KEY!,
});

const db = new PostgresAdapter({
  provider: 'postgres',
  connectionString: process.env.DATABASE_URL!,
});
db.setVectorDimensions(embeddings.dimensions);
unwrap(await db.connect());

const search = new HybridSearch({
  embeddingAdapter: db,
  embeddingService: embeddings,
  keywordAdapter: db,
  defaultWeights: { bm25: 0.4, vector: 0.6 },
});

const results = unwrap(
  await search.search({
    query: 'How do agents use tools?',
    strategy: 'hybrid',
    limit: 10,
    filter: { sourceType: 'document' },
  })
);

for (const result of results) {
  console.log(`[${result.score.toFixed(3)}] ${result.content}`);
  console.log(
    `  vector: ${result.vectorScore?.toFixed(3)}, keyword: ${result.keywordScore?.toFixed(3)}`
  );
}
```

`search()` returns a `MemoryResult<SearchResult[]>`. Each result has `id`, `sourceId`, `sourceType`, `content`, `score`, `metadata` and, depending on which searches found it, `vectorScore` / `keywordScore`.

### Search Strategies

You can switch between strategies per query:

```typescript
// vector only
const vectorResults = await search.search({
  query: 'agent memory architecture',
  strategy: 'vector',
  limit: 5,
});

// keyword only
const keywordResults = await search.search({
  query: 'agent memory architecture',
  strategy: 'keyword',
  limit: 5,
});

// hybrid (combines both)
const hybridResults = await search.search({
  query: 'agent memory architecture',
  strategy: 'hybrid',
  limit: 5,
  weights: { bm25: 0.5, vector: 0.5 },
});
```

### Constructor Options

| Option             | Type                   | Default                      | Description                           |
| ------------------ | ---------------------- | ---------------------------- | ------------------------------------- |
| `embeddingAdapter` | `EmbeddingAdapter`     | —                            | Backend for vector search             |
| `embeddingService` | `EmbeddingService`     | —                            | Converts query text to vectors        |
| `keywordAdapter`   | `KeywordSearchAdapter` | —                            | Backend for keyword search (optional) |
| `defaultWeights`   | `HybridSearchWeights`  | `{ bm25: 0.4, vector: 0.6 }` | Default fusion weights                |

| Search option | Description                                                                                        |
| ------------- | -------------------------------------------------------------------------------------------------- |
| `query`       | Text to search for; embedded with `embeddingService` for vector search                             |
| `strategy`    | `'vector'`, `'keyword'` or `'hybrid'`                                                              |
| `limit`       | Max results (default 10)                                                                           |
| `weights`     | Overrides `defaultWeights` for this query                                                          |
| `threshold`   | Minimum vector similarity, passed to the embedding adapter (Postgres and in-memory default to 0.7) |
| `filter`      | `sourceType`, `agentId`, `threadId`, `userId` — passed to both adapters                            |

When no `keywordAdapter` is provided, `HybridSearch` falls back to a built-in local BM25 index that you populate yourself:

```typescript
const added = unwrap(
  await db.addEmbedding({
    sourceId: 'doc-1',
    sourceType: 'document',
    vector: await embeddings.embed('Agent memory architecture overview'),
    content: 'Agent memory architecture overview',
  })
);

search.indexDocument(added.id, added.content);
console.log(search.indexSize); // 1

search.removeDocument(added.id);
search.clearIndex();
```

Fusion matches results by `id`, so index each document under the id of its stored embedding — otherwise a document found by both searches is counted as two results. The local index ignores `filter` (a warning is logged) and reports every hit as `sourceType: 'document'` with `sourceId` equal to its id. `PostgresAdapter` and `InMemoryEmbeddingAdapter` implement `KeywordSearchAdapter`, so passing one as `keywordAdapter` avoids all of this.

## BM25 Index

The `BM25Index` class implements the Okapi BM25 ranking algorithm for full-text keyword search. It tokenizes documents, builds an inverted index, and scores queries using term frequency and inverse document frequency.

```typescript
import { BM25Index } from '@cogitator-ai/memory';

const index = new BM25Index({ k1: 1.5, b: 0.75 });

index.addDocument({ id: 'doc-1', content: 'Cogitator is an AI agent runtime' });
index.addDocument({ id: 'doc-2', content: 'Agents can use tools for external actions' });
index.addDocument({ id: 'doc-3', content: 'Memory adapters persist conversation history' });

const results = index.search('agent tools', 10);
for (const r of results) {
  console.log(`${r.id}: ${r.score.toFixed(3)} — ${r.content}`);
}
```

### BM25 Parameters

| Parameter   | Type              | Default | Description                                                                 |
| ----------- | ----------------- | ------- | --------------------------------------------------------------------------- |
| `k1`        | number            | 1.5     | Term frequency saturation. Higher = more weight to repeated terms           |
| `b`         | number            | 0.75    | Document length normalization. 0 = no normalization, 1 = full normalization |
| `tokenizer` | `TokenizerConfig` | —       | [Tokenizer](#tokenizer) options used for documents and queries              |

`search(query, limit = 10)` returns `{ id, content, score }[]`, best first. Terms are matched exactly after tokenization (no stemming), so `agent` does not match `agents`.

### Document Management

```typescript
index.addDocuments([
  { id: 'a', content: 'First doc' },
  { id: 'b', content: 'Second doc' },
]);

index.removeDocument('a');
index.getDocument('b'); // { id: 'b', content: 'Second doc' }
index.clear(); // removes all documents
console.log(index.size); // 0
```

## Tokenizer

The BM25 index uses a configurable tokenizer that normalizes text before indexing and searching.

```typescript
import { tokenize, getTermFrequency } from '@cogitator-ai/memory';

const tokens = tokenize('The quick brown fox jumps over the lazy dog', {
  lowercase: true,
  removeStopwords: true,
  minLength: 2,
});
// ['quick', 'brown', 'fox', 'jumps', 'over', 'lazy', 'dog']

const freq = getTermFrequency(tokens);
// Map { 'quick' => 1, 'brown' => 1, 'fox' => 1, ... }
```

| Option            | Type    | Default | Description                      |
| ----------------- | ------- | ------- | -------------------------------- |
| `lowercase`       | boolean | true    | Convert text to lowercase        |
| `removeStopwords` | boolean | true    | Remove common English stop words |
| `minLength`       | number  | 2       | Minimum token length to keep     |

## Reciprocal Rank Fusion

RRF merges ranked lists from different search methods into a single ranking. Each result's score is based on its rank position rather than raw score, which normalizes across different scoring scales.

The formula for each result:

```
RRF_score = weight * (1 / (k + rank + 1))
```

Where `rank` is the 0-based position in each list and `k` is a constant (default: 60) that dampens the contribution of lower-ranked results. A result's fused score is the sum over the lists it appears in.

```typescript
import { reciprocalRankFusion, fuseSearchResults, type SearchResult } from '@cogitator-ai/memory';

const doc = (id: string, score: number): SearchResult => ({
  id,
  sourceId: id,
  sourceType: 'document',
  content: `Document ${id}`,
  score,
});

const vectorResults = [doc('a', 0.95), doc('b', 0.82)];
const keywordResults = [doc('b', 4.2), doc('c', 3.1)];

const fused = fuseSearchResults(vectorResults, keywordResults, { bm25: 0.4, vector: 0.6 });
// 'b' ranks high in both lists, so it gets the top combined score
```

The `fuseSearchResults` function preserves both individual scores on each result:

```typescript
for (const result of fused) {
  console.log(result.id);
  console.log(`  combined: ${result.score}`);
  console.log(`  vector:   ${result.vectorScore}`);
  console.log(`  keyword:  ${result.keywordScore}`);
}
```

### Low-Level RRF

For custom fusion scenarios, use `reciprocalRankFusion` directly. It takes any number of ranked lists (position in the array is the rank; only `id` is read), one weight per list (default 1), and returns a map of fused scores. Duplicate ids within one list count once:

```typescript
const scores = reciprocalRankFusion(
  [
    [{ id: 'a' }, { id: 'b' }],
    [{ id: 'b' }, { id: 'c' }],
  ],
  [0.6, 0.4],
  { k: 60 }
);
// Map { 'a' => 0.00984, 'b' => 0.01623, 'c' => 0.00645 }
```

## Tuning Weights

The balance between BM25 and vector search depends on your data:

| Scenario                          | Recommended Weights          |
| --------------------------------- | ---------------------------- |
| General knowledge retrieval       | `{ bm25: 0.4, vector: 0.6 }` |
| Technical docs with exact terms   | `{ bm25: 0.6, vector: 0.4 }` |
| Conversational / paraphrase-heavy | `{ bm25: 0.2, vector: 0.8 }` |
| Code search                       | `{ bm25: 0.7, vector: 0.3 }` |

The `HybridSearch` class oversamples by 3x (fetches `limit * 3` from each backend) before fusion, ensuring good candidate coverage for the final ranking.
