# Pipeline Architecture

How a search query flows through `@coda/search`. For algorithm details behind each phase, see [Concepts](concepts.md). For parameter guidance, see the [Tuning Guide](tuning.md).

---

## Overview Diagram

```
Query
  -> QueryExpander[] (rewrite/expand)
  -> SearchStage[] (rank, concurrent)
  -> StaticSignal[] (cached) + QuerySignal[] (per-query)
  -> ScoreFusion (fuse all signals)
  -> Paginated results
```

---

## Phase 1: Query Expansion

Before ranking, the query passes through zero or more `QueryExpander` instances. Each expander receives the raw query string and the accumulated tokens from prior expanders, and returns additional tokens.

### QueryExpander interface

```ts
interface QueryExpander {
  readonly name: string;
  expand(query: string, currentTokens: string[]): QueryExpansion;
}

interface QueryExpansion {
  tokens: string[];
}
```

- Expanders run **sequentially** -- each sees the tokens produced by prior expanders.
- Expansion tokens are kept separate from raw query tokens in `StageContext.expansionTokens`.
- Built-in: `GlossaryExpander` -- matches query terms against a [domain glossary](concepts.md#glossary-matching) and adds target identifiers as expansion tokens. See [Custom QueryExpander](extending.md#custom-queryexpander) for how to add your own.

---

## Phase 2: Ranking Stages

All `SearchStage` implementations run concurrently via `Promise.allSettled`. Each stage receives the same `StageContext` and returns `RankedEntry[]`.

### SearchStage interface

```ts
interface StageContext {
  query: string; // Original user query
  tokens: string[]; // Raw tokenized query terms
  expansionTokens: string[]; // Tokens from QueryExpanders
  itemCount: number; // Total documents in index
  indexedIds: ReadonlySet<string>;
  limit: number; // Max results requested
}

interface SearchStage {
  readonly name: string;
  rank(context: StageContext): Promise<RankedEntry[]>;
}
```

### Built-in stages

| Stage          | Name               | What it does                                                                         |
| -------------- | ------------------ | ------------------------------------------------------------------------------------ |
| `KeywordStage` | `keyword`          | [BM25](concepts.md#bm25-keyword-scoring) scoring using raw query tokens              |
| `KeywordStage` | `keyword_expanded` | [BM25](concepts.md#dual-level-keyword-weighting) scoring using expansion tokens only |
| `VectorStage`  | `vector`           | Embedding similarity search via [HNSW](concepts.md#hnsw-vector-search)               |

The three built-in stages are always present. Additional stages can be added via `HybridSearchConfig.stages[]` (e.g., [`GlossaryMatchStage`](concepts.md#expansion-vs-ranking)). See [Custom SearchStage](extending.md#custom-searchstage) for implementation details.

### Fault tolerance

Stages run via `Promise.allSettled`. If a stage throws, it degrades gracefully -- the remaining stages still contribute their signals to fusion. This means a failing embedding provider does not prevent keyword results from being returned.

---

## Phase 3: Ranking Signals

Signals provide additional scoring dimensions outside the stage pipeline, typically derived from [graph structure](concepts.md#graph-signals).

### StaticSignal

Computed once from the graph and cached until `invalidateStaticSignals()` is called. Cheap per query.

```ts
interface StaticSignal<T = unknown> {
  readonly name: string;
  compute(
    graph: ReadonlyGraph<T>,
    getId: (data: T) => string,
  ): NamedSignal | null;
}
```

Built-in:

- [`DegreeSignal`](concepts.md#degreesignal-static) -- node importance by in+out degree, normalized to `[0, 1]`
- [`AdamicAdarSignal`](concepts.md#adamicadarsignal-static) -- neighbor specificity (nodes with rare neighbors rank higher)
- [`BetweennessSignal`](concepts.md#betweennesssignal-static) -- bridge detection via betweenness centrality
- [`ColumnDensitySignal`](concepts.md#columndensitysignal-static-no-graph-required) -- field count density (no graph required)

See [Custom StaticSignal](extending.md#custom-staticsignal) for how to add your own.

### QuerySignal

Computed fresh on every search call, using top-ranked candidates as context.

```ts
interface QuerySignal<T = unknown> {
  readonly name: string;
  compute(
    candidateIds: readonly string[],
    query: string,
    graph: ReadonlyGraph<T>,
    getId: (data: T) => string,
  ): NamedSignal | null;
}
```

Built-in: [`ProximitySignal`](concepts.md#proximitysignal-query-time) -- BFS from top candidates, boosts nearby graph neighbors. See [Custom QuerySignal](extending.md#custom-querysignal) for how to add your own.

---

## Phase 4: Score Fusion

All stage results and signal outputs are collected as `NamedSignal[]` and passed to a `ScoreFusion` implementation.

```ts
interface NamedSignal {
  name: string;
  entries: RankedEntry[];
}

interface ScoreFusion {
  fuse(signals: NamedSignal[]): Map<string, number>;
}
```

### Built-in fusion strategies

| Strategy            | Description                                                                                        |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| `RrfFusion`         | [Reciprocal Rank Fusion](concepts.md#rrf-score-fusion) -- rank-based, parameter-free               |
| `WeightedSumFusion` | [Weighted sum](concepts.md#alternative-weightedsumfusion) of normalized scores -- explicit weights |

`RrfFusion` is the default. It requires no weight tuning and works well when signals are heterogeneous (keyword, vector, glossary, graph). The [`rrfK` parameter](tuning.md#rrfk-rank-weighting) controls how aggressively top ranks are weighted.

### Top-K selection

After fusion produces a `Map<string, number>` of scores, the top-K items are selected with support for keyset pagination (pass an `after` cursor with `{ score, id }` to page through results).
