@retrivora-ai/rag-engine 0.4.2 → 0.4.4

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.
Files changed (35) hide show
  1. package/dist/{DocumentChunker-BICIjSuG.d.mts → DocumentChunker-3yElxTO3.d.mts} +9 -2
  2. package/dist/{DocumentChunker-BICIjSuG.d.ts → DocumentChunker-3yElxTO3.d.ts} +9 -2
  3. package/dist/{RagConfig-CVt24lbC.d.mts → RagConfig-BgRDL9Vy.d.mts} +37 -1
  4. package/dist/{RagConfig-CVt24lbC.d.ts → RagConfig-BgRDL9Vy.d.ts} +37 -1
  5. package/dist/SimpleGraphProvider-M6T7SE7D.mjs +62 -0
  6. package/dist/{chunk-2RLRKGHG.mjs → chunk-OKY5P6RA.mjs} +182 -43
  7. package/dist/handlers/index.d.mts +2 -2
  8. package/dist/handlers/index.d.ts +2 -2
  9. package/dist/handlers/index.js +257 -43
  10. package/dist/handlers/index.mjs +1 -1
  11. package/dist/{index-OI2--lvT.d.mts → index-7qeLTPBL.d.mts} +1 -1
  12. package/dist/{index-D1hoNXMT.d.ts → index-DowY4_K0.d.ts} +1 -1
  13. package/dist/index.d.mts +15 -5
  14. package/dist/index.d.ts +15 -5
  15. package/dist/index.js +161 -1
  16. package/dist/index.mjs +158 -1
  17. package/dist/server.d.mts +52 -12
  18. package/dist/server.d.ts +52 -12
  19. package/dist/server.js +257 -43
  20. package/dist/server.mjs +1 -1
  21. package/package.json +1 -1
  22. package/src/components/ConfigProvider.tsx +1 -0
  23. package/src/components/DocumentUpload.tsx +192 -0
  24. package/src/config/RagConfig.ts +27 -0
  25. package/src/config/constants.ts +7 -0
  26. package/src/config/serverConfig.ts +1 -0
  27. package/src/core/Pipeline.ts +71 -10
  28. package/src/core/ProviderRegistry.ts +40 -8
  29. package/src/index.ts +1 -0
  30. package/src/llm/providers/OllamaProvider.ts +3 -2
  31. package/src/providers/graphdb/BaseGraphProvider.ts +43 -0
  32. package/src/providers/graphdb/SimpleGraphProvider.ts +66 -0
  33. package/src/rag/DocumentChunker.ts +77 -34
  34. package/src/rag/EntityExtractor.ts +43 -0
  35. package/src/types/index.ts +19 -0
@@ -12,6 +12,7 @@ import {
12
12
  VECTOR_DB_PROVIDERS,
13
13
  LLM_PROVIDERS,
14
14
  EMBEDDING_PROVIDERS,
15
+ GRAPH_DB_PROVIDERS,
15
16
  } from './constants';
16
17
 
17
18
  export type VectorDBProvider = typeof VECTOR_DB_PROVIDERS[number];
@@ -42,6 +43,19 @@ export interface VectorDBConfig {
42
43
  options: Record<string, unknown>;
43
44
  }
44
45
 
46
+ // ---------------------------------------------------------------------------
47
+ // Graph DB
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export type GraphDBProvider = typeof GRAPH_DB_PROVIDERS[number];
51
+
52
+ export interface GraphDBConfig {
53
+ /** Which graph database to use */
54
+ provider: GraphDBProvider;
55
+ /** Provider-specific options (URI, credentials, etc.) */
56
+ options: Record<string, unknown>;
57
+ }
58
+
45
59
  // ---------------------------------------------------------------------------
46
60
  // LLM Provider
47
61
  // ---------------------------------------------------------------------------
@@ -128,6 +142,8 @@ export interface UIConfig {
128
142
  visualStyle?: 'glass' | 'solid';
129
143
  /** Border radius: 'none', 'sm', 'md', 'lg', 'xl', 'full' */
130
144
  borderRadius?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
145
+ /** Whether to allow file uploads directly from the UI */
146
+ allowUpload?: boolean;
131
147
  }
132
148
 
133
149
  // ---------------------------------------------------------------------------
@@ -143,6 +159,14 @@ export interface RAGConfig {
143
159
  chunkSize?: number;
144
160
  /** Overlap between adjacent chunks in tokens */
145
161
  chunkOverlap?: number;
162
+ /** Characters used to split text, in order of priority */
163
+ separators?: string[];
164
+ /** Whether to use query transformation (e.g. HyDE) */
165
+ useQueryTransformation?: boolean;
166
+ /** Whether to use graph-based retrieval */
167
+ useGraphRetrieval?: boolean;
168
+ /** Whether to perform reranking on retrieved results */
169
+ useReranking?: boolean;
146
170
  }
147
171
 
148
172
  // ---------------------------------------------------------------------------
@@ -170,4 +194,7 @@ export interface RagConfig {
170
194
 
171
195
  /** Optional RAG pipeline tuning knobs */
172
196
  rag?: RAGConfig;
197
+
198
+ /** Optional Graph database configuration */
199
+ graphDb?: GraphDBConfig;
173
200
  }
@@ -39,6 +39,13 @@ export const EMBEDDING_PROVIDERS = [
39
39
  'custom',
40
40
  ] as const;
41
41
 
42
+ export const GRAPH_DB_PROVIDERS = [
43
+ 'neo4j',
44
+ 'memgraph',
45
+ 'simple',
46
+ 'custom',
47
+ ] as const;
48
+
42
49
  export const PROVIDERS_WITH_EMBEDDINGS = [
43
50
  'openai',
44
51
  'ollama',
@@ -169,6 +169,7 @@ export function getRagConfig(env: Record<string, string | undefined> = process.e
169
169
  "Hello! I'm your AI assistant. Ask me anything about your documents.",
170
170
  visualStyle: (readString(env, 'NEXT_PUBLIC_UI_VISUAL_STYLE') ?? readString(env, 'UI_VISUAL_STYLE') ?? 'glass') as 'glass' | 'solid',
171
171
  borderRadius: (readString(env, 'NEXT_PUBLIC_UI_BORDER_RADIUS') ?? readString(env, 'UI_BORDER_RADIUS') ?? 'xl') as 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full',
172
+ allowUpload: (readString(env, 'NEXT_PUBLIC_ALLOW_UPLOAD') ?? readString(env, 'UI_ALLOW_UPLOAD') ?? 'false') === 'true',
172
173
  },
173
174
  rag: {
174
175
  topK: readNumber(env, 'RAG_TOP_K', 5),
@@ -1,11 +1,13 @@
1
1
  import { BaseVectorProvider } from '../providers/vectordb/BaseVectorProvider';
2
+ import { BaseGraphProvider } from '../providers/graphdb/BaseGraphProvider';
2
3
  import { ILLMProvider, ChatMessage } from '../llm/ILLMProvider';
3
4
  import { DocumentChunker } from '../rag/DocumentChunker';
5
+ import { EntityExtractor } from '../rag/EntityExtractor';
4
6
  import { RagConfig } from '../config/RagConfig';
5
7
  import { ProviderRegistry } from './ProviderRegistry';
6
8
  import { BatchProcessor, BatchOptions } from './BatchProcessor';
7
9
  import { EmbeddingStrategyResolver } from '../config/EmbeddingStrategy';
8
- import { IngestDocument, ChatResponse } from '../types';
10
+ import { IngestDocument, ChatResponse, GraphSearchResult } from '../types';
9
11
 
10
12
  interface QueryFieldHint {
11
13
  field?: string;
@@ -171,9 +173,11 @@ function buildQueryFilter(question: string, hints: QueryFieldHint[]): QueryFilte
171
173
  */
172
174
  export class Pipeline {
173
175
  private vectorDB!: BaseVectorProvider;
176
+ private graphDB?: BaseGraphProvider;
174
177
  private llmProvider!: ILLMProvider;
175
178
  private embeddingProvider!: ILLMProvider;
176
179
  private chunker: DocumentChunker;
180
+ private entityExtractor?: EntityExtractor;
177
181
  private config: RagConfig;
178
182
  private initialised = false;
179
183
 
@@ -202,6 +206,12 @@ export class Pipeline {
202
206
  this.llmProvider = llmProvider;
203
207
  this.embeddingProvider = embeddingProvider;
204
208
 
209
+ if (this.config.graphDb) {
210
+ this.graphDB = await ProviderRegistry.createGraphProvider(this.config.graphDb);
211
+ await this.graphDB.initialize();
212
+ this.entityExtractor = new EntityExtractor(this.llmProvider);
213
+ }
214
+
205
215
  await this.vectorDB.initialize();
206
216
  this.initialised = true;
207
217
  }
@@ -270,6 +280,16 @@ export class Pipeline {
270
280
  docId: doc.docId,
271
281
  chunksIngested: upsertResult.totalProcessed,
272
282
  });
283
+
284
+ // Graph ingestion
285
+ if (this.graphDB && this.entityExtractor) {
286
+ console.log(`[Pipeline] Extracting entities for doc ${doc.docId}...`);
287
+ for (const chunk of chunks) {
288
+ const { nodes, edges } = await this.entityExtractor.extract(chunk.content);
289
+ if (nodes.length > 0) await this.graphDB.addNodes(nodes);
290
+ if (edges.length > 0) await this.graphDB.addEdges(edges);
291
+ }
292
+ }
273
293
  } catch (error) {
274
294
  console.error(`[Pipeline] Failed to ingest document ${doc.docId}:`, error);
275
295
  results.push({ docId: doc.docId, chunksIngested: 0 });
@@ -286,29 +306,70 @@ export class Pipeline {
286
306
  const scoreThreshold = this.config.rag?.scoreThreshold ?? 0.0;
287
307
 
288
308
  try {
289
- const queryVector = await this.embeddingProvider.embed(question, { taskType: 'query' });
309
+ let searchQuery = question;
310
+
311
+ // 1. Query Transformation
312
+ if (this.config.rag?.useQueryTransformation) {
313
+ searchQuery = await this.rewriteQuery(question, history);
314
+ }
315
+
316
+ // 2. Vector Retrieval
317
+ const queryVector = await this.embeddingProvider.embed(searchQuery, { taskType: 'query' });
290
318
  const fieldHints = extractQueryFieldHints(question);
291
- // Build a provider-agnostic filter from hints + original query text. Providers
292
- // may choose to use `queryText` for hybrid keyword matching and `metadata`
293
- // for exact/filtered metadata queries.
294
319
  const filter = buildQueryFilter(question, fieldHints) as Record<string, unknown>;
295
-
296
- // Keep the raw hints available as well for providers that want richer parsing
297
320
  filter.__entityHints = fieldHints;
298
321
 
299
322
  const rawMatches = await this.vectorDB.query(queryVector, topK, ns, filter);
300
-
301
323
  const sources = rawMatches.filter((m) => m.score >= scoreThreshold);
302
- const context = sources.length
324
+
325
+ // 3. Graph Retrieval
326
+ let graphData: GraphSearchResult | undefined;
327
+ if (this.graphDB && this.config.rag?.useGraphRetrieval) {
328
+ graphData = await this.graphDB.query(searchQuery);
329
+ }
330
+
331
+ // 4. Context Augmentation
332
+ let context = sources.length
303
333
  ? sources.map((m, i) => `[Source ${i + 1}]\n${m.content}`).join('\n\n---\n\n')
304
334
  : 'No relevant context found.';
305
335
 
336
+ if (graphData && graphData.nodes.length > 0) {
337
+ const graphContext = graphData.nodes.map(n =>
338
+ `Entity: ${n.label} (${n.id})${n.properties ? ' - ' + JSON.stringify(n.properties) : ''}`
339
+ ).join('\n');
340
+ context = `GRAPH KNOWLEDGE:\n${graphContext}\n\nVECTOR CONTEXT:\n${context}`;
341
+ }
342
+
343
+ // 5. Generation
306
344
  const messages: ChatMessage[] = [...history, { role: 'user', content: question }];
307
345
  const reply = await this.llmProvider.chat(messages, context);
308
346
 
309
- return { reply, sources };
347
+ return { reply, sources, graphData };
310
348
  } catch (error) {
311
349
  throw new Error(`[Pipeline] Chat failed: ${error instanceof Error ? error.message : String(error)}`);
312
350
  }
313
351
  }
352
+
353
+ /**
354
+ * Rewrite the user query for better retrieval performance.
355
+ */
356
+ private async rewriteQuery(question: string, history: ChatMessage[]): Promise<string> {
357
+ const prompt = `
358
+ Given the following conversation history and a new question, rewrite the question to be a better search query for a vector database.
359
+ Focus on extracting the core intent and entities. Do not answer the question, just rewrite it.
360
+
361
+ History:
362
+ ${history.map(m => `${m.role}: ${m.content}`).join('\n')}
363
+
364
+ New Question: ${question}
365
+
366
+ Optimized Search Query:`;
367
+
368
+ const rewrite = await this.llmProvider.chat([
369
+ { role: 'system', content: 'You are an assistant that optimizes search queries for RAG systems.' },
370
+ { role: 'user', content: prompt }
371
+ ], '');
372
+
373
+ return rewrite.trim() || question;
374
+ }
314
375
  }
@@ -1,5 +1,6 @@
1
1
  import { BaseVectorProvider } from '../providers/vectordb/BaseVectorProvider';
2
- import { VectorDBConfig, LLMConfig, EmbeddingConfig } from '../config/RagConfig';
2
+ import { BaseGraphProvider } from '@/providers/graphdb/BaseGraphProvider';
3
+ import { VectorDBConfig, GraphDBConfig, LLMConfig, EmbeddingConfig } from '../config/RagConfig';
3
4
  import { LLMFactory } from '../llm/LLMFactory';
4
5
  import { ILLMProvider } from '../llm/ILLMProvider';
5
6
 
@@ -12,18 +13,19 @@ import { ILLMProvider } from '../llm/ILLMProvider';
12
13
  */
13
14
  export class ProviderRegistry {
14
15
  private static vectorProviders: Record<string, new (config: VectorDBConfig) => BaseVectorProvider> = {};
16
+ private static graphProviders: Record<string, new (config: GraphDBConfig) => BaseGraphProvider> = {};
15
17
 
16
- /**
17
- * Register a custom vector provider class by name.
18
- * The name must match the provider value used in VectorDBConfig.provider.
19
- *
20
- * @example
21
- * ProviderRegistry.registerVectorProvider('my-db', MyCustomProvider);
22
- */
23
18
  static registerVectorProvider(name: string, providerClass: new (config: VectorDBConfig) => BaseVectorProvider) {
24
19
  this.vectorProviders[name] = providerClass;
25
20
  }
26
21
 
22
+ /**
23
+ * Register a custom graph provider class by name.
24
+ */
25
+ static registerGraphProvider(name: string, providerClass: new (config: GraphDBConfig) => BaseGraphProvider) {
26
+ this.graphProviders[name] = providerClass;
27
+ }
28
+
27
29
  /**
28
30
  * Creates a vector database provider based on the configuration.
29
31
  * Built-in providers are dynamically imported to avoid bundling all SDKs.
@@ -85,6 +87,36 @@ export class ProviderRegistry {
85
87
  }
86
88
  }
87
89
 
90
+ /**
91
+ * Creates a graph database provider based on the configuration.
92
+ */
93
+ static async createGraphProvider(config: GraphDBConfig): Promise<BaseGraphProvider> {
94
+ const { provider } = config;
95
+
96
+ // Custom registered provider takes priority
97
+ if (this.graphProviders[provider]) {
98
+ return new this.graphProviders[provider](config);
99
+ }
100
+
101
+ // Built-in providers — lazy-loaded
102
+ switch (provider) {
103
+ case 'neo4j': {
104
+ // Placeholder for real Neo4j provider
105
+ throw new Error('[ProviderRegistry] Neo4j provider not implemented yet.');
106
+ }
107
+ case 'simple': {
108
+ const { SimpleGraphProvider } = await import('@/providers/graphdb/SimpleGraphProvider');
109
+ return new SimpleGraphProvider(config);
110
+ }
111
+ default:
112
+ throw new Error(
113
+ `[ProviderRegistry] Unsupported graph provider: "${provider}". ` +
114
+ `Built-in providers: simple. ` +
115
+ `For custom providers, call ProviderRegistry.registerGraphProvider("${provider}", YourClass).`
116
+ );
117
+ }
118
+ }
119
+
88
120
  /**
89
121
  * Creates an LLM provider based on the configuration.
90
122
  */
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // ── React Components ─────────────────────────────────────────
7
7
  export { ChatWidget } from './components/ChatWidget';
8
8
  export { ChatWindow } from './components/ChatWindow';
9
+ export { DocumentUpload } from './components/DocumentUpload';
9
10
  export { MessageBubble } from './components/MessageBubble';
10
11
  export { SourceCard } from './components/SourceCard';
11
12
  export { ConfigProvider, useConfig } from './components/ConfigProvider';
@@ -76,8 +76,9 @@ export class OllamaProvider implements ILLMProvider {
76
76
  let prompt = text;
77
77
 
78
78
  // Dynamically apply prefixes from configuration if they exist
79
- const queryPrefix = this.embeddingConfig?.queryPrefix;
80
- const docPrefix = this.embeddingConfig?.documentPrefix;
79
+ // Fallback to standard nomic-embed-text prefixes if not provided
80
+ const queryPrefix = this.embeddingConfig?.queryPrefix ?? (model.includes('nomic') ? 'search_query: ' : '');
81
+ const docPrefix = this.embeddingConfig?.documentPrefix ?? (model.includes('nomic') ? 'search_document: ' : '');
81
82
 
82
83
  if (options?.taskType === 'query' && queryPrefix) {
83
84
  if (!prompt.startsWith(queryPrefix)) {
@@ -0,0 +1,43 @@
1
+ import { GraphDBConfig } from '../../config/RagConfig';
2
+ import { GraphNode, Edge, GraphSearchResult } from '../../types';
3
+
4
+ /**
5
+ * BaseGraphProvider — Abstract base class for Graph DB providers.
6
+ */
7
+ export abstract class BaseGraphProvider {
8
+ protected config: GraphDBConfig;
9
+
10
+ constructor(config: GraphDBConfig) {
11
+ this.config = config;
12
+ }
13
+
14
+ /**
15
+ * Initialise connection to the graph database.
16
+ */
17
+ abstract initialize(): Promise<void>;
18
+
19
+ /**
20
+ * Add nodes to the graph.
21
+ */
22
+ abstract addNodes(nodes: GraphNode[]): Promise<void>;
23
+
24
+ /**
25
+ * Add edges (relationships) to the graph.
26
+ */
27
+ abstract addEdges(edges: Edge[]): Promise<void>;
28
+
29
+ /**
30
+ * Query the graph for relevant nodes and edges.
31
+ */
32
+ abstract query(queryText: string, limit?: number): Promise<GraphSearchResult>;
33
+
34
+ /**
35
+ * Check if the database is reachable.
36
+ */
37
+ abstract ping(): Promise<boolean>;
38
+
39
+ /**
40
+ * Close connection.
41
+ */
42
+ abstract disconnect(): Promise<void>;
43
+ }
@@ -0,0 +1,66 @@
1
+ import { BaseGraphProvider } from './BaseGraphProvider';
2
+ import { GraphNode, Edge, GraphSearchResult } from '../../types';
3
+
4
+ /**
5
+ * SimpleGraphProvider — In-memory graph storage for demonstration and local dev.
6
+ */
7
+ export class SimpleGraphProvider extends BaseGraphProvider {
8
+ private nodes: Map<string, GraphNode> = new Map();
9
+ private edges: Edge[] = [];
10
+
11
+ async initialize(): Promise<void> {
12
+ console.log('[SimpleGraphProvider] Initialised in-memory graph store.');
13
+ }
14
+
15
+ async addNodes(nodes: GraphNode[]): Promise<void> {
16
+ for (const node of nodes) {
17
+ this.nodes.set(node.id, node);
18
+ }
19
+ }
20
+
21
+ async addEdges(edges: Edge[]): Promise<void> {
22
+ this.edges.push(...edges);
23
+ }
24
+
25
+ async query(queryText: string, limit = 5): Promise<GraphSearchResult> {
26
+ const q = queryText.toLowerCase();
27
+
28
+ // Simple keyword matching for demonstration
29
+ const matchedNodes = Array.from(this.nodes.values()).filter(node =>
30
+ node.id.toLowerCase().includes(q) ||
31
+ node.label.toLowerCase().includes(q) ||
32
+ JSON.stringify(node.properties).toLowerCase().includes(q)
33
+ ).slice(0, limit);
34
+
35
+ const matchedNodeIds = new Set(matchedNodes.map(n => n.id));
36
+ const matchedEdges = this.edges.filter(edge =>
37
+ matchedNodeIds.has(edge.source) || matchedNodeIds.has(edge.target)
38
+ );
39
+
40
+ // Also include connected nodes
41
+ for (const edge of matchedEdges) {
42
+ if (!matchedNodeIds.has(edge.source)) {
43
+ const source = this.nodes.get(edge.source);
44
+ if (source) matchedNodes.push(source);
45
+ }
46
+ if (!matchedNodeIds.has(edge.target)) {
47
+ const target = this.nodes.get(edge.target);
48
+ if (target) matchedNodes.push(target);
49
+ }
50
+ }
51
+
52
+ return {
53
+ nodes: Array.from(new Set(matchedNodes)),
54
+ edges: matchedEdges
55
+ };
56
+ }
57
+
58
+ async ping(): Promise<boolean> {
59
+ return true;
60
+ }
61
+
62
+ async disconnect(): Promise<void> {
63
+ this.nodes.clear();
64
+ this.edges = [];
65
+ }
66
+ }
@@ -23,19 +23,23 @@ export interface ChunkOptions {
23
23
  docId?: string | number;
24
24
  /** Extra metadata to attach to every chunk */
25
25
  metadata?: Record<string, unknown>;
26
+ /** Characters used to split text, in order of priority */
27
+ separators?: string[];
26
28
  }
27
29
 
28
30
  export class DocumentChunker {
29
31
  private readonly chunkSize: number;
30
32
  private readonly chunkOverlap: number;
33
+ private readonly separators: string[];
31
34
 
32
- constructor(chunkSize = 1000, chunkOverlap = 200) {
35
+ constructor(chunkSize = 1000, chunkOverlap = 200, separators = ['\n\n', '\n', ' ', '']) {
33
36
  this.chunkSize = chunkSize;
34
37
  this.chunkOverlap = chunkOverlap;
38
+ this.separators = separators;
35
39
  }
36
40
 
37
41
  /**
38
- * Split a single text string into overlapping chunks.
42
+ * Split a single text string into overlapping chunks using a recursive strategy.
39
43
  */
40
44
  chunk(text: string, options: ChunkOptions = {}): Chunk[] {
41
45
  const {
@@ -43,53 +47,92 @@ export class DocumentChunker {
43
47
  chunkOverlap = this.chunkOverlap,
44
48
  docId = `doc_${Date.now()}`,
45
49
  metadata = {},
50
+ separators = this.separators,
46
51
  } = options;
47
52
 
48
- // Normalise whitespace
49
- const cleaned = text.replace(/\r\n/g, '\n').trim();
50
- if (!cleaned) return [];
51
-
52
- // Split into sentences (handles . ! ? followed by space/newline)
53
- const sentences = cleaned
54
- .split(/(?<=[.!?])\s+|\n{2,}/)
55
- .map((s) => s.trim())
56
- .filter(Boolean);
57
-
58
- const chunks: Chunk[] = [];
59
- let current = '';
53
+ const finalChunks: Chunk[] = [];
54
+ const splits = this.recursiveSplit(text, separators, chunkSize);
55
+
56
+ let currentChunk: string[] = [];
57
+ let currentLength = 0;
60
58
  let chunkIndex = 0;
61
59
 
62
- for (const sentence of sentences) {
63
- if ((current + ' ' + sentence).trim().length <= chunkSize) {
64
- current = current ? `${current} ${sentence}` : sentence;
65
- } else {
66
- if (current) {
67
- chunks.push({
68
- id: `${docId}_chunk_${chunkIndex++}`,
69
- content: current.trim(),
70
- metadata: { ...metadata, docId, chunkIndex: chunkIndex - 1 },
71
- });
72
- }
60
+ for (const split of splits) {
61
+ if (currentLength + split.length > chunkSize && currentChunk.length > 0) {
62
+ // Current chunk is full, push it
63
+ finalChunks.push({
64
+ id: `${docId}_chunk_${chunkIndex++}`,
65
+ content: currentChunk.join('').trim(),
66
+ metadata: { ...metadata, docId, chunkIndex: chunkIndex - 1 },
67
+ });
73
68
 
74
- // Start new chunk with overlap from the previous one
75
- if (chunkOverlap > 0 && current.length > chunkOverlap) {
76
- current = current.slice(-chunkOverlap) + ' ' + sentence;
77
- } else {
78
- current = sentence;
69
+ // Start new chunk with overlap
70
+ // This is a simplified overlap: we keep items from the end of the previous chunk
71
+ // until we reach the overlap size.
72
+ const overlapItems = [];
73
+ let overlapLen = 0;
74
+ for (let i = currentChunk.length - 1; i >= 0; i--) {
75
+ if (overlapLen + currentChunk[i].length <= chunkOverlap) {
76
+ overlapItems.unshift(currentChunk[i]);
77
+ overlapLen += currentChunk[i].length;
78
+ } else {
79
+ break;
80
+ }
79
81
  }
82
+ currentChunk = overlapItems;
83
+ currentLength = overlapLen;
80
84
  }
85
+
86
+ currentChunk.push(split);
87
+ currentLength += split.length;
81
88
  }
82
89
 
83
90
  // Push the last remaining chunk
84
- if (current.trim()) {
85
- chunks.push({
91
+ if (currentChunk.length > 0) {
92
+ finalChunks.push({
86
93
  id: `${docId}_chunk_${chunkIndex}`,
87
- content: current.trim(),
94
+ content: currentChunk.join('').trim(),
88
95
  metadata: { ...metadata, docId, chunkIndex },
89
96
  });
90
97
  }
91
98
 
92
- return chunks;
99
+ return finalChunks;
100
+ }
101
+
102
+ /**
103
+ * Recursively split text based on separators.
104
+ */
105
+ private recursiveSplit(text: string, separators: string[], chunkSize: number): string[] {
106
+ const finalSplits: string[] = [];
107
+
108
+ // Find the first separator that exists in the text
109
+ let separator = separators[separators.length - 1]; // default to last (usually empty string)
110
+ let nextSeparators: string[] = [];
111
+
112
+ for (let i = 0; i < separators.length; i++) {
113
+ if (text.includes(separators[i])) {
114
+ separator = separators[i];
115
+ nextSeparators = separators.slice(i + 1);
116
+ break;
117
+ }
118
+ }
119
+
120
+ // Split the text
121
+ const parts = text.split(separator);
122
+
123
+ for (const part of parts) {
124
+ if (part.length <= chunkSize) {
125
+ finalSplits.push(part + separator);
126
+ } else if (nextSeparators.length > 0) {
127
+ // Part is too long, try splitting with next level separators
128
+ finalSplits.push(...this.recursiveSplit(part, nextSeparators, chunkSize));
129
+ } else {
130
+ // No more separators, just hard split (should not happen if empty string is the last separator)
131
+ finalSplits.push(part);
132
+ }
133
+ }
134
+
135
+ return finalSplits;
93
136
  }
94
137
 
95
138
  /**
@@ -0,0 +1,43 @@
1
+ import { ILLMProvider } from '../llm/ILLMProvider';
2
+ import { GraphNode, Edge } from '../types';
3
+
4
+ /**
5
+ * EntityExtractor — Uses an LLM to extract entities and relations from text.
6
+ */
7
+ export class EntityExtractor {
8
+ private llm: ILLMProvider;
9
+
10
+ constructor(llm: ILLMProvider) {
11
+ this.llm = llm;
12
+ }
13
+
14
+ /**
15
+ * Extract nodes and edges from a text chunk.
16
+ */
17
+ async extract(text: string): Promise<{ nodes: GraphNode[]; edges: Edge[] }> {
18
+ const prompt = `
19
+ Extract entities and relationships from the following text.
20
+ Format the output as JSON with two keys: "nodes" (array of {id, label, properties}) and "edges" (array of {source, target, type, properties}).
21
+ Use the same ID for the same entity.
22
+
23
+ Text:
24
+ "${text}"
25
+
26
+ Output JSON:
27
+ `;
28
+
29
+ const response = await this.llm.chat([
30
+ { role: 'system', content: 'You are an expert at knowledge graph extraction. Respond only with valid JSON.' },
31
+ { role: 'user', content: prompt }
32
+ ], '');
33
+
34
+ try {
35
+ // Clean up LLM response (sometimes it adds markdown blocks)
36
+ const cleanJson = response.replace(/```json\n?|\n?```/g, '').trim();
37
+ return JSON.parse(cleanJson);
38
+ } catch (error) {
39
+ console.warn('[EntityExtractor] Failed to parse LLM response as JSON:', response);
40
+ return { nodes: [], edges: [] };
41
+ }
42
+ }
43
+ }
@@ -21,4 +21,23 @@ export interface IngestDocument {
21
21
  export interface ChatResponse {
22
22
  reply: string;
23
23
  sources: VectorMatch[];
24
+ graphData?: GraphSearchResult;
25
+ }
26
+
27
+ export interface GraphNode {
28
+ id: string;
29
+ label: string;
30
+ properties?: Record<string, unknown>;
31
+ }
32
+
33
+ export interface Edge {
34
+ source: string;
35
+ target: string;
36
+ type: string;
37
+ properties?: Record<string, unknown>;
38
+ }
39
+
40
+ export interface GraphSearchResult {
41
+ nodes: GraphNode[];
42
+ edges: Edge[];
24
43
  }