# Extending @coda/search

How to add custom ranking stages, signals, expanders, and scorers to the hybrid search [pipeline](pipeline.md). For algorithm background on the built-in implementations, see [Concepts](concepts.md).

All extension points are interface-based. Implement the interface, pass it via `HybridSearchConfig`, and the pipeline picks it up automatically -- no internal changes required.

---

## Custom SearchStage

A [`SearchStage`](pipeline.md#searchstage-interface) scores documents for a query. All stages run concurrently and their results participate in [score fusion](pipeline.md#phase-4-score-fusion).

Here is a complete example: a `RecencyStage` that boosts recently-modified documents.

```ts
import type { SearchStage, StageContext, RankedEntry } from "@coda/search";

class RecencyStage implements SearchStage {
  readonly name = "recency";
  private readonly timestamps: Map<string, number>;

  constructor(timestamps: Map<string, number>) {
    this.timestamps = timestamps;
  }

  async rank(ctx: StageContext): Promise<RankedEntry[]> {
    const now = Date.now();
    const entries: RankedEntry[] = [];
    for (const id of ctx.indexedIds) {
      const ts = this.timestamps.get(id);
      if (ts) {
        // Score decays linearly over 30 days
        const age = (now - ts) / (30 * 86_400_000);
        entries.push({ id, score: Math.max(0, 1 - age) });
      }
    }
    return entries;
  }
}
```

Plug it into the pipeline:

```ts
const hybrid = HybridSearch.create({
  // ... base config
  stages: [new RecencyStage(timestamps)],
  onError: console.error,
});
```

Custom stages are appended after the [built-in `keyword`, `keyword_expanded`, and `vector` stages](pipeline.md#built-in-stages). All stages run concurrently via `Promise.allSettled`, so a throwing stage [degrades gracefully](pipeline.md#fault-tolerance) without blocking others.

---

## Custom StaticSignal

A [`StaticSignal`](pipeline.md#staticsignal) computes a ranking signal from the [graph structure](concepts.md#graph-signals). It runs once and is cached until `invalidateStaticSignals()` is called.

Example: a `PopularitySignal` that boosts documents by external popularity score.

```ts
import type { ReadonlyGraph } from "@coda/data-structures";
import type { StaticSignal, NamedSignal } from "@coda/search";

class PopularitySignal<T> implements StaticSignal<T> {
  readonly name = "popularity";
  private readonly scores: Map<string, number>;

  constructor(scores: Map<string, number>) {
    this.scores = scores;
  }

  compute(
    graph: ReadonlyGraph<T>,
    getId: (data: T) => string,
  ): NamedSignal | null {
    const entries: { id: string; score: number }[] = [];
    for (const node of graph.nodes()) {
      const id = getId(node);
      const score = this.scores.get(id) ?? 0;
      if (score > 0) entries.push({ id, score });
    }
    return entries.length > 0 ? { name: this.name, entries } : null;
  }
}
```

Register it:

```ts
const hybrid = HybridSearch.create({
  // ... base config
  staticSignals: [new PopularitySignal(popularityScores)],
  getGraph: () => myGraph,
  getSignalId: (data) => data.id,
  onError: console.error,
});
```

---

## Custom QuerySignal

A [`QuerySignal`](pipeline.md#querysignal) is computed fresh on every search call, using the top-ranked candidates as context. This is useful for signals that depend on the query or the current result set.

Example: a `UserAffinitySignal` that boosts items the current user has accessed before.

```ts
import type { ReadonlyGraph } from "@coda/data-structures";
import type { QuerySignal, NamedSignal } from "@coda/search";

class UserAffinitySignal<T> implements QuerySignal<T> {
  readonly name = "user_affinity";
  private readonly accessHistory: ReadonlySet<string>;

  constructor(accessHistory: ReadonlySet<string>) {
    this.accessHistory = accessHistory;
  }

  compute(
    candidateIds: readonly string[],
    _query: string,
    _graph: ReadonlyGraph<T>,
    _getId: (data: T) => string,
  ): NamedSignal | null {
    const entries: { id: string; score: number }[] = [];
    for (const id of candidateIds) {
      if (this.accessHistory.has(id)) {
        entries.push({ id, score: 1.0 });
      }
    }
    return entries.length > 0 ? { name: this.name, entries } : null;
  }
}
```

Register it:

```ts
const hybrid = HybridSearch.create({
  // ... base config
  querySignals: [new UserAffinitySignal(userHistory)],
  getGraph: () => myGraph,
  getSignalId: (data) => data.id,
  onError: console.error,
});
```

---

## Custom QueryExpander

A [`QueryExpander`](pipeline.md#queryexpander-interface) rewrites or augments the query before [ranking stages](pipeline.md#phase-2-ranking-stages) run. Expanders run sequentially; each receives the tokens accumulated by prior expanders.

Example: a `SynonymExpander` that adds synonyms from a static map.

```ts
import type { QueryExpander, QueryExpansion } from "@coda/search";

class SynonymExpander implements QueryExpander {
  readonly name = "synonyms";
  private readonly synonyms: Map<string, string[]>;

  constructor(synonyms: Map<string, string[]>) {
    this.synonyms = synonyms;
  }

  expand(query: string, currentTokens: string[]): QueryExpansion {
    const expanded: string[] = [];
    for (const token of currentTokens) {
      const syns = this.synonyms.get(token);
      if (syns) expanded.push(...syns);
    }
    return { tokens: expanded };
  }
}
```

Register it:

```ts
const hybrid = HybridSearch.create({
  // ... base config
  expanders: [new SynonymExpander(synonymMap)],
  onError: console.error,
});
```

Expansion tokens end up in `StageContext.expansionTokens` and are used by the [`keyword_expanded` stage](concepts.md#dual-level-keyword-weighting) separately from raw query tokens.

---

## Custom KeywordScorer

The `KeywordScorer` interface allows replacing [BM25](concepts.md#bm25-keyword-scoring) with a custom scoring strategy. Implementations receive query tokens, an `InvertedIndex`, and the total document count, and return scored results.

```ts
import type {
  KeywordScorer,
  KeywordScorerResult,
  InvertedIndex,
} from "@coda/search";

class MyScorer implements KeywordScorer {
  score(
    tokens: Iterable<string>,
    index: InvertedIndex,
    totalDocs: number,
  ): KeywordScorerResult[] {
    // Custom scoring logic
  }
}
```

Pass it via `HybridSearchConfig.keywordScorer`. The built-in `Bm25Scorer` and `TfidfScorer` both implement this interface.

---

## Custom EmbeddingProvider

The `EmbeddingProvider` interface allows any embedding backend -- OpenAI, local ONNX, sentence-transformers, or a custom model server. Embeddings power the [vector search](concepts.md#hnsw-vector-search) stage in the [pipeline](pipeline.md#built-in-stages).

```ts
import type { EmbeddingProvider } from "@coda/search";

class OpenAIEmbeddingProvider implements EmbeddingProvider {
  readonly dimensions = 1536;

  async embed(texts: Iterable<string>): Promise<Float32Array[]> {
    // Call OpenAI embeddings API for document indexing
  }

  async embedQuery(texts: Iterable<string>): Promise<Float32Array[]> {
    // Call OpenAI embeddings API for query embedding
    // May use a different model or prefix for asymmetric retrieval
  }
}
```

Pass it via `HybridSearchConfig.embeddingProvider`. Set to `null` to run in keyword-only (degraded) mode.
