@voltx/rag 0.1.0 → 0.3.0

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/dist/index.js CHANGED
@@ -21,20 +21,29 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  CharacterSplitter: () => CharacterSplitter,
24
+ JSONLoader: () => JSONLoader,
25
+ MDocument: () => MDocument,
26
+ MarkdownLoader: () => MarkdownLoader,
27
+ MarkdownSplitter: () => MarkdownSplitter,
24
28
  RAGPipeline: () => RAGPipeline,
29
+ RecursiveTextSplitter: () => RecursiveTextSplitter,
30
+ TextLoader: () => TextLoader,
25
31
  VERSION: () => VERSION,
32
+ WebLoader: () => WebLoader,
33
+ cosineSimilarity: () => cosineSimilarity,
34
+ createEmbedder: () => createEmbedder,
26
35
  createRAGPipeline: () => createRAGPipeline
27
36
  });
28
37
  module.exports = __toCommonJS(index_exports);
38
+
39
+ // src/splitters.ts
29
40
  var CharacterSplitter = class {
30
- constructor(chunkSize = 1e3, overlap = 200) {
31
- this.chunkSize = chunkSize;
32
- this.overlap = overlap;
41
+ chunkSize;
42
+ overlap;
43
+ constructor(options = {}) {
44
+ this.chunkSize = options.chunkSize ?? 1e3;
45
+ this.overlap = options.overlap ?? 200;
33
46
  }
34
- /**
35
- * Find the best split point near `pos` by looking for sentence/paragraph
36
- * boundaries first, then word boundaries, falling back to exact position.
37
- */
38
47
  findBreakPoint(text, pos) {
39
48
  if (pos >= text.length) return text.length;
40
49
  const searchStart = Math.max(0, pos - Math.floor(this.chunkSize * 0.2));
@@ -48,9 +57,7 @@ var CharacterSplitter = class {
48
57
  }
49
58
  }
50
59
  for (let i = pos; i >= searchStart; i--) {
51
- if (/\s/.test(text[i])) {
52
- return i + 1;
53
- }
60
+ if (/\s/.test(text[i])) return i + 1;
54
61
  }
55
62
  return pos;
56
63
  }
@@ -61,58 +68,503 @@ var CharacterSplitter = class {
61
68
  while (start < text.length) {
62
69
  const rawEnd = Math.min(start + this.chunkSize, text.length);
63
70
  const end = rawEnd >= text.length ? rawEnd : this.findBreakPoint(text, rawEnd);
64
- chunks.push({
65
- id: `chunk-${index++}`,
66
- content: text.slice(start, end).trim(),
67
- metadata: { start, end }
68
- });
71
+ const content = text.slice(start, end).trim();
72
+ if (content.length > 0) {
73
+ chunks.push({
74
+ id: `chunk-${index++}`,
75
+ content,
76
+ metadata: { start, end, splitter: "character" }
77
+ });
78
+ }
69
79
  const step = end - start - this.overlap;
70
80
  start += step > 0 ? step : end - start;
71
81
  }
82
+ return chunks;
83
+ }
84
+ };
85
+ var RecursiveTextSplitter = class {
86
+ chunkSize;
87
+ overlap;
88
+ separators;
89
+ constructor(options = {}) {
90
+ this.chunkSize = options.chunkSize ?? 1e3;
91
+ this.overlap = options.overlap ?? 200;
92
+ this.separators = options.separators ?? ["\n\n", "\n", ". ", " ", ""];
93
+ }
94
+ split(text) {
95
+ const rawChunks = this.splitText(text, this.separators);
96
+ const merged = this.mergeWithOverlap(rawChunks);
97
+ return merged.map((content, i) => ({
98
+ id: `chunk-${i}`,
99
+ content,
100
+ metadata: { index: i, splitter: "recursive" }
101
+ }));
102
+ }
103
+ /**
104
+ * Recursively split text. Try the first separator; if any resulting piece
105
+ * is still too large, recurse with the next separator in the list.
106
+ */
107
+ splitText(text, separators) {
108
+ const results = [];
109
+ let bestSep = "";
110
+ let bestIdx = 0;
111
+ for (let i = 0; i < separators.length; i++) {
112
+ const sep = separators[i];
113
+ if (sep === "") {
114
+ bestSep = sep;
115
+ bestIdx = i;
116
+ break;
117
+ }
118
+ if (text.includes(sep)) {
119
+ bestSep = sep;
120
+ bestIdx = i;
121
+ break;
122
+ }
123
+ }
124
+ const pieces = bestSep === "" ? [...text] : text.split(bestSep);
125
+ const remainingSeps = separators.slice(bestIdx + 1);
126
+ let current = "";
127
+ for (const piece of pieces) {
128
+ const candidate = current ? current + bestSep + piece : piece;
129
+ if (candidate.length <= this.chunkSize) {
130
+ current = candidate;
131
+ } else {
132
+ if (current.trim()) {
133
+ results.push(current.trim());
134
+ }
135
+ if (piece.length > this.chunkSize && remainingSeps.length > 0) {
136
+ const subChunks = this.splitText(piece, remainingSeps);
137
+ results.push(...subChunks);
138
+ current = "";
139
+ } else {
140
+ current = piece;
141
+ }
142
+ }
143
+ }
144
+ if (current.trim()) {
145
+ results.push(current.trim());
146
+ }
147
+ return results;
148
+ }
149
+ /**
150
+ * Merge chunks with overlap to maintain context between adjacent chunks.
151
+ */
152
+ mergeWithOverlap(chunks) {
153
+ if (this.overlap <= 0 || chunks.length <= 1) return chunks;
154
+ const result = [];
155
+ for (let i = 0; i < chunks.length; i++) {
156
+ if (i === 0) {
157
+ result.push(chunks[i]);
158
+ } else {
159
+ const prev = chunks[i - 1];
160
+ const overlapText = prev.slice(-this.overlap);
161
+ const spaceIdx = overlapText.indexOf(" ");
162
+ const cleanOverlap = spaceIdx >= 0 ? overlapText.slice(spaceIdx + 1) : overlapText;
163
+ result.push((cleanOverlap + " " + chunks[i]).trim());
164
+ }
165
+ }
166
+ return result;
167
+ }
168
+ };
169
+ var MarkdownSplitter = class {
170
+ chunkSize;
171
+ overlap;
172
+ includeHeaders;
173
+ constructor(options = {}) {
174
+ this.chunkSize = options.chunkSize ?? 1500;
175
+ this.overlap = options.overlap ?? 100;
176
+ this.includeHeaders = options.includeHeaders ?? true;
177
+ }
178
+ split(text) {
179
+ const sections = this.splitByHeadings(text);
180
+ const chunks = [];
181
+ let index = 0;
182
+ const fallback = new RecursiveTextSplitter({
183
+ chunkSize: this.chunkSize,
184
+ overlap: this.overlap
185
+ });
186
+ for (const section of sections) {
187
+ if (section.content.length <= this.chunkSize) {
188
+ chunks.push({
189
+ id: `chunk-${index++}`,
190
+ content: section.content.trim(),
191
+ metadata: {
192
+ ...section.headers,
193
+ splitter: "markdown"
194
+ }
195
+ });
196
+ } else {
197
+ const subChunks = fallback.split(section.content);
198
+ for (const sub of subChunks) {
199
+ chunks.push({
200
+ id: `chunk-${index++}`,
201
+ content: sub.content.trim(),
202
+ metadata: {
203
+ ...section.headers,
204
+ ...sub.metadata,
205
+ splitter: "markdown"
206
+ }
207
+ });
208
+ }
209
+ }
210
+ }
72
211
  return chunks.filter((c) => c.content.length > 0);
73
212
  }
213
+ splitByHeadings(text) {
214
+ const lines = text.split("\n");
215
+ const sections = [];
216
+ const headerStack = {};
217
+ let currentContent = "";
218
+ for (const line of lines) {
219
+ const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
220
+ if (headerMatch) {
221
+ if (currentContent.trim()) {
222
+ sections.push({
223
+ content: this.includeHeaders ? this.buildHeaderPrefix(headerStack) + currentContent.trim() : currentContent.trim(),
224
+ headers: { ...headerStack }
225
+ });
226
+ }
227
+ const level = headerMatch[1].length;
228
+ const title = headerMatch[2].trim();
229
+ for (let i = level; i <= 6; i++) {
230
+ delete headerStack[`h${i}`];
231
+ }
232
+ headerStack[`h${level}`] = title;
233
+ currentContent = "";
234
+ } else {
235
+ currentContent += line + "\n";
236
+ }
237
+ }
238
+ if (currentContent.trim()) {
239
+ sections.push({
240
+ content: this.includeHeaders ? this.buildHeaderPrefix(headerStack) + currentContent.trim() : currentContent.trim(),
241
+ headers: { ...headerStack }
242
+ });
243
+ }
244
+ return sections;
245
+ }
246
+ buildHeaderPrefix(headers) {
247
+ const parts = [];
248
+ for (let i = 1; i <= 6; i++) {
249
+ const h = headers[`h${i}`];
250
+ if (h) parts.push(h);
251
+ }
252
+ return parts.length > 0 ? parts.join(" > ") + "\n\n" : "";
253
+ }
254
+ };
255
+
256
+ // src/loaders.ts
257
+ var import_promises = require("fs/promises");
258
+ var import_node_fs = require("fs");
259
+ var TextLoader = class {
260
+ name = "text";
261
+ async load(source) {
262
+ if ((0, import_node_fs.existsSync)(source)) {
263
+ return (0, import_promises.readFile)(source, "utf-8");
264
+ }
265
+ return source;
266
+ }
267
+ };
268
+ var MarkdownLoader = class {
269
+ name = "markdown";
270
+ async load(source) {
271
+ let text;
272
+ if ((0, import_node_fs.existsSync)(source)) {
273
+ text = await (0, import_promises.readFile)(source, "utf-8");
274
+ } else {
275
+ text = source;
276
+ }
277
+ const frontMatterRegex = /^---\s*\n[\s\S]*?\n---\s*\n/;
278
+ return text.replace(frontMatterRegex, "").trim();
279
+ }
280
+ };
281
+ var JSONLoader = class {
282
+ name = "json";
283
+ textKeys;
284
+ separator;
285
+ constructor(options = {}) {
286
+ this.textKeys = options.textKeys ?? ["content", "text", "body", "description"];
287
+ this.separator = options.separator ?? "\n\n";
288
+ }
289
+ async load(source) {
290
+ let raw;
291
+ if ((0, import_node_fs.existsSync)(source)) {
292
+ raw = await (0, import_promises.readFile)(source, "utf-8");
293
+ } else {
294
+ raw = source;
295
+ }
296
+ const data = JSON.parse(raw);
297
+ const texts = this.extractTexts(data);
298
+ return texts.join(this.separator);
299
+ }
300
+ extractTexts(data) {
301
+ const results = [];
302
+ if (Array.isArray(data)) {
303
+ for (const item of data) {
304
+ results.push(...this.extractTexts(item));
305
+ }
306
+ } else if (data !== null && typeof data === "object") {
307
+ const obj = data;
308
+ for (const key of this.textKeys) {
309
+ if (key in obj && typeof obj[key] === "string") {
310
+ results.push(obj[key]);
311
+ }
312
+ }
313
+ if (results.length === 0) {
314
+ for (const value of Object.values(obj)) {
315
+ if (typeof value === "string" && value.length > 20) {
316
+ results.push(value);
317
+ } else if (typeof value === "object" && value !== null) {
318
+ results.push(...this.extractTexts(value));
319
+ }
320
+ }
321
+ }
322
+ } else if (typeof data === "string") {
323
+ results.push(data);
324
+ }
325
+ return results;
326
+ }
327
+ };
328
+ var WebLoader = class {
329
+ name = "web";
330
+ async load(source) {
331
+ const response = await fetch(source);
332
+ if (!response.ok) {
333
+ throw new Error(`[voltx/rag] WebLoader failed to fetch ${source}: ${response.status}`);
334
+ }
335
+ const contentType = response.headers.get("content-type") ?? "";
336
+ const text = await response.text();
337
+ if (contentType.includes("text/html")) {
338
+ return this.stripHTML(text);
339
+ }
340
+ return text;
341
+ }
342
+ stripHTML(html) {
343
+ return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
344
+ }
345
+ };
346
+
347
+ // src/embedder.ts
348
+ var import_ai = require("@voltx/ai");
349
+ function createEmbedder(config) {
350
+ const { model } = config;
351
+ return {
352
+ name: `voltx-embedder:${model}`,
353
+ async embed(text) {
354
+ const result = await (0, import_ai.embed)({ model, value: text });
355
+ return result.embedding;
356
+ },
357
+ async embedBatch(texts) {
358
+ if (texts.length === 0) return [];
359
+ if (texts.length === 1) {
360
+ const result2 = await (0, import_ai.embed)({ model, value: texts[0] });
361
+ return [result2.embedding];
362
+ }
363
+ const result = await (0, import_ai.embedMany)({ model, values: texts });
364
+ return result.embeddings;
365
+ }
366
+ };
367
+ }
368
+
369
+ // src/document.ts
370
+ var MDocument = class _MDocument {
371
+ content;
372
+ format;
373
+ chunks = null;
374
+ constructor(content, format) {
375
+ this.content = content;
376
+ this.format = format;
377
+ }
378
+ /** Create from plain text */
379
+ static fromText(content) {
380
+ return new _MDocument(content, "text");
381
+ }
382
+ /** Create from markdown */
383
+ static fromMarkdown(content) {
384
+ return new _MDocument(content, "markdown");
385
+ }
386
+ /** Create from JSON string */
387
+ static fromJSON(content) {
388
+ JSON.parse(content);
389
+ return new _MDocument(content, "json");
390
+ }
391
+ /** Create from HTML (strips tags) */
392
+ static fromHTML(html) {
393
+ const text = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
394
+ return new _MDocument(text, "html");
395
+ }
396
+ /** Get the raw content */
397
+ getContent() {
398
+ return this.content;
399
+ }
400
+ /** Get the document format */
401
+ getFormat() {
402
+ return this.format;
403
+ }
404
+ /**
405
+ * Split the document into chunks using the specified strategy.
406
+ * Returns the chunks and caches them for subsequent embed() calls.
407
+ */
408
+ chunk(options = {}) {
409
+ const strategy = options.strategy ?? this.defaultStrategy();
410
+ const splitter = this.createSplitter(strategy, options);
411
+ this.chunks = splitter.split(this.content);
412
+ return this.chunks;
413
+ }
414
+ /**
415
+ * Embed the chunks using the provided embedder.
416
+ * Must call chunk() first.
417
+ */
418
+ async embed(embedder) {
419
+ if (!this.chunks) {
420
+ throw new Error("[voltx/rag] Call chunk() before embed()");
421
+ }
422
+ const texts = this.chunks.map((c) => c.content);
423
+ const embeddings = await embedder.embedBatch(texts);
424
+ for (let i = 0; i < this.chunks.length; i++) {
425
+ this.chunks[i].embedding = embeddings[i];
426
+ }
427
+ return this.chunks;
428
+ }
429
+ /** Get cached chunks (null if chunk() hasn't been called) */
430
+ getChunks() {
431
+ return this.chunks;
432
+ }
433
+ defaultStrategy() {
434
+ if (this.format === "markdown") return "markdown";
435
+ return "recursive";
436
+ }
437
+ createSplitter(strategy, options) {
438
+ switch (strategy) {
439
+ case "markdown":
440
+ return new MarkdownSplitter({
441
+ chunkSize: options.chunkSize,
442
+ overlap: options.overlap,
443
+ includeHeaders: options.includeHeaders
444
+ });
445
+ case "character":
446
+ return new CharacterSplitter({
447
+ chunkSize: options.chunkSize,
448
+ overlap: options.overlap
449
+ });
450
+ case "recursive":
451
+ default:
452
+ return new RecursiveTextSplitter({
453
+ chunkSize: options.chunkSize,
454
+ overlap: options.overlap,
455
+ separators: options.separators
456
+ });
457
+ }
458
+ }
74
459
  };
460
+
461
+ // src/utils.ts
462
+ function cosineSimilarity(a, b) {
463
+ if (a.length !== b.length) {
464
+ throw new Error(
465
+ `[voltx/rag] Vector dimension mismatch: ${a.length} vs ${b.length}`
466
+ );
467
+ }
468
+ let dotProduct = 0;
469
+ let normA = 0;
470
+ let normB = 0;
471
+ for (let i = 0; i < a.length; i++) {
472
+ dotProduct += a[i] * b[i];
473
+ normA += a[i] * a[i];
474
+ normB += b[i] * b[i];
475
+ }
476
+ const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
477
+ if (magnitude === 0) return 0;
478
+ return dotProduct / magnitude;
479
+ }
480
+
481
+ // src/index.ts
75
482
  var RAGPipeline = class {
76
- config;
483
+ loader;
484
+ splitter;
485
+ embedder;
486
+ vectorStore;
77
487
  constructor(config) {
78
- this.config = config;
79
- }
80
- /** Ingest a document: load → split → embed → store */
81
- async ingest(source) {
82
- const { loader, splitter = new CharacterSplitter(), embedder, vectorStore } = this.config;
83
- const text = loader ? await loader.load(source) : source;
84
- const chunks = splitter.split(text);
85
- const docs = [];
86
- for (const chunk of chunks) {
87
- const embedding = await embedder.embed(chunk.content);
88
- docs.push({
89
- id: chunk.id,
90
- content: chunk.content,
91
- embedding,
92
- metadata: chunk.metadata
93
- });
94
- }
95
- await vectorStore.upsert(docs);
96
- return docs.length;
488
+ this.loader = config.loader;
489
+ this.splitter = config.splitter ?? new RecursiveTextSplitter();
490
+ this.embedder = config.embedder;
491
+ this.vectorStore = config.vectorStore;
97
492
  }
98
- /** Query: embed question → search vector store → return sources */
99
- async query(question, topK = 5) {
100
- const { embedder, vectorStore } = this.config;
101
- const embedding = await embedder.embed(question);
102
- const results = await vectorStore.search(embedding, topK);
493
+ /**
494
+ * Ingest a document: load → split → embed (batch) → store in vector DB.
495
+ *
496
+ * @param source - File path, URL, or raw text (depends on loader)
497
+ * @param idPrefix - Optional prefix for chunk IDs (default: "doc")
498
+ * @returns Number of chunks ingested and their IDs
499
+ */
500
+ async ingest(source, idPrefix = "doc") {
501
+ const text = this.loader ? await this.loader.load(source) : source;
502
+ const chunks = this.splitter.split(text);
503
+ const texts = chunks.map((c) => c.content);
504
+ const embeddings = await this.embedder.embedBatch(texts);
505
+ const docs = chunks.map((chunk, i) => ({
506
+ id: `${idPrefix}-${chunk.id}`,
507
+ content: chunk.content,
508
+ embedding: embeddings[i],
509
+ metadata: chunk.metadata
510
+ }));
511
+ await this.vectorStore.upsert(docs);
103
512
  return {
104
- sources: results.map((r) => r.document)
513
+ chunks: docs.length,
514
+ ids: docs.map((d) => d.id)
105
515
  };
106
516
  }
517
+ /**
518
+ * Query: embed question → search vector store → return ranked sources.
519
+ *
520
+ * @param question - The user's question
521
+ * @param options - Query options (topK, minScore)
522
+ */
523
+ async query(question, options = {}) {
524
+ const { topK = 5, minScore = 0 } = options;
525
+ const queryEmbedding = await this.embedder.embed(question);
526
+ const results = await this.vectorStore.search(queryEmbedding, topK);
527
+ const filtered = minScore > 0 ? results.filter((r) => r.score >= minScore) : results;
528
+ return {
529
+ sources: filtered.map((r) => r.document),
530
+ queryEmbedding
531
+ };
532
+ }
533
+ /**
534
+ * Convenience: query + format sources into a context string for LLM prompts.
535
+ */
536
+ async getContext(question, options = {}) {
537
+ const { sources } = await this.query(question, options);
538
+ if (sources.length === 0) {
539
+ return "No relevant context found.";
540
+ }
541
+ return sources.map((s, i) => `[Source ${i + 1}]
542
+ ${s.content}`).join("\n\n---\n\n");
543
+ }
544
+ /**
545
+ * Delete documents from the vector store by IDs.
546
+ */
547
+ async delete(ids) {
548
+ await this.vectorStore.delete(ids);
549
+ }
107
550
  };
108
551
  function createRAGPipeline(config) {
109
552
  return new RAGPipeline(config);
110
553
  }
111
- var VERSION = "0.1.0";
554
+ var VERSION = "0.3.0";
112
555
  // Annotate the CommonJS export names for ESM import in node:
113
556
  0 && (module.exports = {
114
557
  CharacterSplitter,
558
+ JSONLoader,
559
+ MDocument,
560
+ MarkdownLoader,
561
+ MarkdownSplitter,
115
562
  RAGPipeline,
563
+ RecursiveTextSplitter,
564
+ TextLoader,
116
565
  VERSION,
566
+ WebLoader,
567
+ cosineSimilarity,
568
+ createEmbedder,
117
569
  createRAGPipeline
118
570
  });