Skip to content

Commit 48b3acf

Browse files
authored
feat(rag): add MMR (Maximal Marginal Relevance) diversity strategy (#163)
* feat(rag): add MMR (Maximal Marginal Relevance) diversity strategy - new MmrStrategy (post-retrieval), greedily selects chunks balancing relevance (context.scores) against similarity to already-selected chunks, so near-duplicates (e.g. overlapping parent/child chunks) don't crowd out distinct context - lambda (relevance/diversity balance, default 0.5) and topK configurable - extract cosineSimilarity as a shared utils/cosine-similarity.ts, used by both MmrStrategy and HybridVectorStore (previously inline) Closes #133 * fix(rag): address review feedback on PR #163 - MmrStrategy: validate topK (non-negative integer) and lambda (finite, in [0, 1]) at construction instead of accepting arbitrary values - MmrStrategy: initialize maxSimToSelected to -Infinity instead of 0, so a genuinely negative cosine similarity (anti-correlated embeddings) is not clamped away, matching the documented MMR formula - cosineSimilarity: add @PARAM a/@PARAM b JSDoc - docs: clarify that chunks without an embedding still participate in MMR selection via their relevance score, they only skip the diversity comparison; only an entirely embedding-free set passes through unranked Not changed: Njent's suggestion to prune context.scores to only the selected chunk IDs. RerankerStrategy already returns the full scores map after slicing chunks to topK; changing MmrStrategy alone would be an inconsistent, one-off deviation from that existing convention.
1 parent 76abfe5 commit 48b3acf

9 files changed

Lines changed: 272 additions & 14 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@nestjs-agentic/rag": minor
3+
---
4+
5+
Add `MmrStrategy`, a post-retrieval Maximal Marginal Relevance diversity strategy that reduces near-duplicate chunks in retrieved context. Extract `cosineSimilarity` as a shared utility, used by both `MmrStrategy` and `HybridVectorStore`. Closes #133.

apps/landing/content/docs/rag/retrieval-strategies.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ Blends a document-level global embedding into each chunk vector to preserve full
1616
### 3. `ParentChildHydrationStrategy`
1717
Works with chunks produced by `ParentChildSplitter`: indexes small sub-chunks (methods, paragraphs) for high vector search precision, but hydrates the full parent text (carried in `chunk.metadata.parentText`) when constructing the final context prompt.
1818

19+
### 4. `MmrStrategy`
20+
A post-retrieval diversity strategy implementing Maximal Marginal Relevance: greedily selects chunks that are relevant to the query but dissimilar to chunks already selected, so near-duplicate results (e.g. overlapping parent/child chunks) don't crowd out distinct context.
21+
22+
```typescript
23+
import { MmrStrategy } from '@nestjs-agentic/rag';
24+
25+
const mmr = new MmrStrategy({
26+
topK: 5,
27+
lambda: 0.5, // 1 = pure relevance, 0 = pure diversity
28+
});
29+
```
30+
31+
Relevance comes from `context.scores` (populated by `RAGPipeline` during retrieval). Chunks without an `embedding` still participate in MMR selection using their relevance score, they just skip the diversity comparison against other chunks. If no chunk in the set has an embedding at all, MMR has no diversity signal to work with and passes the incoming order through, capped at `topK`.
32+
1933
Strategies aren't passed to `KnowledgeBase` directly — they're registered on a `RAGPipeline`, which runs pre-retrieval strategies, retrieves via the `KnowledgeBase`, then runs post-retrieval strategies:
2034

2135
```typescript

docs/ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ All stores implement the shared `AgentMemoryStore` interface (`save(record)` / `
177177
* **Hybrid Retrieval (`HybridVectorStore`)**: Combines dense cosine-similarity vector search with a real BM25 sparse keyword score (IDF-weighted, `k1`/`b` length-normalization, incremental corpus statistics maintained on `addChunks`/`deleteChunk`). Fusion strategy is configurable via `fusionMethod`: `'weighted'` (default) blends max-normalized raw scores by `vectorWeight`; `'rrf'` combines the two rankers' rank positions via Reciprocal Rank Fusion (`reciprocalRankFusion`, smoothing constant `rrfK`, default `60`), avoiding cross-scale score normalization.
178178
* **Relational Traversal (`GraphRAGStrategy` + `InMemoryKnowledgeGraphProvider`)**: Traverses a manually or programmatically populated entity-relationship graph (imports, callers, inheritance) via BFS sub-graph queries, boosting chunks that mention matched entities.
179179
* **Pluggable Reranking (`RerankerStrategy`)**: A post-retrieval hook accepting any custom `rerankFn`, with built-in `createCohereRerankProvider`/`createVoyageRerankProvider` factories, a `minScore` cutoff to drop low-relevance chunks outright, and an observable `onRerankFailure`/`onRerankFailureMode` (`'fallback'` | `'throw'`) for when `rerankFn` fails, instead of silently degrading to term-overlap scoring.
180+
* **Diversity Selection (`MmrStrategy`)**: A post-retrieval Maximal Marginal Relevance strategy that greedily balances relevance (`context.scores`) against similarity to already-selected chunks (via a shared `cosineSimilarity` utility), reducing near-duplicate chunks (e.g. overlapping parent/child sections) crowding out distinct context. Tunable via `lambda` (relevance/diversity balance, default `0.5`) and `topK`.
180181

181182
---
182183

docs/ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ Goal: bring `@nestjs-agentic/rag`'s retrieval and reranking up to what its own d
153153
- [x] Implement real Reciprocal Rank Fusion (RRF) for combining dense and sparse result lists ([#130](https://github.com/irzix/nestjs-agentic/issues/130)).
154154
- [x] Replace `HybridVectorStore`'s term-frequency-only sparse score with real BM25 (IDF, k1/b saturation) ([#131](https://github.com/irzix/nestjs-agentic/issues/131)).
155155
- [x] Ship built-in reranker provider adapters (Cohere, Voyage) and a `minScore` cutoff ([#132](https://github.com/irzix/nestjs-agentic/issues/132)).
156-
- [ ] Add an MMR (Maximal Marginal Relevance) diversity strategy to reduce near-duplicate chunks in context ([#133](https://github.com/irzix/nestjs-agentic/issues/133)).
156+
- [x] Add an MMR (Maximal Marginal Relevance) diversity strategy to reduce near-duplicate chunks in context ([#133](https://github.com/irzix/nestjs-agentic/issues/133)).
157157
- [x] Fix `HybridVectorStore.addChunks` to embed via batched `embedDocuments` instead of one `embedQuery` call per chunk ([#134](https://github.com/irzix/nestjs-agentic/issues/134)).
158158
- [ ] Add an embedding cache (in-memory LRU, pluggable Redis backend) wrapping any `EmbeddingProvider` ([#134](https://github.com/irzix/nestjs-agentic/issues/134)).
159159

packages/rag/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export * from './strategies/late-chunking.strategy';
1919
export * from './strategies/parent-child-hydration.strategy';
2020
export * from './strategies/contextual-compression.strategy';
2121
export * from './strategies/reranker.strategy';
22+
export * from './strategies/mmr.strategy';
23+
export * from './utils/cosine-similarity';
2224
export * from './strategies/graph-rag.strategy';
2325
export * from './strategies/graph-dependency.strategy';
2426
export * from './strategies/u-shaped-context.strategy';

packages/rag/src/stores/hybrid-vector.store.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { DocumentChunk } from '../interfaces/document.interface';
33
import type { EmbeddingProvider } from '../interfaces/embedding.interface';
44
import type { ScoredDocumentChunk, VectorStoreAdapter } from '../interfaces/vector-store.interface';
55
import { reciprocalRankFusion } from '../utils/rrf-fusion';
6+
import { cosineSimilarity } from '../utils/cosine-similarity';
67

78
/**
89
* Options for configuring HybridVectorStore.
@@ -311,19 +312,7 @@ export class HybridVectorStore implements SemanticStoreProvider, VectorStoreAdap
311312
const rawScores = chunksToSearch.map((chunk) => {
312313
const bm25Score = this.bm25Score(chunk.id, queryTermCounts);
313314

314-
let vectorScore = 0;
315-
if (queryVector && chunk.embedding && queryVector.length === chunk.embedding.length) {
316-
let dotProduct = 0;
317-
let normA = 0;
318-
let normB = 0;
319-
for (let i = 0; i < queryVector.length; i++) {
320-
dotProduct += queryVector[i] * chunk.embedding[i];
321-
normA += queryVector[i] * queryVector[i];
322-
normB += chunk.embedding[i] * chunk.embedding[i];
323-
}
324-
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
325-
vectorScore = denominator > 0 ? dotProduct / denominator : 0;
326-
}
315+
const vectorScore = queryVector && chunk.embedding ? cosineSimilarity(queryVector, chunk.embedding) : 0;
327316

328317
return { chunk, bm25Score, vectorScore };
329318
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { DocumentChunk } from '../interfaces/document.interface';
2+
import type { RAGContext, RAGStrategy } from '../interfaces/strategy.interface';
3+
import { cosineSimilarity } from '../utils/cosine-similarity';
4+
5+
/**
6+
* Options for configuring MmrStrategy.
7+
*/
8+
export interface MmrStrategyOptions {
9+
/** Maximum number of chunks to select. Default: `5` */
10+
topK?: number;
11+
12+
/**
13+
* Balances relevance to the query against diversity from already-selected
14+
* chunks: `1` is pure relevance (no diversity), `0` is pure diversity
15+
* (ignores relevance after the first pick). Default: `0.5`
16+
*/
17+
lambda?: number;
18+
}
19+
20+
/**
21+
* Post-retrieval RAG Strategy implementing Maximal Marginal Relevance (MMR):
22+
* greedily selects chunks that are relevant to the query but dissimilar to
23+
* chunks already selected, reducing near-duplicate context (e.g. overlapping
24+
* parent/child chunks or repeated sections) crowding out distinct information.
25+
*
26+
* `MMR = argmax_{d in R \ S} [ lambda * Sim(d, q) - (1 - lambda) * max_{d' in S} Sim(d, d') ]`
27+
*
28+
* `Sim(d, q)` uses `context.scores` (the retrieval relevance score already
29+
* populated by `RAGPipeline`). `Sim(d, d')` requires chunk embeddings — a
30+
* chunk without an `embedding` is treated as having zero similarity to every
31+
* other chunk, so it can still be selected but never penalizes or is
32+
* penalized by diversity against the ones that do carry embeddings.
33+
*/
34+
export class MmrStrategy implements RAGStrategy {
35+
readonly name = 'MMR';
36+
readonly phase = 'post-retrieval' as const;
37+
private readonly topK: number;
38+
private readonly lambda: number;
39+
40+
/**
41+
* Creates a new instance of MmrStrategy.
42+
* @param options Configuration for top-K cutoff and the relevance/diversity balance (`lambda`).
43+
*/
44+
constructor(options?: MmrStrategyOptions) {
45+
const topK = options?.topK ?? 5;
46+
if (!Number.isInteger(topK) || topK < 0) {
47+
throw new RangeError(`MmrStrategy: topK must be a non-negative integer, got ${topK}`);
48+
}
49+
this.topK = topK;
50+
51+
const lambda = options?.lambda ?? 0.5;
52+
if (!Number.isFinite(lambda) || lambda < 0 || lambda > 1) {
53+
throw new RangeError(`MmrStrategy: lambda must be a finite number in [0, 1], got ${lambda}`);
54+
}
55+
this.lambda = lambda;
56+
}
57+
58+
/**
59+
* Selects a diverse top-K subset of `context.chunks` via MMR.
60+
*
61+
* @param context RAGContext payload containing retrieved chunks and their relevance `scores`.
62+
* @returns Updated RAGContext with `chunks` replaced by the MMR-selected subset.
63+
*/
64+
process(context: RAGContext): RAGContext {
65+
const chunks = context.chunks ?? [];
66+
if (chunks.length === 0) return context;
67+
68+
// Without any chunk embeddings there's no diversity signal to select by;
69+
// pass through the incoming order (already relevance-ranked upstream).
70+
if (!chunks.some((c) => c.embedding)) {
71+
return { ...context, chunks: chunks.slice(0, this.topK) };
72+
}
73+
74+
const scores = context.scores;
75+
const relevance = (chunk: DocumentChunk): number => scores?.get(chunk.id) ?? 0;
76+
77+
const remaining = [...chunks];
78+
const selected: DocumentChunk[] = [];
79+
80+
while (remaining.length > 0 && selected.length < this.topK) {
81+
let bestIndex = 0;
82+
let bestScore = -Infinity;
83+
84+
for (let i = 0; i < remaining.length; i++) {
85+
const candidate = remaining[i];
86+
const relevanceScore = relevance(candidate);
87+
88+
// Chunks without an embedding can't be compared for similarity, so
89+
// they never get diversity-penalized (or credited) against selected chunks.
90+
// Starts at -Infinity (not 0) so a genuinely negative cosine similarity
91+
// — an anti-correlated embedding — still counts as the max, per the MMR formula.
92+
let maxSimToSelected = -Infinity;
93+
if (candidate.embedding) {
94+
for (const s of selected) {
95+
if (!s.embedding) continue;
96+
const sim = cosineSimilarity(candidate.embedding, s.embedding);
97+
if (sim > maxSimToSelected) maxSimToSelected = sim;
98+
}
99+
}
100+
if (maxSimToSelected === -Infinity) maxSimToSelected = 0;
101+
102+
const mmrScore = this.lambda * relevanceScore - (1 - this.lambda) * maxSimToSelected;
103+
if (mmrScore > bestScore) {
104+
bestScore = mmrScore;
105+
bestIndex = i;
106+
}
107+
}
108+
109+
selected.push(remaining[bestIndex]);
110+
remaining.splice(bestIndex, 1);
111+
}
112+
113+
return { ...context, chunks: selected };
114+
}
115+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Cosine similarity between two vectors of equal length. Returns `0` for
3+
* mismatched lengths or zero-magnitude vectors, rather than throwing or
4+
* producing `NaN`.
5+
*
6+
* @param a First vector.
7+
* @param b Second vector, compared against `a`.
8+
* @returns Cosine similarity in `[-1, 1]`, or `0` if the inputs are incomparable.
9+
*/
10+
export function cosineSimilarity(a: number[], b: number[]): number {
11+
if (a.length !== b.length || a.length === 0) return 0;
12+
13+
let dotProduct = 0;
14+
let normA = 0;
15+
let normB = 0;
16+
for (let i = 0; i < a.length; i++) {
17+
dotProduct += a[i] * b[i];
18+
normA += a[i] * a[i];
19+
normB += b[i] * b[i];
20+
}
21+
22+
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
23+
return denominator > 0 ? dotProduct / denominator : 0;
24+
}

packages/rag/test/rag.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,114 @@ export class BenchmarkService${i} {
10371037
assert(false, 'Test 16: Built-in Cohere/Voyage rerank provider adapters', err.message);
10381038
}
10391039

1040+
// TEST 17: MmrStrategy diversity selection (#133)
1041+
try {
1042+
const { MmrStrategy, cosineSimilarity } = await import('../src');
1043+
1044+
// 17a. cosineSimilarity utility: known values and edge cases
1045+
assert(Math.abs(cosineSimilarity([1, 0], [1, 0]) - 1) < 1e-9, 'Test 17a: cosineSimilarity of identical vectors is 1');
1046+
assert(Math.abs(cosineSimilarity([1, 0], [0, 1])) < 1e-9, 'Test 17a2: cosineSimilarity of orthogonal vectors is 0');
1047+
assert(cosineSimilarity([1, 0], [1, 0, 0]) === 0, 'Test 17a3: cosineSimilarity of mismatched-length vectors returns 0, not a throw');
1048+
assert(cosineSimilarity([0, 0], [1, 0]) === 0, 'Test 17a4: cosineSimilarity of a zero-magnitude vector returns 0, not NaN');
1049+
1050+
// A and B are near-duplicates (identical embedding); C is distinct but lower-scored.
1051+
const chunkA = { id: 'a', parentId: 'p', content: 'auth token validation', metadata: {}, embedding: [1, 0] };
1052+
const chunkB = { id: 'b', parentId: 'p', content: 'auth token validation (near dup)', metadata: {}, embedding: [1, 0] };
1053+
const chunkC = { id: 'c', parentId: 'p', content: 'unrelated billing export', metadata: {}, embedding: [0, 1] };
1054+
const scores = new Map([['a', 0.9], ['b', 0.85], ['c', 0.5]]);
1055+
1056+
// 17b. Plain top-K by score would pick A, B (both near-duplicates) — establish the baseline being improved on.
1057+
const plainTopK = [chunkA, chunkB, chunkC].sort((x, y) => scores.get(y.id)! - scores.get(x.id)!).slice(0, 2);
1058+
assert(
1059+
plainTopK.map((c) => c.id).sort().join(',') === 'a,b',
1060+
'Test 17b: baseline top-K by score selects the two near-duplicate chunks (A, B)',
1061+
);
1062+
1063+
// 17c. MMR selects A (most relevant) then C (distinct), not A+B, given the near-duplicate penalty
1064+
const mmr = new MmrStrategy({ topK: 2, lambda: 0.5 });
1065+
const mmrResult = mmr.process({ query: 'auth', chunks: [chunkA, chunkB, chunkC], scores });
1066+
const mmrIds = mmrResult.chunks!.map((c) => c.id).sort();
1067+
assert(mmrIds.join(',') === 'a,c', 'Test 17c: MMR selects the relevant chunk plus a distinct one (A, C), surfacing more unique context than plain top-K (A, B)');
1068+
1069+
// 17d. lambda close to 1 behaves like pure relevance ranking (ignores diversity)
1070+
const pureRelevance = new MmrStrategy({ topK: 2, lambda: 1 });
1071+
const pureResult = pureRelevance.process({ query: 'auth', chunks: [chunkA, chunkB, chunkC], scores });
1072+
assert(
1073+
pureResult.chunks!.map((c) => c.id).sort().join(',') === 'a,b',
1074+
'Test 17d: lambda=1 (pure relevance) reduces to plain top-K by score, picking A and B',
1075+
);
1076+
1077+
// 17e. topK is respected
1078+
const cappedResult = mmr.process({ query: 'auth', chunks: [chunkA, chunkB, chunkC], scores: scores });
1079+
assert(cappedResult.chunks!.length === 2, 'Test 17e: MmrStrategy respects the configured topK');
1080+
1081+
// 17f. Chunks without embeddings pass through unchanged (capped at topK), no throw
1082+
const noEmbedChunks = [
1083+
{ id: 'x', parentId: 'p', content: 'x', metadata: {} },
1084+
{ id: 'y', parentId: 'p', content: 'y', metadata: {} },
1085+
];
1086+
const noEmbedResult = mmr.process({ query: 'q', chunks: noEmbedChunks });
1087+
assert(noEmbedResult.chunks!.length === 2, 'Test 17f: chunks without embeddings pass through without throwing');
1088+
1089+
// 17g. Empty chunks array is a no-op
1090+
const emptyResult = mmr.process({ query: 'q', chunks: [] });
1091+
assert(emptyResult.chunks!.length === 0, 'Test 17g: an empty chunks array is handled without throwing');
1092+
1093+
// 17h. Selection order matters, not just membership: the most relevant chunk must be picked first
1094+
assert(mmrResult.chunks![0].id === 'a', 'Test 17h: MMR selects the most relevant chunk (A) first, not just as a set member');
1095+
1096+
// 17i. Invalid topK/lambda are rejected at construction, per review feedback
1097+
let rejectedTopK = false;
1098+
try {
1099+
new MmrStrategy({ topK: -1 });
1100+
} catch {
1101+
rejectedTopK = true;
1102+
}
1103+
assert(rejectedTopK, 'Test 17i: MmrStrategy rejects a negative topK at construction');
1104+
1105+
let rejectedLambda = false;
1106+
try {
1107+
new MmrStrategy({ lambda: 1.5 });
1108+
} catch {
1109+
rejectedLambda = true;
1110+
}
1111+
assert(rejectedLambda, 'Test 17j: MmrStrategy rejects a lambda outside [0, 1] at construction');
1112+
1113+
// 17k. Anti-correlated (negative cosine similarity) embeddings still participate
1114+
// in the diversity penalty per the MMR formula, instead of being clamped to 0, per review feedback
1115+
const chunkP = { id: 'p1', parentId: 'p', content: 'p', metadata: {}, embedding: [1, 0] };
1116+
const chunkQ = { id: 'q1', parentId: 'p', content: 'q', metadata: {}, embedding: [-1, 0] }; // anti-correlated with P
1117+
const antiScores = new Map([['p1', 1], ['q1', 0.99]]);
1118+
// lambda=0 -> pure diversity: after picking P, the score for Q becomes -(1)*(-1) = +1 (rewarded for being anti-correlated),
1119+
// which must be strictly greater than picking a chunk identical to P would score (-(1)*(1) = -1).
1120+
const pureDiversity = new MmrStrategy({ topK: 2, lambda: 0 });
1121+
const chunkR = { id: 'r1', parentId: 'p', content: 'r', metadata: {}, embedding: [1, 0] }; // identical to P
1122+
const antiResult = pureDiversity.process({
1123+
query: 'q',
1124+
chunks: [chunkP, chunkQ, chunkR],
1125+
scores: new Map([...antiScores, ['r1', 0.98]]),
1126+
});
1127+
assert(
1128+
antiResult.chunks![1].id === 'q1',
1129+
'Test 17k: an anti-correlated (negative cosine similarity) chunk is preferred over a duplicate under pure diversity, proving negative similarity is not clamped to 0',
1130+
);
1131+
1132+
// 17l. Mixed embedded/non-embedded chunks: the non-embedded chunk still participates via its score
1133+
const embedded = { id: 'e1', parentId: 'p', content: 'e', metadata: {}, embedding: [1, 0] };
1134+
const noEmbed = { id: 'n1', parentId: 'p', content: 'n', metadata: {} };
1135+
const mixedResult = mmr.process({
1136+
query: 'q',
1137+
chunks: [embedded, noEmbed],
1138+
scores: new Map([['e1', 0.5], ['n1', 0.9]]),
1139+
});
1140+
assert(
1141+
mixedResult.chunks!.length === 2 && mixedResult.chunks!.some((c) => c.id === 'n1'),
1142+
'Test 17l: a chunk without an embedding still participates in selection via its relevance score, in a mixed set',
1143+
);
1144+
} catch (err: any) {
1145+
assert(false, 'Test 17: MmrStrategy diversity selection', err.message);
1146+
}
1147+
10401148
console.log(`\n 📊 Core RAG Test Results: ${passed} passed, ${failed} failed.\n`);
10411149
if (failed > 0) {
10421150
throw new Error('RAG Unit Tests Failed');

0 commit comments

Comments
 (0)