@juspay/neurolink 12.14.20 → 12.15.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/browser/neurolink.min.js +386 -386
  3. package/dist/core/baseProvider.d.ts +6 -3
  4. package/dist/core/baseProvider.js +7 -3
  5. package/dist/providers/amazonBedrock/client.d.ts +7 -5
  6. package/dist/providers/amazonBedrock/client.js +125 -16
  7. package/dist/providers/cohere.d.ts +2 -2
  8. package/dist/providers/cohere.js +5 -1
  9. package/dist/providers/googleAiStudio/client.d.ts +3 -3
  10. package/dist/providers/googleAiStudio/client.js +6 -2
  11. package/dist/providers/googleVertex/client.d.ts +2 -2
  12. package/dist/providers/googleVertex/client.js +5 -1
  13. package/dist/providers/jina.d.ts +2 -2
  14. package/dist/providers/jina.js +5 -1
  15. package/dist/providers/litellm/client.d.ts +2 -2
  16. package/dist/providers/litellm/client.js +5 -1
  17. package/dist/providers/ollama/client.d.ts +2 -2
  18. package/dist/providers/ollama/client.js +5 -1
  19. package/dist/providers/openAI/client.d.ts +3 -3
  20. package/dist/providers/openAI/client.js +6 -2
  21. package/dist/providers/voyage.d.ts +2 -2
  22. package/dist/providers/voyage.js +5 -1
  23. package/dist/rag/document/MDocument.d.ts +19 -1
  24. package/dist/rag/document/MDocument.js +30 -3
  25. package/dist/rag/document/imageLoader.d.ts +55 -0
  26. package/dist/rag/document/imageLoader.js +183 -0
  27. package/dist/rag/index.d.ts +1 -0
  28. package/dist/rag/index.js +2 -0
  29. package/dist/rag/pipeline/RAGPipeline.d.ts +54 -1
  30. package/dist/rag/pipeline/RAGPipeline.js +358 -1
  31. package/dist/rag/ragIntegration.js +146 -25
  32. package/dist/types/generate.d.ts +9 -0
  33. package/dist/types/providers.d.ts +21 -1
  34. package/dist/types/rag.d.ts +144 -0
  35. package/dist/types/rag.js +12 -1
  36. package/dist/utils/fileDetector.d.ts +4 -0
  37. package/dist/utils/fileDetector.js +65 -52
  38. package/dist/utils/messageBuilder.js +143 -76
  39. package/package.json +2 -1
@@ -33,9 +33,13 @@ import { InMemoryBM25Index, createHybridSearch, } from "../retrieval/hybridSearc
33
33
  import { GraphRAG } from "../graphRag/graphRAG.js";
34
34
  import { rerank } from "../reranker/reranker.js";
35
35
  import { ProviderFactory } from "../../factories/providerFactory.js";
36
+ import { ImageLoader } from "../document/imageLoader.js";
37
+ import { ImageProcessor } from "../../utils/imageProcessor.js";
38
+ import { redactUrlForError } from "../../utils/logSanitize.js";
36
39
  import { SpanSerializer, SpanType, SpanStatus, getMetricsAggregator, } from "../../observability/index.js";
37
40
  import { logger } from "../../utils/logger.js";
38
41
  import { withTimeout } from "../../utils/async/withTimeout.js";
42
+ import { ErrorFactory } from "../../utils/errorHandling.js";
39
43
  /**
40
44
  * RAG Pipeline Orchestrator
41
45
  *
@@ -43,6 +47,12 @@ import { withTimeout } from "../../utils/async/withTimeout.js";
43
47
  */
44
48
  /** Default timeout for external provider calls (30 seconds) */
45
49
  const DEFAULT_TIMEOUT_MS = 30_000;
50
+ /**
51
+ * Runtime-validating guard for {@link UpsertableVectorStore}.
52
+ */
53
+ function isUpsertableVectorStore(store) {
54
+ return "upsert" in store;
55
+ }
46
56
  export class RAGPipeline {
47
57
  id;
48
58
  config;
@@ -50,10 +60,21 @@ export class RAGPipeline {
50
60
  bm25Index;
51
61
  graphRAG;
52
62
  embeddingProvider;
63
+ multiModalEmbeddingProvider;
53
64
  generationProvider;
65
+ /**
66
+ * Vision provider for image captioning, when `multiModal.captionProvider` /
67
+ * `captionModel` name one. Undefined means captioning uses
68
+ * {@link generationProvider} — captioning has always run on that provider,
69
+ * and these two fields were declared but read nowhere, so a caller who set
70
+ * them got the pipeline's generation model with no indication otherwise.
71
+ */
72
+ captionProvider;
54
73
  hybridSearch;
55
74
  documents = new Map();
56
75
  allChunks = [];
76
+ multiModalConfig;
77
+ imageLoader;
57
78
  constructor(config) {
58
79
  this.id = config.id || `rag-pipeline-${randomUUID().slice(0, 8)}`;
59
80
  this.config = {
@@ -68,14 +89,23 @@ export class RAGPipeline {
68
89
  enableReranking: false,
69
90
  ...config,
70
91
  };
92
+ this.multiModalConfig = config.multiModal;
93
+ this.imageLoader = new ImageLoader({
94
+ maxImageSize: config.multiModal?.maxImageSize,
95
+ });
71
96
  // Initialize stores
72
97
  this.vectorStore = config.vectorStore || new InMemoryVectorStore();
98
+ if (config.multiModal?.enabled &&
99
+ !isUpsertableVectorStore(this.vectorStore)) {
100
+ throw ErrorFactory.invalidConfiguration("vectorStore", "multi-modal RAG requires a vector store that supports upsert()");
101
+ }
73
102
  this.bm25Index = config.bm25Index || new InMemoryBM25Index();
74
103
  this.graphRAG = new GraphRAG({ threshold: this.config.graphThreshold });
75
104
  logger.info("[RAGPipeline] Pipeline initialized", {
76
105
  id: this.id,
77
106
  indexName: this.config.indexName,
78
107
  embeddingModel: this.config.embeddingModel,
108
+ multiModalEnabled: config.multiModal?.enabled ?? false,
79
109
  });
80
110
  }
81
111
  /**
@@ -88,6 +118,15 @@ export class RAGPipeline {
88
118
  if (this.config.generationModel) {
89
119
  this.generationProvider = await ProviderFactory.createProvider(this.config.generationModel.provider, this.config.generationModel.modelName);
90
120
  }
121
+ // A caption model only makes sense alongside the caption strategy, but it
122
+ // is built whenever named rather than gated on the strategy: the strategy
123
+ // is read per-ingest and can differ from the one set at construction.
124
+ const captionModelName = this.multiModalConfig?.captionModel;
125
+ if (captionModelName) {
126
+ this.captionProvider = await ProviderFactory.createProvider(this.multiModalConfig?.captionProvider ??
127
+ this.config.generationModel?.provider ??
128
+ this.config.embeddingModel.provider, captionModelName);
129
+ }
91
130
  // Initialize hybrid search if enabled
92
131
  if (this.config.enableHybridSearch) {
93
132
  this.hybridSearch = createHybridSearch({
@@ -146,7 +185,7 @@ export class RAGPipeline {
146
185
  topK: 1,
147
186
  }); // Warm up
148
187
  // Upsert into vector store
149
- if ("upsert" in this.vectorStore) {
188
+ if (isUpsertableVectorStore(this.vectorStore)) {
150
189
  await this.vectorStore.upsert(this.config.indexName ?? "default", chunks.map((chunk, i) => ({
151
190
  id: chunk.id,
152
191
  vector: embeddings[i],
@@ -354,8 +393,325 @@ export class RAGPipeline {
354
393
  logger.info("[RAGPipeline] Pipeline cleared", { id: this.id });
355
394
  }
356
395
  // ============================================================================
396
+ // Multi-Modal Methods
397
+ // ============================================================================
398
+ /**
399
+ * Ingest images into the pipeline for multi-modal RAG.
400
+ * Loads images, generates embeddings via the configured multi-modal provider,
401
+ * and stores them alongside text chunks in the vector store.
402
+ *
403
+ * @param sources - Array of image file paths, URLs, or Buffer objects
404
+ * @param options - Ingestion options
405
+ */
406
+ async ingestImages(sources, options) {
407
+ if (!this.multiModalConfig?.enabled) {
408
+ throw ErrorFactory.invalidConfiguration("multiModal", "multi-modal RAG is not enabled; pass a multiModal config with enabled: true");
409
+ }
410
+ await this.ensureInitialized();
411
+ let imagesProcessed = 0;
412
+ let chunksCreated = 0;
413
+ for (const source of sources) {
414
+ try {
415
+ // Load the image
416
+ let imageDoc;
417
+ if (Buffer.isBuffer(source)) {
418
+ const detectedMimeType = ImageProcessor.detectImageType(source);
419
+ if (detectedMimeType === "application/octet-stream") {
420
+ throw new Error("Could not detect image type from buffer contents; unsupported image data");
421
+ }
422
+ imageDoc = this.imageLoader.loadFromBuffer(source, detectedMimeType, `inline-${imagesProcessed}`);
423
+ }
424
+ else {
425
+ imageDoc = await this.imageLoader.load(source);
426
+ }
427
+ const { supportedFormats } = this.multiModalConfig;
428
+ if (supportedFormats && !supportedFormats.includes(imageDoc.mimeType)) {
429
+ throw ErrorFactory.invalidParameters("ingestImages", new Error(`unsupported image MIME type: ${imageDoc.mimeType}`), { mimeType: imageDoc.mimeType, supportedFormats });
430
+ }
431
+ // Generate text representation for the image
432
+ let imageText = imageDoc.text;
433
+ if (this.multiModalConfig.imageTextStrategy === "caption" &&
434
+ (this.captionProvider || this.generationProvider)) {
435
+ imageText = await this.generateImageCaption(imageDoc.image, imageDoc.mimeType);
436
+ }
437
+ else if (this.multiModalConfig.imageTextStrategy === "filename") {
438
+ imageText = redactUrlForError(imageDoc.metadata.source);
439
+ }
440
+ else if (this.multiModalConfig.imageTextStrategy === "none") {
441
+ imageText = "";
442
+ }
443
+ // Generate embedding using the configured multi-modal provider
444
+ const embedding = await this.generateMultiModalEmbedding({
445
+ text: imageText,
446
+ image: imageDoc.image,
447
+ mimeType: imageDoc.mimeType,
448
+ });
449
+ // `IngestOptions.metadata` is a loose record, so a caller's `custom`
450
+ // arrives as `unknown`. Narrowed rather than asserted: a caller that
451
+ // puts a string there should not silently produce a metadata object
452
+ // with `hasImage` spread across its characters.
453
+ const callerCustom = options?.metadata?.custom;
454
+ const inheritedCustom = typeof callerCustom === "object" && callerCustom !== null
455
+ ? callerCustom
456
+ : {};
457
+ // Create a chunk from the image document
458
+ const chunk = {
459
+ id: `img-chunk-${randomUUID().slice(0, 8)}`,
460
+ text: imageText,
461
+ metadata: {
462
+ // Caller metadata first, pipeline-owned fields after. Spread last,
463
+ // a caller passing `custom` replaced the whole object and removed
464
+ // `hasImage` — the one key the retrieval path checks.
465
+ ...options?.metadata,
466
+ documentId: `img-${randomUUID().slice(0, 8)}`,
467
+ chunkIndex: 0,
468
+ startPosition: 0,
469
+ endPosition: imageDoc.image.byteLength,
470
+ source: imageDoc.metadata.source,
471
+ custom: { ...inheritedCustom, hasImage: true },
472
+ },
473
+ embedding,
474
+ image: imageDoc.image,
475
+ imageMimeType: imageDoc.mimeType,
476
+ imageMeta: {
477
+ width: imageDoc.metadata.width,
478
+ height: imageDoc.metadata.height,
479
+ format: imageDoc.metadata.format,
480
+ source: imageDoc.metadata.source,
481
+ hasImage: true,
482
+ },
483
+ };
484
+ // Store in vector store
485
+ if (isUpsertableVectorStore(this.vectorStore)) {
486
+ await this.vectorStore.upsert(this.config.indexName ?? "default", [
487
+ {
488
+ id: chunk.id,
489
+ vector: embedding,
490
+ // Derived from chunk.metadata, as the text path does, rather
491
+ // than hand-listed. Hand-listing is what caused the two paths to
492
+ // diverge: caller metadata never reached the image records, so a
493
+ // filtered query silently returned no images while the identical
494
+ // call worked for text.
495
+ metadata: {
496
+ ...chunk.metadata,
497
+ text: imageText,
498
+ source: imageDoc.metadata.source,
499
+ hasImage: true,
500
+ mimeType: imageDoc.mimeType,
501
+ },
502
+ },
503
+ ]);
504
+ }
505
+ // Add to BM25 index with text description. An empty text
506
+ // representation ("none" strategy) is not indexable and would skew
507
+ // the average document length used by BM25 scoring.
508
+ if (imageText.length > 0) {
509
+ await this.bm25Index.addDocuments([
510
+ {
511
+ id: chunk.id,
512
+ text: imageText,
513
+ metadata: chunk.metadata,
514
+ },
515
+ ]);
516
+ }
517
+ // Retained without the decoded image. `allChunks` is only emptied by
518
+ // clear(), so keeping the buffer means the heap grows by the full size
519
+ // of every image ever ingested, for the life of the process —
520
+ // `maxImageSize` bounds one image, nothing bounds the sum. Nothing
521
+ // reads it back: createGraph uses text/metadata/embedding, and
522
+ // getMultiModalStats filters on imageMeta.hasImage.
523
+ const { image: _retainedImage, ...chunkWithoutImage } = chunk;
524
+ this.allChunks.push(chunkWithoutImage);
525
+ imagesProcessed++;
526
+ chunksCreated++;
527
+ logger.debug("[RAGPipeline] Image ingested", {
528
+ source: redactUrlForError(imageDoc.metadata.source),
529
+ textLength: imageText.length,
530
+ embeddingDimension: embedding.length,
531
+ });
532
+ }
533
+ catch (error) {
534
+ logger.error("[RAGPipeline] Failed to ingest image", {
535
+ source: typeof source === "string" ? redactUrlForError(source) : "buffer",
536
+ error: error instanceof Error ? error.message : String(error),
537
+ });
538
+ }
539
+ }
540
+ logger.info("[RAGPipeline] Image ingestion complete", {
541
+ imagesProcessed,
542
+ chunksCreated,
543
+ });
544
+ return { imagesProcessed, chunksCreated };
545
+ }
546
+ /**
547
+ * Query the pipeline with multi-modal input (text, image, or both).
548
+ *
549
+ * @param query - Text, image, or combined query
550
+ * @param options - Query options
551
+ * @returns Array of multi-modal search results
552
+ */
553
+ async queryMultiModal(query, options) {
554
+ if (!this.multiModalConfig?.enabled) {
555
+ throw ErrorFactory.invalidConfiguration("multiModal", "multi-modal RAG is not enabled; pass a multiModal config with enabled: true");
556
+ }
557
+ await this.ensureInitialized();
558
+ const topK = options?.topK || this.config.defaultTopK || 5;
559
+ // Generate query embedding
560
+ const queryEmbedding = await this.generateMultiModalEmbedding(query);
561
+ const queryHasImage = query.image !== undefined;
562
+ // Search vector store
563
+ const results = await this.vectorStore.query({
564
+ indexName: this.config.indexName ?? "default",
565
+ queryVector: queryEmbedding,
566
+ topK: topK * 2, // get extra for diversity
567
+ filter: options?.filter,
568
+ });
569
+ // Map results to multi-modal search results
570
+ const searchResults = results.map((r) => {
571
+ const resultHasImage = r.metadata?.hasImage ?? false;
572
+ // Derive match type from BOTH query and result modality so all four
573
+ // combinations are emitted (text-query→image-result is "text-to-image",
574
+ // image-query→text-result is "image-to-text", etc.).
575
+ const resultMatchType = resultHasImage
576
+ ? queryHasImage
577
+ ? "image-to-image"
578
+ : "text-to-image"
579
+ : queryHasImage
580
+ ? "image-to-text"
581
+ : "text-to-text";
582
+ return {
583
+ chunk: {
584
+ id: r.id,
585
+ text: r.text || r.metadata?.text || "",
586
+ metadata: {
587
+ documentId: r.id,
588
+ chunkIndex: 0,
589
+ startPosition: 0,
590
+ endPosition: 0,
591
+ source: r.metadata?.source || "",
592
+ },
593
+ image: undefined, // images not stored in vector store metadata
594
+ imageMimeType: r.metadata?.mimeType || undefined,
595
+ imageMeta: {
596
+ hasImage: resultHasImage,
597
+ source: r.metadata?.source || "",
598
+ },
599
+ },
600
+ score: r.score || 0,
601
+ matchType: resultMatchType,
602
+ };
603
+ });
604
+ // Apply diversity across match types
605
+ const diversified = this.diversifyMultiModalResults(searchResults, topK);
606
+ logger.info("[RAGPipeline] Multi-modal query completed", {
607
+ queryType: query.image ? (query.text ? "combined" : "image") : "text",
608
+ resultsCount: diversified.length,
609
+ topScore: diversified.at(0)?.score,
610
+ });
611
+ return diversified;
612
+ }
613
+ /**
614
+ * Get multi-modal pipeline statistics
615
+ */
616
+ getMultiModalStats() {
617
+ const imageChunks = this.allChunks.filter((c) => "imageMeta" in c && c.imageMeta?.hasImage);
618
+ return {
619
+ totalImages: imageChunks.length,
620
+ totalTextChunks: this.allChunks.length - imageChunks.length,
621
+ multiModalEnabled: this.multiModalConfig?.enabled ?? false,
622
+ };
623
+ }
624
+ // ============================================================================
357
625
  // Private Methods
358
626
  // ============================================================================
627
+ /**
628
+ * Generate a multi-modal embedding for text, image, or both.
629
+ * Uses the configured multi-modal embedding provider (e.g. Bedrock Titan Image).
630
+ */
631
+ async generateMultiModalEmbedding(input) {
632
+ if (!this.multiModalConfig?.embeddingModel) {
633
+ throw ErrorFactory.missingConfiguration("multiModal.embeddingModel");
634
+ }
635
+ const { provider, modelName } = this.multiModalConfig.embeddingModel;
636
+ // Build the embed input
637
+ const embedInput = {};
638
+ if (input.text) {
639
+ embedInput.text = input.text;
640
+ }
641
+ if (input.image) {
642
+ embedInput.image = input.image;
643
+ }
644
+ if (input.mimeType) {
645
+ embedInput.mimeType = input.mimeType;
646
+ }
647
+ if (embedInput.text === undefined && embedInput.image === undefined) {
648
+ throw ErrorFactory.invalidParameters("generateMultiModalEmbedding", new Error("Multi-modal embedding input requires text, image, or both"), {
649
+ hasText: input.text !== undefined,
650
+ hasImage: input.image !== undefined,
651
+ });
652
+ }
653
+ // Cache the provider so repeated ingest/query calls don't re-create it
654
+ if (!this.multiModalEmbeddingProvider) {
655
+ const providerInstance = await ProviderFactory.createProvider(provider, modelName);
656
+ if (typeof providerInstance.embed !== "function") {
657
+ throw ErrorFactory.invalidConfiguration("multiModal.embeddingModel.provider", `${provider} does not support embeddings`);
658
+ }
659
+ this.multiModalEmbeddingProvider = providerInstance;
660
+ }
661
+ return await withTimeout(this.multiModalEmbeddingProvider.embed(embedInput), DEFAULT_TIMEOUT_MS, "Multi-modal embedding generation timed out");
662
+ }
663
+ /**
664
+ * Generate a caption for an image using the generation provider's vision model.
665
+ */
666
+ async generateImageCaption(image, mimeType) {
667
+ const provider = this.captionProvider ?? this.generationProvider;
668
+ if (!provider) {
669
+ return "Image";
670
+ }
671
+ try {
672
+ const base64 = image.toString("base64");
673
+ const dataUri = `data:${mimeType};base64,${base64}`;
674
+ const result = await withTimeout(provider.generate({
675
+ prompt: "Describe this image in one sentence for use as a search document. Be specific about the content.",
676
+ systemPrompt: "You are an image captioning assistant. Provide concise, descriptive captions.",
677
+ input: { text: "", images: [dataUri] },
678
+ maxTokens: 100,
679
+ }), DEFAULT_TIMEOUT_MS, "Image captioning timed out");
680
+ return result?.content || "Image";
681
+ }
682
+ catch (error) {
683
+ logger.warn("[RAGPipeline] Image captioning failed, using fallback", {
684
+ error: error instanceof Error ? error.message : String(error),
685
+ });
686
+ return "Image";
687
+ }
688
+ }
689
+ /**
690
+ * Diversify multi-modal results to ensure a mix of text and image results.
691
+ */
692
+ diversifyMultiModalResults(results, topK) {
693
+ if (results.length <= topK) {
694
+ return results;
695
+ }
696
+ const imageResults = results.filter((r) => r.chunk.imageMeta?.hasImage);
697
+ const textResults = results.filter((r) => !r.chunk.imageMeta?.hasImage);
698
+ const diversified = [];
699
+ let imgIdx = 0;
700
+ let txtIdx = 0;
701
+ // Interleave: alternate between image and text results
702
+ for (let i = 0; i < topK && (imgIdx < imageResults.length || txtIdx < textResults.length); i++) {
703
+ if (i % 2 === 0 && imgIdx < imageResults.length) {
704
+ diversified.push(imageResults[imgIdx++]);
705
+ }
706
+ else if (txtIdx < textResults.length) {
707
+ diversified.push(textResults[txtIdx++]);
708
+ }
709
+ else if (imgIdx < imageResults.length) {
710
+ diversified.push(imageResults[imgIdx++]);
711
+ }
712
+ }
713
+ return diversified;
714
+ }
359
715
  /**
360
716
  * Ensure pipeline is initialized
361
717
  */
@@ -431,5 +787,6 @@ export function createRAGPipeline(options) {
431
787
  : undefined,
432
788
  enableHybridSearch: options.enableHybrid,
433
789
  enableGraphRAG: options.enableGraph,
790
+ multiModal: options.multiModal,
434
791
  });
435
792
  }