@mastra/rag 0.0.0-commonjs-20250227130920
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +23 -0
- package/CHANGELOG.md +1151 -0
- package/LICENSE +44 -0
- package/README.md +26 -0
- package/dist/_tsup-dts-rollup.d.cts +620 -0
- package/dist/_tsup-dts-rollup.d.ts +620 -0
- package/dist/index.cjs +2524 -0
- package/dist/index.d.cts +22 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +2505 -0
- package/docker-compose.yaml +22 -0
- package/eslint.config.js +6 -0
- package/package.json +72 -0
- package/src/document/document.test.ts +985 -0
- package/src/document/document.ts +281 -0
- package/src/document/index.ts +2 -0
- package/src/document/transformers/character.ts +279 -0
- package/src/document/transformers/html.ts +346 -0
- package/src/document/transformers/json.ts +265 -0
- package/src/document/transformers/latex.ts +19 -0
- package/src/document/transformers/markdown.ts +244 -0
- package/src/document/transformers/text.ts +134 -0
- package/src/document/transformers/token.ts +148 -0
- package/src/document/transformers/transformer.ts +5 -0
- package/src/document/types.ts +103 -0
- package/src/graph-rag/index.test.ts +235 -0
- package/src/graph-rag/index.ts +306 -0
- package/src/index.ts +6 -0
- package/src/rerank/index.test.ts +150 -0
- package/src/rerank/index.ts +154 -0
- package/src/tools/document-chunker.ts +30 -0
- package/src/tools/graph-rag.ts +117 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/vector-query.ts +90 -0
- package/src/utils/default-settings.ts +33 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/vector-prompts.ts +729 -0
- package/src/utils/vector-search.ts +41 -0
- package/tsconfig.json +5 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { GraphChunk, GraphEdge, GraphEmbedding, GraphNode } from './';
|
|
4
|
+
import { GraphRAG } from './';
|
|
5
|
+
|
|
6
|
+
describe('GraphRAG', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.clearAllMocks(); // Clear any mock state before each test
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('addNode', () => {
|
|
12
|
+
it('should throw an error if node does not have an embedding', () => {
|
|
13
|
+
const graph = new GraphRAG();
|
|
14
|
+
const node = {
|
|
15
|
+
id: '1',
|
|
16
|
+
content: 'Node 1',
|
|
17
|
+
};
|
|
18
|
+
expect(() => graph.addNode(node)).toThrow('Node must have an embedding');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should throw an error if node embedding dimension is not equal to the graph dimension', () => {
|
|
22
|
+
const graph = new GraphRAG(2);
|
|
23
|
+
const node: GraphNode = {
|
|
24
|
+
id: '1',
|
|
25
|
+
content: 'Node 1',
|
|
26
|
+
embedding: [1, 2, 3],
|
|
27
|
+
};
|
|
28
|
+
expect(() => graph.addNode(node)).toThrow('Embedding dimension must be 2');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should add a node to the graph', () => {
|
|
32
|
+
const graph = new GraphRAG(3);
|
|
33
|
+
const node = {
|
|
34
|
+
id: '1',
|
|
35
|
+
content: 'Node 1',
|
|
36
|
+
embedding: [1, 2, 3],
|
|
37
|
+
};
|
|
38
|
+
graph.addNode(node);
|
|
39
|
+
expect(graph['nodes'].size).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('addEdge', () => {
|
|
44
|
+
it('should throw an error if either source or target node does not exist', () => {
|
|
45
|
+
const graph = new GraphRAG();
|
|
46
|
+
const edge: GraphEdge = {
|
|
47
|
+
source: '1',
|
|
48
|
+
target: '2',
|
|
49
|
+
weight: 0.5,
|
|
50
|
+
type: 'semantic',
|
|
51
|
+
};
|
|
52
|
+
expect(() => graph.addEdge(edge)).toThrow('Both source and target nodes must exist');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should add an edge between two nodes', () => {
|
|
56
|
+
const graph = new GraphRAG(3);
|
|
57
|
+
const node1: GraphNode = {
|
|
58
|
+
id: '1',
|
|
59
|
+
content: 'Node 1',
|
|
60
|
+
embedding: [1, 2, 3],
|
|
61
|
+
};
|
|
62
|
+
const node2: GraphNode = {
|
|
63
|
+
id: '2',
|
|
64
|
+
content: 'Node 2',
|
|
65
|
+
embedding: [4, 5, 6],
|
|
66
|
+
};
|
|
67
|
+
graph.addNode(node1);
|
|
68
|
+
graph.addNode(node2);
|
|
69
|
+
const edge: GraphEdge = {
|
|
70
|
+
source: '1',
|
|
71
|
+
target: '2',
|
|
72
|
+
weight: 0.5,
|
|
73
|
+
type: 'semantic',
|
|
74
|
+
};
|
|
75
|
+
graph.addEdge(edge);
|
|
76
|
+
expect(graph['edges'].length).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('createGraph', () => {
|
|
81
|
+
it("chunks and embeddings can't be empty", () => {
|
|
82
|
+
const graph = new GraphRAG(3);
|
|
83
|
+
const chunks: GraphChunk[] = [];
|
|
84
|
+
const embeddings: GraphEmbedding[] = [];
|
|
85
|
+
expect(() => graph.createGraph(chunks, embeddings)).toThrowError(
|
|
86
|
+
'Chunks and embeddings arrays must not be empty',
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
it('chunks and embeddings must have the same length', () => {
|
|
90
|
+
const graph = new GraphRAG(3);
|
|
91
|
+
const chunks: GraphChunk[] = [
|
|
92
|
+
{
|
|
93
|
+
text: 'Chunk 1',
|
|
94
|
+
metadata: {},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
text: 'Chunk 2',
|
|
98
|
+
metadata: {},
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
const embeddings: GraphEmbedding[] = [
|
|
102
|
+
{
|
|
103
|
+
vector: [1, 2, 3],
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
expect(() => graph.createGraph(chunks, embeddings)).toThrowError(
|
|
107
|
+
'Chunks and embeddings must have the same length',
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
it('should return the top ranked nodes', () => {
|
|
111
|
+
const results = [
|
|
112
|
+
{
|
|
113
|
+
metadata: {
|
|
114
|
+
text: 'Chunk 1',
|
|
115
|
+
},
|
|
116
|
+
vector: [1, 2, 3],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
metadata: {
|
|
120
|
+
text: 'Chunk 2',
|
|
121
|
+
},
|
|
122
|
+
vector: [4, 5, 6],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
metadata: {
|
|
126
|
+
text: 'Chunk 3',
|
|
127
|
+
},
|
|
128
|
+
vector: [7, 8, 9],
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const chunks = results.map(result => ({
|
|
133
|
+
text: result?.metadata?.text,
|
|
134
|
+
metadata: result.metadata,
|
|
135
|
+
}));
|
|
136
|
+
const embeddings = results.map(result => ({
|
|
137
|
+
vector: result.vector,
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
const graph = new GraphRAG(3);
|
|
141
|
+
graph.createGraph(chunks, embeddings);
|
|
142
|
+
|
|
143
|
+
const nodes = graph.getNodes();
|
|
144
|
+
expect(nodes.length).toBe(3);
|
|
145
|
+
expect(nodes[0]?.id).toBe('0');
|
|
146
|
+
expect(nodes[1]?.id).toBe('1');
|
|
147
|
+
expect(nodes[2]?.id).toBe('2');
|
|
148
|
+
|
|
149
|
+
const edges = graph.getEdges();
|
|
150
|
+
expect(edges.length).toBe(6);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('query', () => {
|
|
155
|
+
it("query embedding can't be empty", () => {
|
|
156
|
+
const graph = new GraphRAG(3);
|
|
157
|
+
const queryEmbedding: number[] = [];
|
|
158
|
+
expect(() => graph.query({ query: queryEmbedding, topK: 2, randomWalkSteps: 3, restartProb: 0.1 })).toThrowError(
|
|
159
|
+
`Query embedding must have dimension ${3}`,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('topK must be greater than 0', () => {
|
|
164
|
+
const graph = new GraphRAG(3);
|
|
165
|
+
const queryEmbedding = [1, 2, 3];
|
|
166
|
+
const topK = 0;
|
|
167
|
+
expect(() => graph.query({ query: queryEmbedding, topK, randomWalkSteps: 3, restartProb: 0.1 })).toThrowError(
|
|
168
|
+
'TopK must be greater than 0',
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('randomWalkSteps must be greater than 0', () => {
|
|
173
|
+
const graph = new GraphRAG(3);
|
|
174
|
+
const queryEmbedding = [1, 2, 3];
|
|
175
|
+
const topK = 2;
|
|
176
|
+
const randomWalkSteps = 0;
|
|
177
|
+
expect(() => graph.query({ query: queryEmbedding, topK, randomWalkSteps, restartProb: 0.1 })).toThrowError(
|
|
178
|
+
'Random walk steps must be greater than 0',
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('restartProb must be between 0 and 1', () => {
|
|
183
|
+
const graph = new GraphRAG(3);
|
|
184
|
+
const queryEmbedding = [1, 2, 3];
|
|
185
|
+
const topK = 2;
|
|
186
|
+
const randomWalkSteps = 3;
|
|
187
|
+
const restartProb = -0.1;
|
|
188
|
+
expect(() => graph.query({ query: queryEmbedding, topK, randomWalkSteps, restartProb })).toThrowError(
|
|
189
|
+
'Restart probability must be between 0 and 1',
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should return the top ranked nodes', () => {
|
|
194
|
+
const graph = new GraphRAG(3);
|
|
195
|
+
const node1: GraphNode = {
|
|
196
|
+
id: '1',
|
|
197
|
+
content: 'Node 1',
|
|
198
|
+
embedding: [1, 2, 3],
|
|
199
|
+
};
|
|
200
|
+
const node2: GraphNode = {
|
|
201
|
+
id: '2',
|
|
202
|
+
content: 'Node 2',
|
|
203
|
+
embedding: [11, 12, 13],
|
|
204
|
+
};
|
|
205
|
+
const node3: GraphNode = {
|
|
206
|
+
id: '3',
|
|
207
|
+
content: 'Node 3',
|
|
208
|
+
embedding: [21, 22, 23],
|
|
209
|
+
};
|
|
210
|
+
graph.addNode(node1);
|
|
211
|
+
graph.addNode(node2);
|
|
212
|
+
graph.addNode(node3);
|
|
213
|
+
graph.addEdge({
|
|
214
|
+
source: '1',
|
|
215
|
+
target: '2',
|
|
216
|
+
weight: 0.5,
|
|
217
|
+
type: 'semantic',
|
|
218
|
+
});
|
|
219
|
+
graph.addEdge({
|
|
220
|
+
source: '2',
|
|
221
|
+
target: '3',
|
|
222
|
+
weight: 0.7,
|
|
223
|
+
type: 'semantic',
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const queryEmbedding = [15, 16, 17];
|
|
227
|
+
const topK = 2;
|
|
228
|
+
const randomWalkSteps = 3;
|
|
229
|
+
const restartProb = 0.1;
|
|
230
|
+
const rerankedResults = graph.query({ query: queryEmbedding, topK, randomWalkSteps, restartProb });
|
|
231
|
+
|
|
232
|
+
expect(rerankedResults.length).toBe(2);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TODO: GraphRAG Enhancements
|
|
3
|
+
* - Add support for more edge types (sequential, hierarchical, citation, etc)
|
|
4
|
+
* - Allow for custom edge types
|
|
5
|
+
* - Utilize metadata for richer connections
|
|
6
|
+
* - Improve graph traversal and querying using types
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
type SupportedEdgeType = 'semantic';
|
|
10
|
+
|
|
11
|
+
// Types for graph nodes and edges
|
|
12
|
+
export interface GraphNode {
|
|
13
|
+
id: string;
|
|
14
|
+
content: string;
|
|
15
|
+
embedding?: number[];
|
|
16
|
+
metadata?: Record<string, any>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RankedNode extends GraphNode {
|
|
20
|
+
score: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GraphEdge {
|
|
24
|
+
source: string;
|
|
25
|
+
target: string;
|
|
26
|
+
weight: number;
|
|
27
|
+
type: SupportedEdgeType;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GraphChunk {
|
|
31
|
+
text: string;
|
|
32
|
+
metadata: Record<string, any>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GraphEmbedding {
|
|
36
|
+
vector: number[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class GraphRAG {
|
|
40
|
+
private nodes: Map<string, GraphNode>;
|
|
41
|
+
private edges: GraphEdge[];
|
|
42
|
+
private dimension: number;
|
|
43
|
+
private threshold: number;
|
|
44
|
+
|
|
45
|
+
constructor(dimension: number = 1536, threshold: number = 0.7) {
|
|
46
|
+
this.nodes = new Map();
|
|
47
|
+
this.edges = [];
|
|
48
|
+
this.dimension = dimension;
|
|
49
|
+
this.threshold = threshold;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Add a node to the graph
|
|
53
|
+
addNode(node: GraphNode): void {
|
|
54
|
+
if (!node.embedding) {
|
|
55
|
+
throw new Error('Node must have an embedding');
|
|
56
|
+
}
|
|
57
|
+
if (node.embedding.length !== this.dimension) {
|
|
58
|
+
throw new Error(`Embedding dimension must be ${this.dimension}`);
|
|
59
|
+
}
|
|
60
|
+
this.nodes.set(node.id, node);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Add an edge between two nodes
|
|
64
|
+
addEdge(edge: GraphEdge): void {
|
|
65
|
+
if (!this.nodes.has(edge.source) || !this.nodes.has(edge.target)) {
|
|
66
|
+
throw new Error('Both source and target nodes must exist');
|
|
67
|
+
}
|
|
68
|
+
this.edges.push(edge);
|
|
69
|
+
// Add reverse edge
|
|
70
|
+
this.edges.push({
|
|
71
|
+
source: edge.target,
|
|
72
|
+
target: edge.source,
|
|
73
|
+
weight: edge.weight,
|
|
74
|
+
type: edge.type,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Helper method to get all nodes
|
|
79
|
+
getNodes(): GraphNode[] {
|
|
80
|
+
return Array.from(this.nodes.values());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Helper method to get all edges
|
|
84
|
+
getEdges(): GraphEdge[] {
|
|
85
|
+
return this.edges;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getEdgesByType(type: string): GraphEdge[] {
|
|
89
|
+
return this.edges.filter(edge => edge.type === type);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
clear(): void {
|
|
93
|
+
this.nodes.clear();
|
|
94
|
+
this.edges = [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
updateNodeContent(id: string, newContent: string): void {
|
|
98
|
+
const node = this.nodes.get(id);
|
|
99
|
+
if (!node) {
|
|
100
|
+
throw new Error(`Node ${id} not found`);
|
|
101
|
+
}
|
|
102
|
+
node.content = newContent;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Get neighbors of a node
|
|
106
|
+
private getNeighbors(nodeId: string, edgeType?: string): { id: string; weight: number }[] {
|
|
107
|
+
return this.edges
|
|
108
|
+
.filter(edge => edge.source === nodeId && (!edgeType || edge.type === edgeType))
|
|
109
|
+
.map(edge => ({
|
|
110
|
+
id: edge.target,
|
|
111
|
+
weight: edge.weight,
|
|
112
|
+
}))
|
|
113
|
+
.filter(node => node !== undefined);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Calculate cosine similarity between two vectors
|
|
117
|
+
private cosineSimilarity(vec1: number[], vec2: number[]): number {
|
|
118
|
+
if (!vec1 || !vec2) {
|
|
119
|
+
throw new Error('Vectors must not be null or undefined');
|
|
120
|
+
}
|
|
121
|
+
const vectorLength = vec1.length;
|
|
122
|
+
|
|
123
|
+
if (vectorLength !== vec2.length) {
|
|
124
|
+
throw new Error(`Vector dimensions must match: vec1(${vec1.length}) !== vec2(${vec2.length})`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let dotProduct = 0;
|
|
128
|
+
let normVec1 = 0;
|
|
129
|
+
let normVec2 = 0;
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < vectorLength; i++) {
|
|
132
|
+
const a = vec1[i]!; // Non-null assertion operator
|
|
133
|
+
const b = vec2[i]!;
|
|
134
|
+
|
|
135
|
+
dotProduct += a * b;
|
|
136
|
+
normVec1 += a * a;
|
|
137
|
+
normVec2 += b * b;
|
|
138
|
+
}
|
|
139
|
+
const magnitudeProduct = Math.sqrt(normVec1 * normVec2);
|
|
140
|
+
|
|
141
|
+
if (magnitudeProduct === 0) {
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const similarity = dotProduct / magnitudeProduct;
|
|
146
|
+
return Math.max(-1, Math.min(1, similarity));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
createGraph(chunks: GraphChunk[], embeddings: GraphEmbedding[]) {
|
|
150
|
+
if (!chunks?.length || !embeddings?.length) {
|
|
151
|
+
throw new Error('Chunks and embeddings arrays must not be empty');
|
|
152
|
+
}
|
|
153
|
+
if (chunks.length !== embeddings.length) {
|
|
154
|
+
throw new Error('Chunks and embeddings must have the same length');
|
|
155
|
+
}
|
|
156
|
+
// Create nodes from chunks
|
|
157
|
+
chunks.forEach((chunk, index) => {
|
|
158
|
+
const node: GraphNode = {
|
|
159
|
+
id: index.toString(),
|
|
160
|
+
content: chunk.text,
|
|
161
|
+
embedding: embeddings[index]?.vector,
|
|
162
|
+
metadata: { ...chunk.metadata },
|
|
163
|
+
};
|
|
164
|
+
this.addNode(node);
|
|
165
|
+
this.nodes.set(node.id, node);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Create edges based on cosine similarity
|
|
169
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
170
|
+
const firstEmbedding = embeddings[i]?.vector as number[];
|
|
171
|
+
for (let j = i + 1; j < chunks.length; j++) {
|
|
172
|
+
const secondEmbedding = embeddings[j]?.vector as number[];
|
|
173
|
+
const similarity = this.cosineSimilarity(firstEmbedding, secondEmbedding);
|
|
174
|
+
|
|
175
|
+
// Only create edges if similarity is above threshold
|
|
176
|
+
if (similarity > this.threshold) {
|
|
177
|
+
this.addEdge({
|
|
178
|
+
source: i.toString(),
|
|
179
|
+
target: j.toString(),
|
|
180
|
+
weight: similarity,
|
|
181
|
+
type: 'semantic',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private selectWeightedNeighbor(neighbors: Array<{ id: string; weight: number }>): string {
|
|
189
|
+
// Sum all weights to normalize probabilities
|
|
190
|
+
const totalWeight = neighbors.reduce((sum, n) => sum + n.weight, 0);
|
|
191
|
+
|
|
192
|
+
// Pick a random point in the total weight range
|
|
193
|
+
let remainingWeight = Math.random() * totalWeight;
|
|
194
|
+
|
|
195
|
+
// Subtract each weight from our random value until we go below 0
|
|
196
|
+
// Higher weights will make us go below 0 more often, making them more likely to be selected
|
|
197
|
+
for (const neighbor of neighbors) {
|
|
198
|
+
remainingWeight -= neighbor.weight;
|
|
199
|
+
if (remainingWeight <= 0) {
|
|
200
|
+
return neighbor.id;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return neighbors[neighbors.length - 1]?.id as string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Perform random walk with restart
|
|
208
|
+
private randomWalkWithRestart(startNodeId: string, steps: number, restartProb: number): Map<string, number> {
|
|
209
|
+
const visits = new Map<string, number>();
|
|
210
|
+
let currentNodeId = startNodeId;
|
|
211
|
+
|
|
212
|
+
for (let step = 0; step < steps; step++) {
|
|
213
|
+
// Record visit
|
|
214
|
+
visits.set(currentNodeId, (visits.get(currentNodeId) || 0) + 1);
|
|
215
|
+
|
|
216
|
+
// Decide whether to restart
|
|
217
|
+
if (Math.random() < restartProb) {
|
|
218
|
+
currentNodeId = startNodeId;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Get neighbors
|
|
223
|
+
const neighbors = this.getNeighbors(currentNodeId);
|
|
224
|
+
if (neighbors.length === 0) {
|
|
225
|
+
currentNodeId = startNodeId;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Select random weighted neighbor and set as current node
|
|
230
|
+
currentNodeId = this.selectWeightedNeighbor(neighbors);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Normalize visits
|
|
234
|
+
const totalVisits = Array.from(visits.values()).reduce((a, b) => a + b, 0);
|
|
235
|
+
const normalizedVisits = new Map<string, number>();
|
|
236
|
+
for (const [nodeId, count] of visits) {
|
|
237
|
+
normalizedVisits.set(nodeId, count / totalVisits);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return normalizedVisits;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Retrieve relevant nodes using hybrid approach
|
|
244
|
+
query({
|
|
245
|
+
query,
|
|
246
|
+
topK = 10,
|
|
247
|
+
randomWalkSteps = 100,
|
|
248
|
+
restartProb = 0.15,
|
|
249
|
+
}: {
|
|
250
|
+
query: number[];
|
|
251
|
+
topK?: number;
|
|
252
|
+
randomWalkSteps?: number;
|
|
253
|
+
restartProb?: number;
|
|
254
|
+
}): RankedNode[] {
|
|
255
|
+
if (!query || query.length !== this.dimension) {
|
|
256
|
+
throw new Error(`Query embedding must have dimension ${this.dimension}`);
|
|
257
|
+
}
|
|
258
|
+
if (topK < 1) {
|
|
259
|
+
throw new Error('TopK must be greater than 0');
|
|
260
|
+
}
|
|
261
|
+
if (randomWalkSteps < 1) {
|
|
262
|
+
throw new Error('Random walk steps must be greater than 0');
|
|
263
|
+
}
|
|
264
|
+
if (restartProb <= 0 || restartProb >= 1) {
|
|
265
|
+
throw new Error('Restart probability must be between 0 and 1');
|
|
266
|
+
}
|
|
267
|
+
// Retrieve nodes and calculate similarity
|
|
268
|
+
const similarities = Array.from(this.nodes.values()).map(node => ({
|
|
269
|
+
node,
|
|
270
|
+
similarity: this.cosineSimilarity(query, node.embedding!),
|
|
271
|
+
}));
|
|
272
|
+
|
|
273
|
+
// Sort by similarity
|
|
274
|
+
similarities.sort((a, b) => b.similarity - a.similarity);
|
|
275
|
+
const topNodes = similarities.slice(0, topK);
|
|
276
|
+
|
|
277
|
+
// Re-ranks nodes using random walk with restart
|
|
278
|
+
const rerankedNodes = new Map<string, { node: GraphNode; score: number }>();
|
|
279
|
+
|
|
280
|
+
// For each top node, perform random walk
|
|
281
|
+
for (const { node, similarity } of topNodes) {
|
|
282
|
+
const walkScores = this.randomWalkWithRestart(node.id, randomWalkSteps, restartProb);
|
|
283
|
+
|
|
284
|
+
// Combine dense retrieval score with graph score
|
|
285
|
+
for (const [nodeId, walkScore] of walkScores) {
|
|
286
|
+
const node = this.nodes.get(nodeId)!;
|
|
287
|
+
const existingScore = rerankedNodes.get(nodeId)?.score || 0;
|
|
288
|
+
rerankedNodes.set(nodeId, {
|
|
289
|
+
node,
|
|
290
|
+
score: existingScore + similarity * walkScore,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Sort by final score and return top K nodes
|
|
296
|
+
return Array.from(rerankedNodes.values())
|
|
297
|
+
.sort((a, b) => b.score - a.score)
|
|
298
|
+
.slice(0, topK)
|
|
299
|
+
.map(item => ({
|
|
300
|
+
id: item.node.id,
|
|
301
|
+
content: item.node.content,
|
|
302
|
+
metadata: item.node.metadata,
|
|
303
|
+
score: item.score,
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { cohere } from '@ai-sdk/cohere';
|
|
2
|
+
import { CohereRelevanceScorer } from '@mastra/core/relevance';
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { rerank } from '.';
|
|
6
|
+
|
|
7
|
+
vi.spyOn(CohereRelevanceScorer.prototype, 'getRelevanceScore').mockImplementation(async () => {
|
|
8
|
+
return 1;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const getScoreSpreads = (results1: any, results2: any) => {
|
|
12
|
+
const scoreSpread1 = Math.max(...results1.map((r: any) => r.score)) - Math.min(...results1.map((r: any) => r.score));
|
|
13
|
+
const scoreSpread2 = Math.max(...results2.map((r: any) => r.score)) - Math.min(...results2.map((r: any) => r.score));
|
|
14
|
+
return { scoreSpread1, scoreSpread2 };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe('rerank', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should throw an error if weights do not add up to 1', async () => {
|
|
23
|
+
const results = [
|
|
24
|
+
{ id: '1', metadata: { text: 'Test result 1' }, score: 0.5 },
|
|
25
|
+
{ id: '2', metadata: { text: 'Test result 2' }, score: 0.4 },
|
|
26
|
+
{ id: '3', metadata: { text: 'Test result 3' }, score: 0.9 },
|
|
27
|
+
];
|
|
28
|
+
await expect(
|
|
29
|
+
rerank(results, 'test query', cohere('rerank-v3.5'), { weights: { semantic: 0.5, vector: 0.3, position: 0.5 } }),
|
|
30
|
+
).rejects.toThrow('Weights must add up to 1');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should rerank results with default weights', async () => {
|
|
34
|
+
const results = [
|
|
35
|
+
{ id: '1', metadata: { text: 'Test result 1' }, score: 0.5 },
|
|
36
|
+
{ id: '2', metadata: { text: 'Test result 2' }, score: 0.4 },
|
|
37
|
+
{ id: '3', metadata: { text: 'Test result 3' }, score: 0.9 },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const rerankedResults = await rerank(results, 'test query', cohere('rerank-v3.5'), { topK: 2 });
|
|
41
|
+
|
|
42
|
+
expect(rerankedResults).toHaveLength(2);
|
|
43
|
+
expect(rerankedResults[0]).toStrictEqual({
|
|
44
|
+
result: { id: '3', metadata: { text: 'Test result 3' }, score: 0.9 },
|
|
45
|
+
score: 0.8266666666666667,
|
|
46
|
+
details: {
|
|
47
|
+
semantic: 1,
|
|
48
|
+
vector: 0.9,
|
|
49
|
+
position: 0.33333333333333337,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
expect(rerankedResults[1]).toStrictEqual({
|
|
53
|
+
result: { id: '1', metadata: { text: 'Test result 1' }, score: 0.5 },
|
|
54
|
+
score: 0.8,
|
|
55
|
+
details: {
|
|
56
|
+
semantic: 1,
|
|
57
|
+
vector: 0.5,
|
|
58
|
+
position: 1,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const { scoreSpread1, scoreSpread2 } = getScoreSpreads(results, rerankedResults);
|
|
63
|
+
expect(scoreSpread1).toBe(0.5);
|
|
64
|
+
expect(scoreSpread2).toBe(0.026666666666666616);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should rerank results with custom weights', async () => {
|
|
68
|
+
const results = [
|
|
69
|
+
{ id: '1', metadata: { text: 'Test result 1' }, score: 0.5 },
|
|
70
|
+
{ id: '2', metadata: { text: 'Test result 2' }, score: 0.4 },
|
|
71
|
+
{ id: '3', metadata: { text: 'Test result 3' }, score: 0.9 },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const rerankedResults = await rerank(results, 'test query', cohere('rerank-v3.5'), {
|
|
75
|
+
weights: {
|
|
76
|
+
semantic: 0.5,
|
|
77
|
+
vector: 0.4,
|
|
78
|
+
position: 0.1,
|
|
79
|
+
},
|
|
80
|
+
topK: 2,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(rerankedResults).toHaveLength(2);
|
|
84
|
+
expect(rerankedResults[0]).toStrictEqual({
|
|
85
|
+
result: { id: '3', metadata: { text: 'Test result 3' }, score: 0.9 },
|
|
86
|
+
score: 0.8933333333333334,
|
|
87
|
+
details: {
|
|
88
|
+
semantic: 1,
|
|
89
|
+
vector: 0.9,
|
|
90
|
+
position: 0.33333333333333337,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
expect(rerankedResults[1]).toStrictEqual({
|
|
94
|
+
result: { id: '1', metadata: { text: 'Test result 1' }, score: 0.5 },
|
|
95
|
+
score: 0.7999999999999999,
|
|
96
|
+
details: {
|
|
97
|
+
semantic: 1,
|
|
98
|
+
vector: 0.5,
|
|
99
|
+
position: 1,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
const { scoreSpread1, scoreSpread2 } = getScoreSpreads(results, rerankedResults);
|
|
103
|
+
expect(scoreSpread1).toBe(0.5);
|
|
104
|
+
expect(scoreSpread2).toBe(0.09333333333333349);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should handle query embedding when provided', async () => {
|
|
108
|
+
const results = [
|
|
109
|
+
{ id: '1', metadata: { text: 'Test result 1' }, score: 0.8 },
|
|
110
|
+
{ id: '2', metadata: { text: 'Test result 2' }, score: 0.6 },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const rerankedResults = await rerank(results, 'test query', cohere('rerank-v3.5'), {
|
|
114
|
+
queryEmbedding: [0.5, 0.3, -0.2, 0.4],
|
|
115
|
+
topK: 2,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Ensure query embedding analysis is being applied (we don't know exact score without knowing internals)
|
|
119
|
+
expect(rerankedResults).toHaveLength(2);
|
|
120
|
+
expect(rerankedResults[0]).toStrictEqual({
|
|
121
|
+
result: { id: '1', metadata: { text: 'Test result 1' }, score: 0.8 },
|
|
122
|
+
score: 0.9200000000000002,
|
|
123
|
+
details: {
|
|
124
|
+
semantic: 1,
|
|
125
|
+
vector: 0.8,
|
|
126
|
+
position: 1,
|
|
127
|
+
queryAnalysis: {
|
|
128
|
+
magnitude: 0.7348469228349535,
|
|
129
|
+
dominantFeatures: [0, 3, 1, 2],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
expect(rerankedResults[1]).toStrictEqual({
|
|
134
|
+
result: { id: '2', metadata: { text: 'Test result 2' }, score: 0.6 },
|
|
135
|
+
score: 0.74,
|
|
136
|
+
details: {
|
|
137
|
+
semantic: 1,
|
|
138
|
+
vector: 0.6,
|
|
139
|
+
position: 0.5,
|
|
140
|
+
queryAnalysis: {
|
|
141
|
+
magnitude: 0.7348469228349535,
|
|
142
|
+
dominantFeatures: [0, 3, 1, 2],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
const { scoreSpread1, scoreSpread2 } = getScoreSpreads(results, rerankedResults);
|
|
147
|
+
expect(scoreSpread1).toBe(0.20000000000000007);
|
|
148
|
+
expect(scoreSpread2).toBe(0.18000000000000016);
|
|
149
|
+
});
|
|
150
|
+
});
|