@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.
Files changed (60) hide show
  1. package/README.md +27 -0
  2. package/dist/{ILLMProvider-Bw2A28nU.d.mts → ILLMProvider-DNhyOYoK.d.mts} +41 -1
  3. package/dist/{ILLMProvider-Bw2A28nU.d.ts → ILLMProvider-DNhyOYoK.d.ts} +41 -1
  4. package/dist/handlers/index.d.mts +2 -2
  5. package/dist/handlers/index.d.ts +2 -2
  6. package/dist/handlers/index.js +563 -205
  7. package/dist/handlers/index.mjs +563 -205
  8. package/dist/index-C9v7-tWd.d.mts +183 -0
  9. package/dist/{index-B70ZLkfG.d.mts → index-CHL1jdYm.d.mts} +1 -1
  10. package/dist/index-CjQdL0cX.d.ts +183 -0
  11. package/dist/{index-DVu-mkAM.d.ts → index-Hgbwl9X4.d.ts} +1 -1
  12. package/dist/index.css +40 -0
  13. package/dist/index.d.mts +3 -3
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.js +326 -158
  16. package/dist/index.mjs +312 -151
  17. package/dist/server.d.mts +15 -94
  18. package/dist/server.d.ts +15 -94
  19. package/dist/server.js +623 -205
  20. package/dist/server.mjs +615 -205
  21. package/package.json +1 -1
  22. package/src/app/api/chat/route.ts +2 -2
  23. package/src/app/api/health/route.ts +3 -2
  24. package/src/app/api/ingest/route.ts +3 -2
  25. package/src/app/api/suggestions/route.ts +3 -2
  26. package/src/app/api/upload/route.ts +3 -2
  27. package/src/app/constants.tsx +85 -30
  28. package/src/app/layout.tsx +18 -7
  29. package/src/components/MessageBubble.tsx +39 -2
  30. package/src/components/ThinkingBlock.tsx +75 -0
  31. package/src/components/VisualizationRenderer.tsx +27 -1
  32. package/src/config/RagConfig.ts +47 -0
  33. package/src/config/serverConfig.ts +25 -0
  34. package/src/core/ConfigResolver.ts +56 -4
  35. package/src/core/ConfigValidator.ts +2 -1
  36. package/src/core/Pipeline.ts +226 -68
  37. package/src/core/ProviderRegistry.ts +11 -2
  38. package/src/core/QueryProcessor.ts +38 -4
  39. package/src/core/Retrivora.ts +51 -0
  40. package/src/exceptions/index.ts +59 -0
  41. package/src/handlers/index.ts +2 -0
  42. package/src/hooks/useRagChat.ts +26 -4
  43. package/src/index.ts +25 -1
  44. package/src/lib/plugin.ts +24 -0
  45. package/src/llm/LLMFactory.ts +4 -1
  46. package/src/llm/providers/AnthropicProvider.ts +70 -20
  47. package/src/llm/providers/GeminiProvider.ts +14 -15
  48. package/src/llm/providers/OllamaProvider.ts +13 -16
  49. package/src/llm/providers/OpenAIProvider.ts +9 -14
  50. package/src/llm/providers/UniversalLLMAdapter.ts +5 -5
  51. package/src/llm/utils.ts +46 -0
  52. package/src/providers/vectordb/MongoDBProvider.ts +19 -7
  53. package/src/providers/vectordb/MultiTablePostgresProvider.ts +45 -13
  54. package/src/rag/EntityExtractor.ts +2 -2
  55. package/src/rag/Reranker.ts +9 -16
  56. package/src/server.ts +27 -1
  57. package/src/types/chat.ts +7 -0
  58. package/src/utils/UITransformer.ts +73 -4
  59. package/dist/DocumentChunker-Dh9TvmGG.d.mts +0 -45
  60. package/dist/DocumentChunker-Dh9TvmGG.d.ts +0 -45
@@ -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: prompt }],
120
+ [{ role: 'user', content: `CONTEXT:\n${truncatedContext}\n\nANSWER:\n${answer}` }],
134
121
  '',
135
- { temperature: 0, maxTokens: 120 },
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
- this.config.llm.systemPrompt = chartInstruction;
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
- const scoreThreshold = this.config.rag?.scoreThreshold ?? 0.0;
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 highlyRelevant = fullSources.filter(m => m.score >= 0.4);
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 blocks the stream.
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 trainedSchemaPromise = allMetadataKeys.length > 0
551
- ? SchemaMapper.train(this.llmProvider, ns, allMetadataKeys).catch(() => undefined)
552
- : Promise.resolve(undefined);
553
-
554
- // 4.6 Start UI Transformation concurrently with generation to drastically reduce latency
555
- const uiTransformationPromise = trainedSchemaPromise.then(trainedSchema =>
556
- this.generateUiTransformation(
557
- question,
558
- sources,
559
- trainedSchema,
560
- hasNumericPredicates,
561
- )
562
- ).catch(uiError => {
563
- console.warn('[Pipeline] UI transformation failed concurrently:', uiError);
564
- return UITransformer.transform(question, sources, this.config);
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 + restrictionSuffix } as ChatMessage;
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
- fullReply += chunk;
584
- yield chunk;
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
- fullReply = reply;
589
- yield reply;
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. Yield metadata frame IMMEDIATELY after generation don't wait for scoring/UI.
615
- // Hallucination scoring and UI transformation are kicked off as background tasks.
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 + restrictionSuffix,
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
- } as ChatResponse & { trace: ObservabilityTrace };
643
-
644
- // 8. Hallucination scoring fire-and-forget, yields an updated trace frame.
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
- trainedSchema?: SchemaMap,
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, trainedSchema);
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
- if (forceDeterministic) {
675
- return UITransformer.transform(question, sources, this.config, trainedSchema);
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, trainedSchema);
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 strategy = await QueryProcessor.determineRetrievalStrategy(query, this.llmRouter.get('fast'));
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 Error(`Unsupported vector provider: ${provider}`);
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 Error(`Unsupported graph provider: ${provider}`);
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
- addHint(value, field);
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 = this.normalizeHintValue(match[1] ?? '');
194
+ const field = match[1] ?? '';
165
195
  const value = match[2] ?? '';
166
196
 
167
- if (field && !this.isLikelyPromptPhrase(field)) {
168
- addHint(value, field);
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
+ }
@@ -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));