@juspay/neurolink 12.14.21 → 12.15.1
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/CHANGELOG.md +2 -6
- package/dist/browser/neurolink.min.js +397 -397
- package/dist/cli/commands/auth.js +6 -2
- package/dist/cli/commands/proxy.d.ts +4 -0
- package/dist/cli/commands/proxy.js +119 -39
- package/dist/cli/index.js +8 -18
- package/dist/cli/lifecycle.d.ts +8 -0
- package/dist/cli/lifecycle.js +90 -0
- package/dist/core/baseProvider.d.ts +6 -3
- package/dist/core/baseProvider.js +7 -3
- package/dist/providers/amazonBedrock/client.d.ts +7 -5
- package/dist/providers/amazonBedrock/client.js +125 -16
- package/dist/providers/cohere.d.ts +2 -2
- package/dist/providers/cohere.js +5 -1
- package/dist/providers/googleAiStudio/client.d.ts +3 -3
- package/dist/providers/googleAiStudio/client.js +6 -2
- package/dist/providers/googleVertex/client.d.ts +2 -2
- package/dist/providers/googleVertex/client.js +5 -1
- package/dist/providers/jina.d.ts +2 -2
- package/dist/providers/jina.js +5 -1
- package/dist/providers/litellm/client.d.ts +2 -2
- package/dist/providers/litellm/client.js +5 -1
- package/dist/providers/ollama/client.d.ts +2 -2
- package/dist/providers/ollama/client.js +5 -1
- package/dist/providers/openAI/client.d.ts +3 -3
- package/dist/providers/openAI/client.js +6 -2
- package/dist/providers/voyage.d.ts +2 -2
- package/dist/providers/voyage.js +5 -1
- package/dist/proxy/codexAccountUsage.js +18 -6
- package/dist/proxy/codexFallback.js +17 -10
- package/dist/proxy/codexUsage.js +3 -0
- package/dist/proxy/proxyAnalysis.js +14 -11
- package/dist/proxy/proxyTokenUsage.d.ts +7 -0
- package/dist/proxy/proxyTokenUsage.js +21 -0
- package/dist/proxy/proxyTracer.d.ts +2 -0
- package/dist/proxy/proxyTracer.js +21 -40
- package/dist/proxy/quotaHeaders.js +7 -3
- package/dist/rag/document/MDocument.d.ts +19 -1
- package/dist/rag/document/MDocument.js +30 -3
- package/dist/rag/document/imageLoader.d.ts +55 -0
- package/dist/rag/document/imageLoader.js +183 -0
- package/dist/rag/index.d.ts +1 -0
- package/dist/rag/index.js +2 -0
- package/dist/rag/pipeline/RAGPipeline.d.ts +54 -1
- package/dist/rag/pipeline/RAGPipeline.js +358 -1
- package/dist/rag/ragIntegration.js +146 -25
- package/dist/server/routes/claudeProxyRoutes.js +35 -1
- package/dist/server/routes/codexProxyRoutes.d.ts +5 -9
- package/dist/server/routes/codexProxyRoutes.js +106 -18
- package/dist/types/codex.d.ts +10 -0
- package/dist/types/providers.d.ts +21 -1
- package/dist/types/proxy.d.ts +24 -6
- package/dist/types/rag.d.ts +144 -0
- package/dist/types/rag.js +12 -1
- package/dist/utils/pricing.js +4 -2
- package/docs-site/static/search-index.json +1 -1
- 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 (
|
|
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
|
}
|