@retrivora-ai/rag-engine 1.9.2 → 1.9.6
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/README.md +27 -0
- package/dist/{ILLMProvider-Bw2A28nU.d.mts → ILLMProvider-DNhyOYoK.d.mts} +41 -1
- package/dist/{ILLMProvider-Bw2A28nU.d.ts → ILLMProvider-DNhyOYoK.d.ts} +41 -1
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +563 -205
- package/dist/handlers/index.mjs +563 -205
- package/dist/index-C9v7-tWd.d.mts +183 -0
- package/dist/{index-B70ZLkfG.d.mts → index-CHL1jdYm.d.mts} +1 -1
- package/dist/index-CjQdL0cX.d.ts +183 -0
- package/dist/{index-DVu-mkAM.d.ts → index-Hgbwl9X4.d.ts} +1 -1
- package/dist/index.css +40 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +326 -158
- package/dist/index.mjs +312 -151
- package/dist/server.d.mts +15 -94
- package/dist/server.d.ts +15 -94
- package/dist/server.js +623 -205
- package/dist/server.mjs +615 -205
- package/package.json +1 -1
- package/src/app/api/chat/route.ts +2 -2
- package/src/app/api/health/route.ts +3 -2
- package/src/app/api/ingest/route.ts +3 -2
- package/src/app/api/suggestions/route.ts +3 -2
- package/src/app/api/upload/route.ts +3 -2
- package/src/app/constants.tsx +85 -30
- package/src/app/layout.tsx +18 -7
- package/src/components/MessageBubble.tsx +39 -2
- package/src/components/ThinkingBlock.tsx +75 -0
- package/src/components/VisualizationRenderer.tsx +27 -1
- package/src/config/RagConfig.ts +47 -0
- package/src/config/serverConfig.ts +25 -0
- package/src/core/ConfigResolver.ts +56 -4
- package/src/core/ConfigValidator.ts +2 -1
- package/src/core/Pipeline.ts +226 -68
- package/src/core/ProviderRegistry.ts +11 -2
- package/src/core/QueryProcessor.ts +38 -4
- package/src/core/Retrivora.ts +51 -0
- package/src/exceptions/index.ts +59 -0
- package/src/handlers/index.ts +2 -0
- package/src/hooks/useRagChat.ts +26 -4
- package/src/index.ts +25 -1
- package/src/lib/plugin.ts +24 -0
- package/src/llm/LLMFactory.ts +4 -1
- package/src/llm/providers/AnthropicProvider.ts +70 -20
- package/src/llm/providers/GeminiProvider.ts +14 -15
- package/src/llm/providers/OllamaProvider.ts +13 -16
- package/src/llm/providers/OpenAIProvider.ts +9 -14
- package/src/llm/providers/UniversalLLMAdapter.ts +5 -5
- package/src/llm/utils.ts +46 -0
- package/src/providers/vectordb/MongoDBProvider.ts +19 -7
- package/src/providers/vectordb/MultiTablePostgresProvider.ts +45 -13
- package/src/rag/EntityExtractor.ts +2 -2
- package/src/rag/Reranker.ts +9 -16
- package/src/server.ts +27 -1
- package/src/types/chat.ts +7 -0
- package/src/utils/UITransformer.ts +73 -4
- package/dist/DocumentChunker-Dh9TvmGG.d.mts +0 -45
- package/dist/DocumentChunker-Dh9TvmGG.d.ts +0 -45
package/src/core/Pipeline.ts
CHANGED
|
@@ -115,24 +115,15 @@ async function scoreHallucination(
|
|
|
115
115
|
? context.slice(0, maxContextChars) + '\n...[truncated]'
|
|
116
116
|
: context;
|
|
117
117
|
|
|
118
|
-
const prompt = `You are an AI quality checker. Given the CONTEXT retrieved from a knowledge base and the ANSWER generated from it, rate how well-grounded the answer is.
|
|
119
|
-
|
|
120
|
-
CONTEXT:
|
|
121
|
-
${truncatedContext}
|
|
122
|
-
|
|
123
|
-
ANSWER:
|
|
124
|
-
${answer}
|
|
125
|
-
|
|
126
|
-
Return ONLY a valid JSON object with no markdown fences:
|
|
127
|
-
{"score": <float 0-1>, "reason": "<one sentence>"}
|
|
128
|
-
|
|
129
|
-
Where score 0 = fully grounded in context, score 1 = likely hallucinated or unsupported.`;
|
|
130
|
-
|
|
131
118
|
try {
|
|
132
119
|
const raw = await llm.chat(
|
|
133
|
-
[{ role: 'user', content:
|
|
120
|
+
[{ role: 'user', content: `CONTEXT:\n${truncatedContext}\n\nANSWER:\n${answer}` }],
|
|
134
121
|
'',
|
|
135
|
-
{
|
|
122
|
+
{
|
|
123
|
+
systemPrompt: 'You are an AI quality checker. Given the CONTEXT retrieved from a knowledge base and the ANSWER generated from it, rate how well-grounded the answer is. Return ONLY a valid JSON object with no markdown fences: {"score": <float 0-1>, "reason": "<one sentence>"} where score 0 = fully grounded in context, score 1 = likely hallucinated or unsupported.',
|
|
124
|
+
temperature: 0,
|
|
125
|
+
maxTokens: 120
|
|
126
|
+
},
|
|
136
127
|
);
|
|
137
128
|
|
|
138
129
|
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
@@ -223,7 +214,13 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
223
214
|
- Do NOT try to format product lists as tables.
|
|
224
215
|
`;
|
|
225
216
|
|
|
226
|
-
|
|
217
|
+
// Merge chartInstruction with any user-supplied systemPrompt.
|
|
218
|
+
// We append rather than overwrite so plugin consumers who pass a custom
|
|
219
|
+
// systemPrompt do not have it silently replaced.
|
|
220
|
+
const userPromptPrefix = this.config.llm.systemPrompt
|
|
221
|
+
? `${this.config.llm.systemPrompt}\n\n`
|
|
222
|
+
: '';
|
|
223
|
+
this.config.llm.systemPrompt = `${userPromptPrefix}${chartInstruction}`;
|
|
227
224
|
|
|
228
225
|
this.vectorDB = await ProviderRegistry.createVectorProvider(this.config.vectorDb);
|
|
229
226
|
|
|
@@ -431,7 +428,9 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
431
428
|
await this.initialize();
|
|
432
429
|
const ns = namespace ?? this.config.projectId;
|
|
433
430
|
const topK = this.config.rag?.topK ?? 5;
|
|
434
|
-
|
|
431
|
+
// Default score threshold of 0.3 filters random matches while keeping
|
|
432
|
+
// genuinely relevant chunks. Users can override via rag.scoreThreshold.
|
|
433
|
+
const scoreThreshold = this.config.rag?.scoreThreshold ?? 0.3;
|
|
435
434
|
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
436
435
|
const requestStart = performance.now();
|
|
437
436
|
|
|
@@ -462,10 +461,11 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
462
461
|
const cacheKey = `${ns}::${searchQuery}`;
|
|
463
462
|
const cachedVector = this.embeddingCache.get(cacheKey);
|
|
464
463
|
|
|
464
|
+
const useGraph = this.graphDB && this.config.rag?.useGraphRetrieval;
|
|
465
465
|
const [strategyResult, embeddedVector] = await Promise.all([
|
|
466
466
|
QueryProcessor.determineRetrievalStrategy(
|
|
467
467
|
searchQuery,
|
|
468
|
-
this.llmRouter.get('fast'),
|
|
468
|
+
useGraph ? this.llmRouter.get('fast') : undefined,
|
|
469
469
|
this.config.rag?.graphKeywords,
|
|
470
470
|
this.config.rag?.vectorKeywords
|
|
471
471
|
),
|
|
@@ -525,7 +525,8 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
525
525
|
displayCount = fullSources.length;
|
|
526
526
|
} else {
|
|
527
527
|
// For general semantic queries, count the number of matches meeting high relevance threshold
|
|
528
|
-
const
|
|
528
|
+
const baseThreshold = Math.max(scoreThreshold, 0.4);
|
|
529
|
+
const highlyRelevant = fullSources.filter(m => m.score >= baseThreshold);
|
|
529
530
|
displayCount = Math.max(highlyRelevant.length, topK);
|
|
530
531
|
// Cap non-metadata semantic searches to a maximum of 15 to keep the UI clean
|
|
531
532
|
if (displayCount > 15) {
|
|
@@ -545,48 +546,186 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
545
546
|
context = `GRAPH KNOWLEDGE:\n${graphContext}\n\nVECTOR CONTEXT:\n${context}`;
|
|
546
547
|
}
|
|
547
548
|
|
|
548
|
-
// 4.5 Schema Training — fire-and-forget, never
|
|
549
|
+
// 4.5 Schema Training — true fire-and-forget, never gates anything.
|
|
549
550
|
const allMetadataKeys = Array.from(new Set(sources.flatMap(s => Object.keys(s.metadata || {}))));
|
|
550
|
-
const
|
|
551
|
-
? SchemaMapper.
|
|
552
|
-
:
|
|
553
|
-
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
)
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
551
|
+
const cachedSchema = allMetadataKeys.length > 0
|
|
552
|
+
? SchemaMapper.getCached(ns, allMetadataKeys)
|
|
553
|
+
: undefined;
|
|
554
|
+
|
|
555
|
+
// Kick off schema training in the background — don't await it before starting UI transform.
|
|
556
|
+
// The next request will benefit from the trained schema via the cache.
|
|
557
|
+
if (allMetadataKeys.length > 0 && !cachedSchema) {
|
|
558
|
+
SchemaMapper.train(this.llmProvider, ns, allMetadataKeys).catch(() => { /* non-critical */ });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// 4.6 Start UI Transformation concurrently with generation.
|
|
562
|
+
// For product queries: use the fast heuristic immediately — no LLM call needed.
|
|
563
|
+
// For analytics queries: fire analyzeAndDecide() in parallel with text streaming.
|
|
564
|
+
const isProductQ = UITransformer.isProductQueryPublic(question);
|
|
565
|
+
const uiTransformationPromise = isProductQ
|
|
566
|
+
// Product carousel: pure heuristic, zero LLM latency
|
|
567
|
+
? Promise.resolve(
|
|
568
|
+
UITransformer.transform(question, sources, this.config, cachedSchema,
|
|
569
|
+
{ visualizationHint: 'product_browse', recommendedChart: 'text', filterInStockOnly: false, wantsExplicitTable: false, isTemporal: false, isComparison: false, language: 'en' }
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
: this.generateUiTransformation(
|
|
573
|
+
question,
|
|
574
|
+
sources,
|
|
575
|
+
cachedSchema,
|
|
576
|
+
hasNumericPredicates,
|
|
577
|
+
).catch(uiError => {
|
|
578
|
+
console.warn('[Pipeline] UI transformation failed concurrently:', uiError);
|
|
579
|
+
return UITransformer.transform(question, sources, this.config, cachedSchema);
|
|
580
|
+
});
|
|
566
581
|
|
|
567
582
|
// 5. Generation (Streaming)
|
|
583
|
+
// Start hallucination scoring as a background promise BEFORE streaming
|
|
584
|
+
// so that the score runs concurrently with text delivery to the client.
|
|
585
|
+
// We await it together with uiTransformationPromise (see step 7) so the
|
|
586
|
+
// metadata frame is never yielded until the score is resolved — no
|
|
587
|
+
// post-yield mutation required.
|
|
588
|
+
let hallucinationScoringPromise: Promise<{ score: number; reason: string } | undefined> =
|
|
589
|
+
Promise.resolve(undefined);
|
|
590
|
+
|
|
568
591
|
const restrictionSuffix = '\n\n(IMPORTANT: Format your response beautifully using rich Markdown! Use bolding for emphasis, headings (##) for structure, bullet points for lists, and proper line breaks between paragraphs to make it highly readable. However, NEVER generate Markdown tables, HTML figures, or text charts (the UI renders requested charts separately, so NEVER say you cannot create, display, draw, render, or provide a chart/visualization). If listing products, use simple markdown bullet points or comma-separated names. NEVER use plus signs (+) to separate product names, category names, or list items. For product description/detail questions, provide customer-facing prose only and do NOT list internal catalog fields such as Handle, Body (HTML), Vendor, Type, Tags, Published, Option, Variant, SKU, or raw metadata labels. You CAN use numbers for years/counts like 2006 or 5800, but NEVER put a dollar sign ($) before them.)';
|
|
592
|
+
|
|
593
|
+
const isClaude37 = this.config.llm.model.includes('claude-3-7-sonnet');
|
|
594
|
+
const isNativeThinking = isClaude37 && (this.config.llm.options?.thinking === true || this.config.llm.options?.thinking === 'enabled' || this.config.llm.options?.thinking !== false);
|
|
595
|
+
|
|
596
|
+
const modelNameLower = this.config.llm.model.toLowerCase();
|
|
597
|
+
const isReasoningModel = modelNameLower.includes('-r1') || modelNameLower.includes('deepseek-r1') || modelNameLower.includes('reasoning') || modelNameLower.includes('thinking');
|
|
598
|
+
const isSimulatedThinking = !isNativeThinking && (
|
|
599
|
+
this.config.llm.options?.thinking === true ||
|
|
600
|
+
this.config.llm.options?.thinking === 'enabled' ||
|
|
601
|
+
(isReasoningModel && this.config.llm.options?.thinking !== false)
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
let finalRestrictionSuffix = restrictionSuffix;
|
|
605
|
+
if (isSimulatedThinking) {
|
|
606
|
+
finalRestrictionSuffix += '\n\n(IMPORTANT: You must think step-by-step before answering. Write your thinking process inside a `<think>...</think>` block. Write the `<think>` block first, then follow it with your final response. Keep the thinking process thorough and detailed. Example: `<think>Thinking details here...</think>Final answer text here.`)';
|
|
607
|
+
}
|
|
608
|
+
|
|
569
609
|
const hardenedHistory = [...history];
|
|
570
|
-
const userQuestion = { role: 'user', content: question +
|
|
610
|
+
const userQuestion = { role: 'user', content: question + finalRestrictionSuffix } as ChatMessage;
|
|
571
611
|
const messages: ChatMessage[] = [...hardenedHistory, userQuestion];
|
|
572
612
|
|
|
573
613
|
const systemPrompt = this.config.llm.systemPrompt ?? '';
|
|
574
614
|
const userPrompt = messages.map(m => `${m.role}: ${m.content}`).join('\n');
|
|
575
615
|
|
|
576
616
|
let fullReply = '';
|
|
617
|
+
let thinkingText = '';
|
|
618
|
+
let thinkingStartMs = performance.now();
|
|
619
|
+
let thinkingDurationMs = 0;
|
|
577
620
|
const generateStart = performance.now();
|
|
578
621
|
|
|
579
622
|
if (this.llmProvider.chatStream) {
|
|
580
623
|
const stream = this.llmProvider.chatStream(messages, context);
|
|
581
624
|
if (!stream) throw new Error(`[Pipeline] ${this.config.llm.provider} chatStream returned undefined`);
|
|
625
|
+
|
|
626
|
+
let inThinking = false;
|
|
627
|
+
let textBuffer = '';
|
|
628
|
+
|
|
582
629
|
for await (const chunk of stream) {
|
|
583
|
-
|
|
584
|
-
|
|
630
|
+
// 1. Native Anthropic thinking
|
|
631
|
+
if (chunk.startsWith('\x00THINKING\x00')) {
|
|
632
|
+
const thinkingDelta = chunk.slice('\x00THINKING\x00'.length);
|
|
633
|
+
thinkingText += thinkingDelta;
|
|
634
|
+
thinkingDurationMs = performance.now() - thinkingStartMs;
|
|
635
|
+
yield { type: 'thinking', text: thinkingDelta } as any;
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// 2. Simulated thinking tag parsing
|
|
640
|
+
if (isSimulatedThinking) {
|
|
641
|
+
textBuffer += chunk;
|
|
642
|
+
while (textBuffer.length > 0) {
|
|
643
|
+
if (!inThinking) {
|
|
644
|
+
const thinkIndex = textBuffer.indexOf('<think>');
|
|
645
|
+
if (thinkIndex !== -1) {
|
|
646
|
+
const before = textBuffer.slice(0, thinkIndex);
|
|
647
|
+
if (before) {
|
|
648
|
+
fullReply += before;
|
|
649
|
+
yield before;
|
|
650
|
+
}
|
|
651
|
+
inThinking = true;
|
|
652
|
+
thinkingStartMs = performance.now();
|
|
653
|
+
textBuffer = textBuffer.slice(thinkIndex + '<think>'.length);
|
|
654
|
+
} else {
|
|
655
|
+
const maxSafeLength = textBuffer.length - '<think>'.length;
|
|
656
|
+
if (maxSafeLength > 0) {
|
|
657
|
+
const safeText = textBuffer.slice(0, maxSafeLength);
|
|
658
|
+
fullReply += safeText;
|
|
659
|
+
yield safeText;
|
|
660
|
+
textBuffer = textBuffer.slice(maxSafeLength);
|
|
661
|
+
}
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
} else {
|
|
665
|
+
const endThinkIndex = textBuffer.indexOf('</think>');
|
|
666
|
+
if (endThinkIndex !== -1) {
|
|
667
|
+
const thinkingDelta = textBuffer.slice(0, endThinkIndex);
|
|
668
|
+
if (thinkingDelta) {
|
|
669
|
+
thinkingText += thinkingDelta;
|
|
670
|
+
thinkingDurationMs = performance.now() - thinkingStartMs;
|
|
671
|
+
yield { type: 'thinking', text: thinkingDelta } as any;
|
|
672
|
+
}
|
|
673
|
+
inThinking = false;
|
|
674
|
+
textBuffer = textBuffer.slice(endThinkIndex + '</think>'.length);
|
|
675
|
+
} else {
|
|
676
|
+
const maxSafeLength = textBuffer.length - '</think>'.length;
|
|
677
|
+
if (maxSafeLength > 0) {
|
|
678
|
+
const thinkingDelta = textBuffer.slice(0, maxSafeLength);
|
|
679
|
+
thinkingText += thinkingDelta;
|
|
680
|
+
thinkingDurationMs = performance.now() - thinkingStartMs;
|
|
681
|
+
yield { type: 'thinking', text: thinkingDelta } as any;
|
|
682
|
+
textBuffer = textBuffer.slice(maxSafeLength);
|
|
683
|
+
}
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
} else {
|
|
689
|
+
fullReply += chunk;
|
|
690
|
+
yield chunk;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Flush any remaining buffer
|
|
695
|
+
if (isSimulatedThinking && textBuffer.length > 0) {
|
|
696
|
+
if (inThinking) {
|
|
697
|
+
thinkingText += textBuffer;
|
|
698
|
+
yield { type: 'thinking', text: textBuffer } as any;
|
|
699
|
+
} else {
|
|
700
|
+
fullReply += textBuffer;
|
|
701
|
+
yield textBuffer;
|
|
702
|
+
}
|
|
585
703
|
}
|
|
586
704
|
} else {
|
|
587
705
|
const reply = await this.llmProvider.chat(messages, context);
|
|
588
|
-
|
|
589
|
-
|
|
706
|
+
if (isSimulatedThinking) {
|
|
707
|
+
const thinkStart = reply.indexOf('<think>');
|
|
708
|
+
const thinkEnd = reply.indexOf('</think>');
|
|
709
|
+
if (thinkStart !== -1 && thinkEnd !== -1 && thinkEnd > thinkStart) {
|
|
710
|
+
thinkingText = reply.slice(thinkStart + '<think>'.length, thinkEnd);
|
|
711
|
+
fullReply = reply.slice(0, thinkStart) + reply.slice(thinkEnd + '</think>'.length);
|
|
712
|
+
thinkingDurationMs = 100;
|
|
713
|
+
} else {
|
|
714
|
+
fullReply = reply;
|
|
715
|
+
}
|
|
716
|
+
} else {
|
|
717
|
+
fullReply = reply;
|
|
718
|
+
}
|
|
719
|
+
yield fullReply;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Kick off hallucination scoring now that we have the full reply.
|
|
723
|
+
// It runs in parallel with the remaining post-processing below.
|
|
724
|
+
const runHallucination = this.config.llm.options?.hallucinationScoring === true ||
|
|
725
|
+
(this.config.llm.provider !== 'ollama' && this.config.llm.options?.hallucinationScoring !== false);
|
|
726
|
+
if (runHallucination) {
|
|
727
|
+
hallucinationScoringPromise = scoreHallucination(this.llmProvider, fullReply, context)
|
|
728
|
+
.catch(() => undefined);
|
|
590
729
|
}
|
|
591
730
|
|
|
592
731
|
const generateMs = performance.now() - generateStart;
|
|
@@ -611,14 +750,20 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
611
750
|
estimatedCostUsd: estimateCostUsd(promptTokens, completionTokens, this.config.llm.model),
|
|
612
751
|
};
|
|
613
752
|
|
|
614
|
-
// 7.
|
|
615
|
-
//
|
|
753
|
+
// 7. Await both UI transformation and hallucination score concurrently
|
|
754
|
+
// before yielding the metadata frame. This guarantees the trace is always
|
|
755
|
+
// complete when the consumer receives it — no post-yield mutation needed.
|
|
756
|
+
const [uiTransformation, hallucinationResult] = await Promise.all([
|
|
757
|
+
uiTransformationPromise,
|
|
758
|
+
hallucinationScoringPromise,
|
|
759
|
+
]);
|
|
760
|
+
|
|
616
761
|
const trace: ObservabilityTrace = {
|
|
617
762
|
requestId,
|
|
618
763
|
query: question,
|
|
619
764
|
rewrittenQuery,
|
|
620
765
|
systemPrompt,
|
|
621
|
-
userPrompt: question +
|
|
766
|
+
userPrompt: question + finalRestrictionSuffix,
|
|
622
767
|
chunks: sources.map((s) => ({
|
|
623
768
|
id: s.id,
|
|
624
769
|
score: s.score,
|
|
@@ -629,28 +774,22 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
629
774
|
latency,
|
|
630
775
|
tokens,
|
|
631
776
|
timestamp: new Date().toISOString(),
|
|
777
|
+
// Hallucination score is now always present when available
|
|
778
|
+
...(hallucinationResult ? {
|
|
779
|
+
hallucinationScore: hallucinationResult.score,
|
|
780
|
+
hallucinationReason: hallucinationResult.reason,
|
|
781
|
+
} : {}),
|
|
632
782
|
};
|
|
633
783
|
|
|
634
|
-
const uiTransformation = await uiTransformationPromise;
|
|
635
|
-
|
|
636
784
|
yield {
|
|
637
785
|
reply: '',
|
|
638
786
|
sources,
|
|
639
787
|
graphData,
|
|
640
788
|
ui_transformation: uiTransformation,
|
|
641
789
|
trace,
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
// The client receives this as a supplementary observability frame after the metadata.
|
|
646
|
-
scoreHallucination(this.llmProvider, fullReply, context)
|
|
647
|
-
.then((hallucinationResult) => {
|
|
648
|
-
if (hallucinationResult) {
|
|
649
|
-
trace.hallucinationScore = hallucinationResult.score;
|
|
650
|
-
trace.hallucinationReason = hallucinationResult.reason;
|
|
651
|
-
}
|
|
652
|
-
})
|
|
653
|
-
.catch(() => { /* non-critical */ });
|
|
790
|
+
thinking: thinkingText || undefined,
|
|
791
|
+
thinkingMs: thinkingDurationMs > 0 ? Math.round(thinkingDurationMs) : undefined,
|
|
792
|
+
} as ChatResponse & { trace: ObservabilityTrace; thinking?: string; thinkingMs?: number };
|
|
654
793
|
|
|
655
794
|
} catch (error) {
|
|
656
795
|
throw new Error(`[Pipeline] Stream failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -664,22 +803,31 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
664
803
|
private async generateUiTransformation(
|
|
665
804
|
question: string,
|
|
666
805
|
sources: VectorMatch[],
|
|
667
|
-
|
|
806
|
+
cachedSchema?: SchemaMap,
|
|
668
807
|
forceDeterministic: boolean = false,
|
|
669
808
|
): Promise<unknown> {
|
|
670
809
|
if (!sources || sources.length === 0) {
|
|
671
|
-
return UITransformer.transform(question, sources, this.config,
|
|
810
|
+
return UITransformer.transform(question, sources, this.config, cachedSchema);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Fast heuristic for product queries — skip both LLM calls entirely.
|
|
814
|
+
if (UITransformer.isProductQueryPublic(question)) {
|
|
815
|
+
return UITransformer.transform(question, sources, this.config, cachedSchema,
|
|
816
|
+
{ visualizationHint: 'product_browse', recommendedChart: 'text', filterInStockOnly: false, wantsExplicitTable: false, isTemporal: false, isComparison: false, language: 'en' }
|
|
817
|
+
);
|
|
672
818
|
}
|
|
673
819
|
|
|
674
|
-
|
|
675
|
-
|
|
820
|
+
const isLocalProvider = this.config.llm.provider === 'ollama';
|
|
821
|
+
const disableLlmUiTransform = this.config.llm.options?.disableLlmUiTransform === true;
|
|
822
|
+
if (forceDeterministic || isLocalProvider || disableLlmUiTransform) {
|
|
823
|
+
return UITransformer.transform(question, sources, this.config, cachedSchema);
|
|
676
824
|
}
|
|
677
825
|
|
|
678
826
|
try {
|
|
679
827
|
return await UITransformer.analyzeAndDecide(question, sources, this.llmRouter.get('fast'));
|
|
680
828
|
} catch (err) {
|
|
681
829
|
console.warn('[Pipeline] generateUiTransformation failed, using heuristic fallback:', err);
|
|
682
|
-
return UITransformer.transform(question, sources, this.config,
|
|
830
|
+
return UITransformer.transform(question, sources, this.config, cachedSchema);
|
|
683
831
|
}
|
|
684
832
|
}
|
|
685
833
|
|
|
@@ -802,7 +950,11 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
802
950
|
let queryVector = this.embeddingCache.get(cacheKey);
|
|
803
951
|
|
|
804
952
|
// Determine strategy
|
|
805
|
-
const
|
|
953
|
+
const useGraph = this.graphDB && this.config.rag?.useGraphRetrieval;
|
|
954
|
+
const strategy = await QueryProcessor.determineRetrievalStrategy(
|
|
955
|
+
query,
|
|
956
|
+
useGraph ? this.llmRouter.get('fast') : undefined
|
|
957
|
+
);
|
|
806
958
|
console.debug(`[Pipeline] Determined retrieval strategy: ${strategy}`);
|
|
807
959
|
|
|
808
960
|
const [retrievedVector, graphData] = await Promise.all([
|
|
@@ -867,10 +1019,13 @@ Optimized Search Query:`;
|
|
|
867
1019
|
|
|
868
1020
|
const rewrite = await this.llmProvider.chat(
|
|
869
1021
|
[
|
|
870
|
-
{ role: 'system', content: 'You are an assistant that optimizes search queries for RAG systems.' },
|
|
871
1022
|
{ role: 'user', content: prompt }
|
|
872
1023
|
],
|
|
873
|
-
''
|
|
1024
|
+
'',
|
|
1025
|
+
{
|
|
1026
|
+
systemPrompt: 'You are an assistant that optimizes search queries for RAG systems. Focus on extracting the core intent and entities. Do not answer the question, just rewrite it.',
|
|
1027
|
+
temperature: 0
|
|
1028
|
+
}
|
|
874
1029
|
);
|
|
875
1030
|
|
|
876
1031
|
return rewrite.trim() || question;
|
|
@@ -900,10 +1055,13 @@ Suggestions:`;
|
|
|
900
1055
|
|
|
901
1056
|
const response = await this.llmProvider.chat(
|
|
902
1057
|
[
|
|
903
|
-
{ role: 'system', content: 'You are a helpful assistant that generates search suggestions.' },
|
|
904
1058
|
{ role: 'user', content: prompt }
|
|
905
1059
|
],
|
|
906
|
-
''
|
|
1060
|
+
'',
|
|
1061
|
+
{
|
|
1062
|
+
systemPrompt: 'You are a helpful assistant that generates search suggestions based on the provided snippets. Focus on questions that can be answered by the context, keep each under 10 words, and return ONLY a JSON array of strings.',
|
|
1063
|
+
temperature: 0
|
|
1064
|
+
}
|
|
907
1065
|
);
|
|
908
1066
|
|
|
909
1067
|
const match = response.match(/\[[\s\S]*\]/);
|
|
@@ -4,6 +4,7 @@ import { VectorDBConfig, GraphDBConfig, LLMConfig, EmbeddingConfig } from '../co
|
|
|
4
4
|
import { LLMFactory } from '../llm/LLMFactory';
|
|
5
5
|
import { ILLMProvider } from '../llm/ILLMProvider';
|
|
6
6
|
import { IProviderValidator, IProviderHealthChecker } from './ProviderInterfaces';
|
|
7
|
+
import { ProviderNotFoundException } from '../exceptions';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* ProviderRegistry — dynamic provider loader for Vector DBs and LLMs.
|
|
@@ -116,7 +117,7 @@ export class ProviderRegistry {
|
|
|
116
117
|
return UniversalVectorProvider;
|
|
117
118
|
}
|
|
118
119
|
default:
|
|
119
|
-
throw new
|
|
120
|
+
throw new ProviderNotFoundException('vector', provider);
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
|
|
@@ -137,11 +138,19 @@ export class ProviderRegistry {
|
|
|
137
138
|
return new SimpleGraphProvider(config);
|
|
138
139
|
}
|
|
139
140
|
default:
|
|
140
|
-
throw new
|
|
141
|
+
throw new ProviderNotFoundException('graph', provider);
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
static createLLMProvider(llmConfig: LLMConfig, embeddingConfig?: EmbeddingConfig): ILLMProvider {
|
|
145
146
|
return LLMFactory.create(llmConfig, embeddingConfig);
|
|
146
147
|
}
|
|
148
|
+
|
|
149
|
+
static registerLLMProvider(name: string, factory: (config: LLMConfig) => ILLMProvider): void {
|
|
150
|
+
LLMFactory.register(name, factory);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
static createEmbeddingProvider(embeddingConfig: EmbeddingConfig): ILLMProvider {
|
|
154
|
+
return LLMFactory.createEmbeddingProvider(embeddingConfig);
|
|
155
|
+
}
|
|
147
156
|
}
|
|
@@ -72,6 +72,30 @@ export class QueryProcessor {
|
|
|
72
72
|
}
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
const activeValidFields = validFields.length > 0
|
|
76
|
+
? validFields
|
|
77
|
+
: ['brand', 'category', 'price', 'rating', 'stock', 'stock_quantity', 'status', 'name', 'title'];
|
|
78
|
+
|
|
79
|
+
const resolveValidField = (fieldStr: string): string | null => {
|
|
80
|
+
const cleaned = this.normalizeHintValue(fieldStr)
|
|
81
|
+
.replace(/\b(?:provide|show|get|give|list|all|the|a|an|of|organizations?|companies?|records?|items?|whose|with|having|where|that|which|have|has|is|are|was|were)\b/gi, ' ')
|
|
82
|
+
.replace(/\s+/g, ' ')
|
|
83
|
+
.trim();
|
|
84
|
+
|
|
85
|
+
const comparable = (v: string) => v.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
86
|
+
const cleanedComparable = comparable(cleaned);
|
|
87
|
+
if (!cleanedComparable) return null;
|
|
88
|
+
|
|
89
|
+
const matchedField = activeValidFields.find(fieldName => {
|
|
90
|
+
const candidate = comparable(fieldName);
|
|
91
|
+
return candidate === cleanedComparable ||
|
|
92
|
+
candidate.includes(cleanedComparable) ||
|
|
93
|
+
cleanedComparable.includes(candidate);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return matchedField ?? null;
|
|
97
|
+
};
|
|
98
|
+
|
|
75
99
|
// 1. Quoted phrases
|
|
76
100
|
for (const match of question.matchAll(/["']([^"']{2,100})["']/g)) {
|
|
77
101
|
addHint(match[1]);
|
|
@@ -105,7 +129,13 @@ export class QueryProcessor {
|
|
|
105
129
|
const field = match[1];
|
|
106
130
|
const value = match[2];
|
|
107
131
|
if (field && !this.isLikelyPromptPhrase(field)) {
|
|
108
|
-
|
|
132
|
+
const resolvedField = resolveValidField(field);
|
|
133
|
+
if (resolvedField) {
|
|
134
|
+
addHint(value, resolvedField);
|
|
135
|
+
} else {
|
|
136
|
+
addHint(value);
|
|
137
|
+
addHint(field);
|
|
138
|
+
}
|
|
109
139
|
}
|
|
110
140
|
}
|
|
111
141
|
|
|
@@ -161,13 +191,17 @@ export class QueryProcessor {
|
|
|
161
191
|
|
|
162
192
|
for (const pattern of fieldValuePatterns) {
|
|
163
193
|
for (const match of question.matchAll(pattern)) {
|
|
164
|
-
const field =
|
|
194
|
+
const field = match[1] ?? '';
|
|
165
195
|
const value = match[2] ?? '';
|
|
166
196
|
|
|
167
|
-
|
|
168
|
-
|
|
197
|
+
const resolvedField = resolveValidField(field);
|
|
198
|
+
if (resolvedField) {
|
|
199
|
+
addHint(value, resolvedField);
|
|
169
200
|
} else {
|
|
170
201
|
addHint(value);
|
|
202
|
+
if (field && !this.isLikelyPromptPhrase(field)) {
|
|
203
|
+
addHint(field);
|
|
204
|
+
}
|
|
171
205
|
}
|
|
172
206
|
}
|
|
173
207
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { RagConfig, RetrievalConfig, UniversalRagConfig } from '../config/RagConfig';
|
|
2
|
+
import { ChatMessage, ChatResponse, IngestDocument } from '../types';
|
|
3
|
+
import { ConfigResolver } from './ConfigResolver';
|
|
4
|
+
import { ConfigValidator } from './ConfigValidator';
|
|
5
|
+
import { Pipeline } from './Pipeline';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Public SDK facade matching the prompt-level Retrivora API.
|
|
9
|
+
*
|
|
10
|
+
* It keeps provider details behind configuration and delegates execution to the
|
|
11
|
+
* existing Pipeline implementation.
|
|
12
|
+
*/
|
|
13
|
+
export class Retrivora {
|
|
14
|
+
private readonly pipeline: Pipeline;
|
|
15
|
+
readonly config: RagConfig;
|
|
16
|
+
|
|
17
|
+
constructor(config?: UniversalRagConfig) {
|
|
18
|
+
this.config = ConfigResolver.resolveUniversal(config);
|
|
19
|
+
this.pipeline = new Pipeline(this.config);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async initialize(): Promise<void> {
|
|
23
|
+
await ConfigValidator.validateAndThrow(this.config);
|
|
24
|
+
await this.pipeline.initialize();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async ingest(
|
|
28
|
+
documents: IngestDocument[],
|
|
29
|
+
namespace?: string,
|
|
30
|
+
): Promise<Array<{ docId: string | number; chunksIngested: number }>> {
|
|
31
|
+
return this.pipeline.ingest(documents, namespace);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async ask(question: string, history: ChatMessage[] = [], namespace?: string): Promise<ChatResponse> {
|
|
35
|
+
return this.pipeline.ask(question, history, namespace);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
askStream(
|
|
39
|
+
question: string,
|
|
40
|
+
history: ChatMessage[] = [],
|
|
41
|
+
namespace?: string,
|
|
42
|
+
): AsyncIterable<string | ChatResponse> {
|
|
43
|
+
return this.pipeline.askStream(question, history, namespace);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getPipeline(): Pipeline {
|
|
47
|
+
return this.pipeline;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type { RetrievalConfig, UniversalRagConfig };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named SDK exceptions make failures machine-readable for host applications.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type RetrivoraErrorCode =
|
|
6
|
+
| 'PROVIDER_NOT_FOUND'
|
|
7
|
+
| 'EMBEDDING_FAILED'
|
|
8
|
+
| 'RETRIEVAL_FAILED'
|
|
9
|
+
| 'RATE_LIMITED'
|
|
10
|
+
| 'CONFIGURATION_ERROR'
|
|
11
|
+
| 'AUTHENTICATION_ERROR';
|
|
12
|
+
|
|
13
|
+
export class RetrivoraError extends Error {
|
|
14
|
+
readonly code: RetrivoraErrorCode;
|
|
15
|
+
readonly details?: unknown;
|
|
16
|
+
|
|
17
|
+
constructor(message: string, code: RetrivoraErrorCode, details?: unknown) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = new.target.name;
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.details = details;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ProviderNotFoundException extends RetrivoraError {
|
|
26
|
+
constructor(providerType: string, provider: string, details?: unknown) {
|
|
27
|
+
super(`Unsupported ${providerType} provider: ${provider}`, 'PROVIDER_NOT_FOUND', details);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class EmbeddingFailedException extends RetrivoraError {
|
|
32
|
+
constructor(message = 'Embedding generation failed', details?: unknown) {
|
|
33
|
+
super(message, 'EMBEDDING_FAILED', details);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class RetrievalException extends RetrivoraError {
|
|
38
|
+
constructor(message = 'Retrieval failed', details?: unknown) {
|
|
39
|
+
super(message, 'RETRIEVAL_FAILED', details);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class RateLimitException extends RetrivoraError {
|
|
44
|
+
constructor(message = 'Provider rate limit exceeded', details?: unknown) {
|
|
45
|
+
super(message, 'RATE_LIMITED', details);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class ConfigurationException extends RetrivoraError {
|
|
50
|
+
constructor(message: string, details?: unknown) {
|
|
51
|
+
super(message, 'CONFIGURATION_ERROR', details);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class AuthenticationException extends RetrivoraError {
|
|
56
|
+
constructor(message = 'Provider authentication failed', details?: unknown) {
|
|
57
|
+
super(message, 'AUTHENTICATION_ERROR', details);
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/handlers/index.ts
CHANGED
|
@@ -162,6 +162,8 @@ export function createStreamHandler(configOrPlugin?: Partial<RagConfig> | Vector
|
|
|
162
162
|
if (!isActive) break;
|
|
163
163
|
if (typeof chunk === 'string') {
|
|
164
164
|
enqueue(sseTextFrame(chunk));
|
|
165
|
+
} else if (chunk && typeof chunk === 'object' && 'type' in chunk && (chunk as any).type === 'thinking') {
|
|
166
|
+
enqueue(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
165
167
|
} else {
|
|
166
168
|
// Retrieval metadata object — always the final frame
|
|
167
169
|
enqueue(sseMetaFrame(chunk));
|