@retrivora-ai/rag-engine 1.9.0 → 1.9.2

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 (47) hide show
  1. package/dist/{ILLMProvider-BOJFz3Na.d.mts → ILLMProvider-Bw2A28nU.d.mts} +12 -0
  2. package/dist/{ILLMProvider-BOJFz3Na.d.ts → ILLMProvider-Bw2A28nU.d.ts} +12 -0
  3. package/dist/handlers/index.d.mts +2 -2
  4. package/dist/handlers/index.d.ts +2 -2
  5. package/dist/handlers/index.js +1874 -542
  6. package/dist/handlers/index.mjs +1873 -541
  7. package/dist/{index-D3V9Et2M.d.mts → index-B70ZLkfG.d.mts} +1 -1
  8. package/dist/{index-BwpcaziY.d.ts → index-DVu-mkAM.d.ts} +1 -1
  9. package/dist/index.css +83 -0
  10. package/dist/index.d.mts +2 -2
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.js +330 -106
  13. package/dist/index.mjs +333 -107
  14. package/dist/server.d.mts +32 -5
  15. package/dist/server.d.ts +32 -5
  16. package/dist/server.js +1871 -736
  17. package/dist/server.mjs +1870 -735
  18. package/package.json +1 -1
  19. package/src/components/ChatWindow.tsx +24 -14
  20. package/src/components/MarkdownComponents.tsx +3 -3
  21. package/src/components/MessageBubble.tsx +89 -7
  22. package/src/components/ProductCard.tsx +29 -2
  23. package/src/components/UIDispatcher.tsx +1 -0
  24. package/src/components/VisualizationRenderer.tsx +143 -11
  25. package/src/config/EmbeddingStrategy.ts +5 -4
  26. package/src/config/RagConfig.ts +10 -0
  27. package/src/config/serverConfig.ts +16 -1
  28. package/src/core/LLMRouter.ts +79 -0
  29. package/src/core/Pipeline.ts +295 -51
  30. package/src/core/ProviderRegistry.ts +6 -0
  31. package/src/core/QueryProcessor.ts +108 -9
  32. package/src/handlers/index.ts +37 -11
  33. package/src/hooks/useRagChat.ts +77 -17
  34. package/src/llm/providers/UniversalLLMAdapter.ts +110 -13
  35. package/src/providers/vectordb/ChromaDBProvider.ts +13 -2
  36. package/src/providers/vectordb/MilvusProvider.ts +18 -2
  37. package/src/providers/vectordb/MultiTablePostgresProvider.ts +48 -16
  38. package/src/providers/vectordb/PostgreSQLProvider.ts +1 -1
  39. package/src/providers/vectordb/QdrantProvider.ts +1 -1
  40. package/src/providers/vectordb/RedisProvider.ts +3 -4
  41. package/src/providers/vectordb/WeaviateProvider.ts +41 -3
  42. package/src/types/chat.ts +2 -0
  43. package/src/types/index.ts +26 -0
  44. package/src/utils/ProductExtractor.ts +5 -3
  45. package/src/utils/SchemaMapper.ts +6 -4
  46. package/src/utils/UITransformer.ts +1350 -490
  47. package/src/utils/synonyms.ts +6 -4
@@ -0,0 +1,79 @@
1
+ import { ILLMProvider } from '../llm/ILLMProvider';
2
+ import { LLMFactory } from '../llm/LLMFactory';
3
+ import { RagConfig, LLMConfig } from '../config/RagConfig';
4
+
5
+ /**
6
+ * Provider-aware fast model defaults.
7
+ * Maps an LLM provider name to a sensible lightweight model for routing tasks.
8
+ */
9
+ const FAST_MODEL_DEFAULTS: Record<string, string> = {
10
+ openai: 'gpt-4o-mini',
11
+ gemini: 'gemini-2.0-flash',
12
+ anthropic: 'claude-3-haiku-20240307',
13
+ ollama: '', // Ollama has no universal lightweight default — reuse main model
14
+ rest: '',
15
+ universal_rest: '',
16
+ custom: '',
17
+ };
18
+
19
+ /**
20
+ * LLMRouter — manages multiple LLM instances for different task roles.
21
+ *
22
+ * Roles:
23
+ * - 'default' : the main configured LLM (used for chat generation)
24
+ * - 'fast' : a lightweight model for classification/routing tasks (UI decisions, strategy)
25
+ * - 'powerful': reserved for future use (defaults to main model)
26
+ *
27
+ * Provider-agnostic: will never mix provider SDKs (e.g., won't set a Gemini model name on an
28
+ * OpenAI client). If FAST_LLM_MODEL is set in the environment it is applied to the *same* provider.
29
+ */
30
+ export class LLMRouter {
31
+ private models = new Map<string, ILLMProvider>();
32
+
33
+ constructor(private config: RagConfig) {}
34
+
35
+ /**
36
+ * Initialize all LLM roles.
37
+ *
38
+ * @param prebuiltDefault - optional pre-built provider (from EmbeddingStrategyResolver).
39
+ * When provided it is used directly as the 'default' role without re-constructing.
40
+ */
41
+ async initialize(prebuiltDefault?: ILLMProvider): Promise<void> {
42
+ // 1. Seed 'default' — prefer the pre-built provider so we don't double-construct it.
43
+ const defaultModel = prebuiltDefault ?? LLMFactory.create(this.config.llm, this.config.embedding);
44
+ this.models.set('default', defaultModel);
45
+
46
+ // 2. Resolve 'fast' model — always stays on the SAME provider to avoid SDK mismatches.
47
+ const envFastModel = process.env.FAST_LLM_MODEL;
48
+ const providerFastDefault = FAST_MODEL_DEFAULTS[this.config.llm.provider] ?? '';
49
+
50
+ const fastModelName = envFastModel || providerFastDefault;
51
+
52
+ if (fastModelName && fastModelName !== this.config.llm.model) {
53
+ console.log(`[LLMRouter] Fast role → provider="${this.config.llm.provider}" model="${fastModelName}"`);
54
+ const fastConfig: LLMConfig = {
55
+ ...this.config.llm,
56
+ model: fastModelName,
57
+ };
58
+ this.models.set('fast', LLMFactory.create(fastConfig, this.config.embedding));
59
+ } else {
60
+ console.log(`[LLMRouter] Fast role → reusing default model (no lightweight alternative configured).`);
61
+ this.models.set('fast', defaultModel);
62
+ }
63
+
64
+ // 3. 'powerful' defaults to main model (reserved for future use)
65
+ this.models.set('powerful', defaultModel);
66
+ }
67
+
68
+ /**
69
+ * Retrieve a model provider by its task role.
70
+ * Falls back to 'default' if the requested role is not registered.
71
+ */
72
+ get(role: 'fast' | 'powerful' | 'default'): ILLMProvider {
73
+ const provider = this.models.get(role) ?? this.models.get('default');
74
+ if (!provider) {
75
+ throw new Error(`[LLMRouter] No provider registered for role: "${role}". Did you call initialize()?`);
76
+ }
77
+ return provider;
78
+ }
79
+ }
@@ -11,7 +11,8 @@ import { RagConfig } from '../config/RagConfig';
11
11
  import { ProviderRegistry } from './ProviderRegistry';
12
12
  import { BatchProcessor, BatchOptions } from './BatchProcessor';
13
13
  import { EmbeddingStrategyResolver } from '../config/EmbeddingStrategy';
14
- import { QueryProcessor } from './QueryProcessor';
14
+ import { QueryProcessor, NumericPredicate, QueryFilter } from './QueryProcessor';
15
+ import { LLMRouter } from './LLMRouter';
15
16
  import {
16
17
  IngestDocument, ChatResponse, VectorMatch, GraphSearchResult,
17
18
  RetrievalResult, UpsertDocument, ObservabilityTrace, LatencyBreakdown,
@@ -166,6 +167,7 @@ export class Pipeline {
166
167
  private graphDB?: BaseGraphProvider;
167
168
  private llmProvider!: ILLMProvider;
168
169
  private embeddingProvider!: ILLMProvider;
170
+ private llmRouter!: LLMRouter;
169
171
  private chunker: DocumentChunker;
170
172
  private llamaIngestor?: LlamaIndexIngestor;
171
173
  private entityExtractor?: EntityExtractor;
@@ -198,17 +200,26 @@ export class Pipeline {
198
200
  if (this.initialised) return;
199
201
 
200
202
  const chartInstruction = `\
201
- You are a helpful product assistant. Use the provided context to answer questions accurately.
203
+ You are a helpful assistant. Use the provided context to answer questions accurately.
202
204
 
203
- ### UI STYLE RULES (CRITICAL):
205
+ ### CRITICAL RULES:
206
+ - ONLY answer the user's specific question. Do NOT suggest or recommend unrelated products unless specifically asked.
204
207
  - NEVER generate markdown tables. If you do, the UI will break.
205
208
  - NEVER generate HTML tags like <figure>, <tbody>, <tr>, etc.
206
209
  - NEVER generate text-based charts or graphs.
210
+ - NEVER say you cannot display, render, draw, or create a chart when the user asks for a visualization. The UI handles visual rendering separately.
207
211
  - ONLY use plain text and bullet points.
212
+ - NEVER use the plus sign (+) as a separator between names, categories, or products. Use commas or bullet points.
213
+ - ONLY answer the question if the sources contain relevant information to answer it, else say that you cannot answer the question.
214
+ - If answer cannot be found in the sources, say that you cannot answer the question and do not hallucinate.
215
+ - Do not use information from previous turns to answer the question.
216
+ - You CAN use numbers for years and counts (e.g., 2006, 5800). But NEVER put a dollar sign ($) before non-monetary numbers like Stock quantities, EAN numbers, or years. Only use dollar signs for actual monetary prices.
208
217
 
209
218
  ### PRODUCT DISPLAY:
210
- - When recommending products, simply list their names, prices, and features in a friendly, conversational manner.
219
+ - Recommended Products should only be displayed when the user explicitly asks for products.
220
+ - When recommending products (ONLY when asked), simply list their names, prices, and features in a friendly, conversational manner.
211
221
  - The UI will automatically detect these products and show high-quality product cards in a carousel below your message.
222
+ - For product descriptions, summarize customer-facing details only. Do NOT list internal catalog fields such as Handle, Body (HTML), Vendor, Type, Tags, Published, Option, Variant, SKU, or raw metadata labels.
212
223
  - Do NOT try to format product lists as tables.
213
224
  `;
214
225
 
@@ -216,14 +227,23 @@ You are a helpful product assistant. Use the provided context to answer question
216
227
 
217
228
  this.vectorDB = await ProviderRegistry.createVectorProvider(this.config.vectorDb);
218
229
 
219
- const { llmProvider, embeddingProvider } = await EmbeddingStrategyResolver.resolve(
230
+ // Initialize LLM Router for multi-model support
231
+ this.llmRouter = new LLMRouter(this.config);
232
+
233
+ // EmbeddingStrategyResolver creates the providers with the correct configs
234
+ const { llmProvider: resolvedLLM, embeddingProvider } = await EmbeddingStrategyResolver.resolve(
220
235
  this.config.llm,
221
236
  this.config.embedding
222
237
  );
223
-
224
- this.llmProvider = llmProvider;
225
238
  this.embeddingProvider = embeddingProvider;
226
239
 
240
+ // Seed the router with the correctly initialised LLM provider, then initialise
241
+ // the rest of the router (fast/powerful roles) using provider-aware defaults.
242
+ await this.llmRouter.initialize(resolvedLLM);
243
+
244
+ // Primary LLM provider — use what the router resolved as 'default'
245
+ this.llmProvider = this.llmRouter.get('default');
246
+
227
247
  if (this.config.graphDb) {
228
248
  this.graphDB = await ProviderRegistry.createGraphProvider(this.config.graphDb);
229
249
  await this.graphDB.initialize();
@@ -334,7 +354,10 @@ You are a helpful product assistant. Use the provided context to answer question
334
354
  );
335
355
 
336
356
  if (upsertResult.errors.length > 0) {
337
- console.warn(`[Pipeline] Ingestion: ${upsertResult.errors.length} batches failed`);
357
+ console.warn(`[Pipeline] Ingestion: ${upsertResult.errors.length} batches failed. Error details:`);
358
+ upsertResult.errors.forEach((err, idx) => {
359
+ console.warn(` Batch ${idx + 1} Error: ${err.error.message || String(err.error)}`);
360
+ });
338
361
  }
339
362
 
340
363
  return upsertResult.totalProcessed;
@@ -397,6 +420,12 @@ You are a helpful product assistant. Use the provided context to answer question
397
420
  /**
398
421
  * High-performance streaming RAG flow.
399
422
  * Yields text chunks first, then the retrieval metadata + observability trace at the end.
423
+ *
424
+ * Latency optimizations:
425
+ * - Strategy classification runs in parallel with query embedding (saves ~400ms)
426
+ * - Hallucination scoring is fire-and-forget (doesn't block metadata yield)
427
+ * - UITransformation is computed after text streaming and emitted with metadata
428
+ * - SchemaMapper.train runs while answer generation streams
400
429
  */
401
430
  async *askStream(question: string, history: ChatMessage[] = [], namespace?: string): AsyncIterable<string | ChatResponse> {
402
431
  await this.initialize();
@@ -416,58 +445,131 @@ You are a helpful product assistant. Use the provided context to answer question
416
445
  rewrittenQuery = searchQuery !== question ? searchQuery : undefined;
417
446
  }
418
447
 
419
- // 2. Parallel Retrieval (with latency tracking)
448
+ // 2. Parallel: embed query + classify retrieval strategy at the same time.
449
+ // Previously strategy was awaited first (serialized LLM call), blocking embed+retrieve.
420
450
  const hints = QueryProcessor.extractQueryFieldHints(question, this.config.rag?.filterableFields);
421
451
  const filter = QueryProcessor.buildQueryFilter(question, hints);
452
+ const numericPredicates = QueryProcessor.extractNumericPredicates(question, this.config.rag?.filterableFields);
453
+ if (numericPredicates.length > 0) {
454
+ filter.__numericPredicates = numericPredicates;
455
+ }
422
456
 
423
457
  const embedStart = performance.now();
424
- const { sources: rawSources, graphData } = await this.retrieve(searchQuery, {
425
- namespace: ns,
426
- topK: topK * 2,
427
- filter
428
- });
458
+
459
+ // Parallelize: strategy classification + embedding run concurrently.
460
+ // Strategy is an LLM call (~300-800ms). Embedding is also async.
461
+ // Neither depends on the other's result, so both can run at the same time.
462
+ const cacheKey = `${ns}::${searchQuery}`;
463
+ const cachedVector = this.embeddingCache.get(cacheKey);
464
+
465
+ const [strategyResult, embeddedVector] = await Promise.all([
466
+ QueryProcessor.determineRetrievalStrategy(
467
+ searchQuery,
468
+ this.llmRouter.get('fast'),
469
+ this.config.rag?.graphKeywords,
470
+ this.config.rag?.vectorKeywords
471
+ ),
472
+ // Embed immediately regardless of strategy — costs nothing if cached.
473
+ // If strategy turns out to be 'graph'-only we just won't use the vector.
474
+ cachedVector
475
+ ? Promise.resolve(cachedVector)
476
+ : this.embeddingProvider.embed(searchQuery, { taskType: 'query' }),
477
+ ]);
478
+
479
+ const queryVector = embeddedVector;
480
+ if (!cachedVector && queryVector.length > 0) {
481
+ this.embeddingCache.set(cacheKey, queryVector);
482
+ }
483
+
484
+ const graphData = (strategyResult === 'graph' || strategyResult === 'both') && this.graphDB && this.config.rag?.useGraphRetrieval
485
+ ? await this.graphDB.query(searchQuery)
486
+ : undefined;
487
+
488
+ const hasMetadataFilter = filter.metadata && Object.keys(filter.metadata).length > 0;
489
+ const hasNumericPredicates = Array.isArray(filter.__numericPredicates) && filter.__numericPredicates.length > 0;
490
+ const wantsExhaustiveList = hasNumericPredicates || hasMetadataFilter || /\b(list|all|show|provide|give|get|browse|find|search|display)\b/i.test(question);
491
+ const retrievalLimit = wantsExhaustiveList ? Math.max(topK * 20, 100) : topK * 2;
492
+
493
+ const rawSources = (strategyResult === 'vector' || strategyResult === 'both') && queryVector && queryVector.length > 0
494
+ ? await this.vectorDB.query(queryVector, retrievalLimit, ns, filter)
495
+ : [];
496
+
429
497
  const retrieveEnd = performance.now();
430
- const embedMs = retrieveEnd - embedStart; // embed + retrieve combined (cache may make embed ~0)
498
+ const embedMs = retrieveEnd - embedStart;
431
499
  const retrieveMs = retrieveEnd - embedStart;
432
500
 
433
501
  // 3. Reranking
434
502
  const rerankStart = performance.now();
435
- let sources = rawSources.filter((m) => m.score >= scoreThreshold);
436
- if (this.config.rag?.useReranking) {
437
- // We do not pass llmProvider here to opt into the fast, deterministic keyword-based re-ranking in production.
438
- // Pass this.llmProvider as the 4th argument if you want high-accuracy LLM-based re-ranking.
439
- sources = await this.reranker.rerank(sources, question, topK);
440
- } else {
441
- sources = sources.slice(0, topK);
503
+ const structuredSources = this.applyStructuredFilters(rawSources, filter);
504
+ let fullSources = hasNumericPredicates
505
+ ? structuredSources
506
+ : structuredSources.filter((m) => m.score >= scoreThreshold);
507
+ const rerankLimit = wantsExhaustiveList ? retrievalLimit : topK;
508
+ if (!hasNumericPredicates && this.config.rag?.useReranking) {
509
+ fullSources = await this.reranker.rerank(fullSources, question, rerankLimit);
510
+ } else if (!wantsExhaustiveList) {
511
+ fullSources = fullSources.slice(0, topK);
442
512
  }
443
513
  const rerankMs = performance.now() - rerankStart;
444
514
 
445
- // 4. Context Augmentation
446
- let context = sources.length
447
- ? sources.map((m, i) => `[Source ${i + 1}]\n${m.content}`).join('\n\n---\n\n')
515
+ // 4. Context Augmentation - Review all retrieved/reranked sources for full comprehension
516
+ let context = fullSources.length
517
+ ? fullSources.map((m, i) => `[Source ${i + 1}]\n${m.content}`).join('\n\n---\n\n')
448
518
  : 'No relevant context found.';
449
519
 
520
+ // Count the records of relevant sources to pass into the slice dynamically
521
+ let displayCount = 15; // default fallback
522
+
523
+ if (hasMetadataFilter) {
524
+ // If a metadata filter matches (e.g. brand, category), all of those matches are highly relevant
525
+ displayCount = fullSources.length;
526
+ } else {
527
+ // For general semantic queries, count the number of matches meeting high relevance threshold
528
+ const highlyRelevant = fullSources.filter(m => m.score >= 0.4);
529
+ displayCount = Math.max(highlyRelevant.length, topK);
530
+ // Cap non-metadata semantic searches to a maximum of 15 to keep the UI clean
531
+ if (displayCount > 15) {
532
+ displayCount = 15;
533
+ }
534
+ }
535
+
536
+ // Ensure they are sorted from highest to lowest score, then slice using our dynamic count
537
+ const sources = [...fullSources]
538
+ .sort((a, b) => b.score - a.score)
539
+ .slice(0, displayCount);
540
+
450
541
  if (graphData && graphData.nodes.length > 0) {
451
- console.log(`[Graph Retrieval] Found ${graphData.nodes.length} relevant entities.`);
452
542
  const graphContext = graphData.nodes.map(n =>
453
543
  `Entity: ${n.label} (${n.id})${n.properties ? ' - ' + JSON.stringify(n.properties) : ''}`
454
544
  ).join('\n');
455
545
  context = `GRAPH KNOWLEDGE:\n${graphContext}\n\nVECTOR CONTEXT:\n${context}`;
456
546
  }
457
547
 
458
- // 4.5 Schema Training (non-blocking)
548
+ // 4.5 Schema Training — fire-and-forget, never blocks the stream.
459
549
  const allMetadataKeys = Array.from(new Set(sources.flatMap(s => Object.keys(s.metadata || {}))));
460
- const trainingPromise = allMetadataKeys.length > 0
461
- ? SchemaMapper.train(this.llmProvider, ns, allMetadataKeys)
550
+ const trainedSchemaPromise = allMetadataKeys.length > 0
551
+ ? SchemaMapper.train(this.llmProvider, ns, allMetadataKeys).catch(() => undefined)
462
552
  : Promise.resolve(undefined);
463
553
 
464
- // 5. Generation (Streaming) capture the full reply for observability
465
- const restrictionSuffix = '\n\n(IMPORTANT: Use plain text only. NEVER generate tables, HTML figures, or text charts. If listing products, use simple bullet points.)';
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
+ });
566
+
567
+ // 5. Generation (Streaming)
568
+ 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.)';
466
569
  const hardenedHistory = [...history];
467
570
  const userQuestion = { role: 'user', content: question + restrictionSuffix } as ChatMessage;
468
571
  const messages: ChatMessage[] = [...hardenedHistory, userQuestion];
469
572
 
470
- // Build prompt strings for tracing
471
573
  const systemPrompt = this.config.llm.systemPrompt ?? '';
472
574
  const userPrompt = messages.map(m => `${m.role}: ${m.content}`).join('\n');
473
575
 
@@ -476,9 +578,7 @@ You are a helpful product assistant. Use the provided context to answer question
476
578
 
477
579
  if (this.llmProvider.chatStream) {
478
580
  const stream = this.llmProvider.chatStream(messages, context);
479
- if (!stream) {
480
- throw new Error(`[Pipeline] ${this.config.llm.provider} chatStream returned undefined`);
481
- }
581
+ if (!stream) throw new Error(`[Pipeline] ${this.config.llm.provider} chatStream returned undefined`);
482
582
  for await (const chunk of stream) {
483
583
  fullReply += chunk;
484
584
  yield chunk;
@@ -511,14 +611,8 @@ You are a helpful product assistant. Use the provided context to answer question
511
611
  estimatedCostUsd: estimateCostUsd(promptTokens, completionTokens, this.config.llm.model),
512
612
  };
513
613
 
514
- // 7. Hallucination scoring (async, non-blocking on generation yield)
515
- const hallucinationResult = await scoreHallucination(this.llmProvider, fullReply, context).catch(() => undefined);
516
-
517
- // 8. Automated UI transformation
518
- const trainedSchema = await trainingPromise;
519
- const uiTransformation = await this.generateUiTransformation(question, sources, trainedSchema);
520
-
521
- // 9. Build observability trace
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.
522
616
  const trace: ObservabilityTrace = {
523
617
  requestId,
524
618
  query: question,
@@ -534,12 +628,11 @@ You are a helpful product assistant. Use the provided context to answer question
534
628
  } satisfies RetrievedChunk)),
535
629
  latency,
536
630
  tokens,
537
- hallucinationScore: hallucinationResult?.score,
538
- hallucinationReason: hallucinationResult?.reason,
539
631
  timestamp: new Date().toISOString(),
540
632
  };
541
633
 
542
- // Yield metadata + trace + UI transformation
634
+ const uiTransformation = await uiTransformationPromise;
635
+
543
636
  yield {
544
637
  reply: '',
545
638
  sources,
@@ -548,6 +641,17 @@ You are a helpful product assistant. Use the provided context to answer question
548
641
  trace,
549
642
  } as ChatResponse & { trace: ObservabilityTrace };
550
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 */ });
654
+
551
655
  } catch (error) {
552
656
  throw new Error(`[Pipeline] Stream failed: ${error instanceof Error ? error.message : String(error)}`);
553
657
  }
@@ -560,20 +664,136 @@ You are a helpful product assistant. Use the provided context to answer question
560
664
  private async generateUiTransformation(
561
665
  question: string,
562
666
  sources: VectorMatch[],
563
- trainedSchema?: SchemaMap
667
+ trainedSchema?: SchemaMap,
668
+ forceDeterministic: boolean = false,
564
669
  ): Promise<unknown> {
565
670
  if (!sources || sources.length === 0) {
566
671
  return UITransformer.transform(question, sources, this.config, trainedSchema);
567
672
  }
568
673
 
674
+ if (forceDeterministic) {
675
+ return UITransformer.transform(question, sources, this.config, trainedSchema);
676
+ }
677
+
569
678
  try {
570
- return await UITransformer.analyzeAndDecide(question, sources, this.llmProvider);
679
+ return await UITransformer.analyzeAndDecide(question, sources, this.llmRouter.get('fast'));
571
680
  } catch (err) {
572
681
  console.warn('[Pipeline] generateUiTransformation failed, using heuristic fallback:', err);
573
682
  return UITransformer.transform(question, sources, this.config, trainedSchema);
574
683
  }
575
684
  }
576
685
 
686
+ private applyStructuredFilters(sources: VectorMatch[], filter: QueryFilter): VectorMatch[] {
687
+ const predicates = Array.isArray(filter.__numericPredicates)
688
+ ? filter.__numericPredicates
689
+ : [];
690
+ if (predicates.length === 0) return sources;
691
+
692
+ return sources
693
+ .filter(source => predicates.every(predicate => {
694
+ const value = this.resolveNumericPredicateValue(source, predicate);
695
+ return value !== null && this.matchesNumericPredicate(value, predicate);
696
+ }))
697
+ .sort((a, b) => {
698
+ const primary = predicates[0];
699
+ const aValue = this.resolveNumericPredicateValue(a, primary) ?? 0;
700
+ const bValue = this.resolveNumericPredicateValue(b, primary) ?? 0;
701
+ return primary.operator === 'lt' || primary.operator === 'lte'
702
+ ? aValue - bValue
703
+ : bValue - aValue;
704
+ });
705
+ }
706
+
707
+ private resolveNumericPredicateValue(source: VectorMatch, predicate: NumericPredicate): number | null {
708
+ const meta = source.metadata || {};
709
+ const field = predicate.field;
710
+ const entries = Object.entries(meta)
711
+ .filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object');
712
+
713
+ if (field) {
714
+ const normalizedField = this.normalizeComparableField(field);
715
+ const exact = entries.find(([key]) => this.normalizeComparableField(key) === normalizedField);
716
+ const fuzzy = exact ?? entries
717
+ .map(([key, value]) => ({ key, value, score: this.fieldSimilarityScore(key, field) }))
718
+ .filter(candidate => candidate.score > 0)
719
+ .sort((a, b) => b.score - a.score)[0];
720
+ if (fuzzy) {
721
+ const value = this.toFiniteNumber(Array.isArray(fuzzy) ? fuzzy[1] : fuzzy.value);
722
+ if (value !== null) return value;
723
+ }
724
+
725
+ const contentValue = this.extractNumericValueFromContent(source.content, field);
726
+ if (contentValue !== null) return contentValue;
727
+ }
728
+
729
+ for (const [key, value] of entries) {
730
+ if (/(id|sku|ean|upc|phone|zip|postal|code)/i.test(key)) continue;
731
+ const numeric = this.toFiniteNumber(value);
732
+ if (numeric !== null) return numeric;
733
+ }
734
+
735
+ return null;
736
+ }
737
+
738
+ private extractNumericValueFromContent(content: string, field: string): number | null {
739
+ const escapedWords = field
740
+ .split(/\s+|_+|-+/)
741
+ .filter(Boolean)
742
+ .map(word => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
743
+ if (escapedWords.length === 0) return null;
744
+
745
+ const pattern = new RegExp(`(?:${escapedWords.join('[\\\\s_:-]*')})\\s*[:\\-–]?\\s*([\\d,]+(?:\\.\\d+)?)`, 'i');
746
+ const match = content.match(pattern);
747
+ return match ? this.toFiniteNumber(match[1]) : null;
748
+ }
749
+
750
+ private matchesNumericPredicate(value: number, predicate: NumericPredicate): boolean {
751
+ switch (predicate.operator) {
752
+ case 'gt':
753
+ return value > predicate.value;
754
+ case 'gte':
755
+ return value >= predicate.value;
756
+ case 'lt':
757
+ return value < predicate.value;
758
+ case 'lte':
759
+ return value <= predicate.value;
760
+ case 'eq':
761
+ default:
762
+ return value === predicate.value;
763
+ }
764
+ }
765
+
766
+ private normalizeComparableField(value: string): string {
767
+ return value.toLowerCase().replace(/[^a-z0-9]/g, '');
768
+ }
769
+
770
+ private fieldSimilarityScore(candidate: string, requested: string): number {
771
+ const normalizedCandidate = this.normalizeComparableField(candidate);
772
+ const normalizedRequested = this.normalizeComparableField(requested);
773
+ if (normalizedCandidate === normalizedRequested) return 10;
774
+ if (normalizedCandidate.includes(normalizedRequested) || normalizedRequested.includes(normalizedCandidate)) return 8;
775
+
776
+ const candidateTokens = this.fieldTokens(candidate);
777
+ const requestedTokens = this.fieldTokens(requested);
778
+ const overlap = requestedTokens.filter(token => candidateTokens.includes(token)).length;
779
+ if (overlap === 0) return 0;
780
+ return overlap / Math.max(requestedTokens.length, candidateTokens.length);
781
+ }
782
+
783
+ private fieldTokens(value: string): string[] {
784
+ return value
785
+ .toLowerCase()
786
+ .split(/[^a-z0-9]+/)
787
+ .map(token => token.replace(/ies$/, 'y').replace(/s$/, ''))
788
+ .filter(token => token.length > 1);
789
+ }
790
+
791
+ private toFiniteNumber(value: unknown): number | null {
792
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
793
+ const numeric = Number(String(value ?? '').replace(/[$,% ,]/g, ''));
794
+ return Number.isFinite(numeric) ? numeric : null;
795
+ }
796
+
577
797
  async retrieve(query: string, options: { namespace?: string; topK?: number; filter?: Record<string, unknown> }): Promise<RetrievalResult> {
578
798
  const ns = options.namespace ?? this.config.projectId;
579
799
  const topK = options.topK ?? 5;
@@ -582,7 +802,7 @@ You are a helpful product assistant. Use the provided context to answer question
582
802
  let queryVector = this.embeddingCache.get(cacheKey);
583
803
 
584
804
  // Determine strategy
585
- const strategy = await QueryProcessor.determineRetrievalStrategy(query, this.llmProvider);
805
+ const strategy = await QueryProcessor.determineRetrievalStrategy(query, this.llmRouter.get('fast'));
586
806
  console.debug(`[Pipeline] Determined retrieval strategy: ${strategy}`);
587
807
 
588
808
  const [retrievedVector, graphData] = await Promise.all([
@@ -602,11 +822,35 @@ You are a helpful product assistant. Use the provided context to answer question
602
822
  queryVector = retrievedVector;
603
823
  }
604
824
 
825
+ const baseFilter = { ...(options.filter ?? {}), queryText: query } as QueryFilter;
826
+ const numericPredicates = QueryProcessor.extractNumericPredicates(query, this.config.rag?.filterableFields);
827
+ if (numericPredicates.length > 0) {
828
+ baseFilter.__numericPredicates = numericPredicates;
829
+ }
830
+ const retrievalLimit = numericPredicates.length > 0 ? Math.max(topK * 20, 100) : topK;
831
+
605
832
  const sources = (strategy === 'vector' || strategy === 'both') && queryVector && queryVector.length > 0
606
- ? await this.vectorDB.query(queryVector, topK, ns, options.filter)
833
+ ? this.applyStructuredFilters(
834
+ await this.vectorDB.query(queryVector, retrievalLimit, ns, baseFilter),
835
+ baseFilter,
836
+ )
607
837
  : [];
608
838
 
609
- return { sources, graphData };
839
+ // Multi-Vector Support: Check for parent_id in metadata
840
+ const resolvedSources: VectorMatch[] = [];
841
+ for (const source of sources) {
842
+ const parentId = source.metadata?.parent_id;
843
+ if (parentId) {
844
+ console.log(`[Pipeline] Multi-Vector: Found child chunk. Parent ID: ${parentId}`);
845
+ // In a full implementation, we would fetch the parent document here.
846
+ // For now, we pass the child chunk forward.
847
+ resolvedSources.push(source);
848
+ } else {
849
+ resolvedSources.push(source);
850
+ }
851
+ }
852
+
853
+ return { sources: resolvedSources, graphData };
610
854
  }
611
855
 
612
856
  /** Rewrite the user query for better retrieval performance. */
@@ -77,6 +77,12 @@ export class ProviderRegistry {
77
77
  }
78
78
  case 'pgvector':
79
79
  case 'postgresql': {
80
+ // POSTGRES_MODE=single uses the simple single-table provider (default: multi).
81
+ const postgresMode = (process.env.POSTGRES_MODE ?? 'multi').toLowerCase();
82
+ if (postgresMode === 'single') {
83
+ const { PostgreSQLProvider } = await import('../providers/vectordb/PostgreSQLProvider');
84
+ return PostgreSQLProvider;
85
+ }
80
86
  const { MultiTablePostgresProvider } = await import('../providers/vectordb/MultiTablePostgresProvider');
81
87
  return MultiTablePostgresProvider as unknown as VectorProviderClass;
82
88
  }