@retrivora-ai/rag-engine 1.9.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/{ILLMProvider-BOJFz3Na.d.mts → ILLMProvider-BQyKZLnd.d.mts} +10 -0
  2. package/dist/{ILLMProvider-BOJFz3Na.d.ts → ILLMProvider-BQyKZLnd.d.ts} +10 -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 +1719 -467
  6. package/dist/handlers/index.mjs +1718 -466
  7. package/dist/{index-BwpcaziY.d.ts → index-A0GqPsaG.d.ts} +1 -1
  8. package/dist/{index-D3V9Et2M.d.mts → index-CDftK3qs.d.mts} +1 -1
  9. package/dist/index.css +26 -0
  10. package/dist/index.d.mts +2 -2
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.js +246 -76
  13. package/dist/index.mjs +248 -76
  14. package/dist/server.d.mts +32 -5
  15. package/dist/server.d.ts +32 -5
  16. package/dist/server.js +1726 -671
  17. package/dist/server.mjs +1724 -669
  18. package/package.json +1 -1
  19. package/src/components/MarkdownComponents.tsx +3 -3
  20. package/src/components/MessageBubble.tsx +49 -2
  21. package/src/components/ProductCard.tsx +33 -6
  22. package/src/components/UIDispatcher.tsx +1 -0
  23. package/src/components/VisualizationRenderer.tsx +143 -11
  24. package/src/config/EmbeddingStrategy.ts +5 -4
  25. package/src/config/RagConfig.ts +10 -0
  26. package/src/config/serverConfig.ts +16 -1
  27. package/src/core/LLMRouter.ts +79 -0
  28. package/src/core/Pipeline.ts +262 -45
  29. package/src/core/ProviderRegistry.ts +6 -0
  30. package/src/core/QueryProcessor.ts +98 -0
  31. package/src/handlers/index.ts +8 -5
  32. package/src/hooks/useRagChat.ts +53 -17
  33. package/src/llm/providers/UniversalLLMAdapter.ts +110 -13
  34. package/src/providers/vectordb/ChromaDBProvider.ts +13 -2
  35. package/src/providers/vectordb/MilvusProvider.ts +18 -2
  36. package/src/providers/vectordb/MultiTablePostgresProvider.ts +12 -12
  37. package/src/providers/vectordb/PostgreSQLProvider.ts +1 -1
  38. package/src/providers/vectordb/QdrantProvider.ts +1 -1
  39. package/src/providers/vectordb/RedisProvider.ts +3 -4
  40. package/src/providers/vectordb/WeaviateProvider.ts +41 -3
  41. package/src/types/index.ts +26 -0
  42. package/src/utils/ProductExtractor.ts +5 -3
  43. package/src/utils/UITransformer.ts +1348 -489
  44. package/src/utils/synonyms.ts +2 -0
@@ -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();
@@ -397,6 +417,12 @@ You are a helpful product assistant. Use the provided context to answer question
397
417
  /**
398
418
  * High-performance streaming RAG flow.
399
419
  * Yields text chunks first, then the retrieval metadata + observability trace at the end.
420
+ *
421
+ * Latency optimizations:
422
+ * - Strategy classification runs in parallel with query embedding (saves ~400ms)
423
+ * - Hallucination scoring is fire-and-forget (doesn't block metadata yield)
424
+ * - UITransformation is computed after text streaming and emitted with metadata
425
+ * - SchemaMapper.train runs while answer generation streams
400
426
  */
401
427
  async *askStream(question: string, history: ChatMessage[] = [], namespace?: string): AsyncIterable<string | ChatResponse> {
402
428
  await this.initialize();
@@ -416,28 +442,67 @@ You are a helpful product assistant. Use the provided context to answer question
416
442
  rewrittenQuery = searchQuery !== question ? searchQuery : undefined;
417
443
  }
418
444
 
419
- // 2. Parallel Retrieval (with latency tracking)
445
+ // 2. Parallel: embed query + classify retrieval strategy at the same time.
446
+ // Previously strategy was awaited first (serialized LLM call), blocking embed+retrieve.
420
447
  const hints = QueryProcessor.extractQueryFieldHints(question, this.config.rag?.filterableFields);
421
448
  const filter = QueryProcessor.buildQueryFilter(question, hints);
449
+ const numericPredicates = QueryProcessor.extractNumericPredicates(question, this.config.rag?.filterableFields);
450
+ if (numericPredicates.length > 0) {
451
+ filter.__numericPredicates = numericPredicates;
452
+ }
422
453
 
423
454
  const embedStart = performance.now();
424
- const { sources: rawSources, graphData } = await this.retrieve(searchQuery, {
425
- namespace: ns,
426
- topK: topK * 2,
427
- filter
428
- });
455
+
456
+ // Parallelize: strategy classification + embedding run concurrently.
457
+ // Strategy is an LLM call (~300-800ms). Embedding is also async.
458
+ // Neither depends on the other's result, so both can run at the same time.
459
+ const cacheKey = `${ns}::${searchQuery}`;
460
+ const cachedVector = this.embeddingCache.get(cacheKey);
461
+
462
+ const [strategyResult, embeddedVector] = await Promise.all([
463
+ QueryProcessor.determineRetrievalStrategy(
464
+ searchQuery,
465
+ this.llmRouter.get('fast'),
466
+ this.config.rag?.graphKeywords,
467
+ this.config.rag?.vectorKeywords
468
+ ),
469
+ // Embed immediately regardless of strategy — costs nothing if cached.
470
+ // If strategy turns out to be 'graph'-only we just won't use the vector.
471
+ cachedVector
472
+ ? Promise.resolve(cachedVector)
473
+ : this.embeddingProvider.embed(searchQuery, { taskType: 'query' }),
474
+ ]);
475
+
476
+ const queryVector = embeddedVector;
477
+ if (!cachedVector && queryVector.length > 0) {
478
+ this.embeddingCache.set(cacheKey, queryVector);
479
+ }
480
+
481
+ const graphData = (strategyResult === 'graph' || strategyResult === 'both') && this.graphDB && this.config.rag?.useGraphRetrieval
482
+ ? await this.graphDB.query(searchQuery)
483
+ : undefined;
484
+
485
+ const hasNumericPredicates = Array.isArray(filter.__numericPredicates) && filter.__numericPredicates.length > 0;
486
+ const wantsExhaustiveList = hasNumericPredicates || /\b(list|all|show|provide|give|get)\b/i.test(question);
487
+ const retrievalLimit = wantsExhaustiveList ? Math.max(topK * 20, 100) : topK * 2;
488
+
489
+ const rawSources = (strategyResult === 'vector' || strategyResult === 'both') && queryVector && queryVector.length > 0
490
+ ? await this.vectorDB.query(queryVector, retrievalLimit, ns, filter)
491
+ : [];
492
+
429
493
  const retrieveEnd = performance.now();
430
- const embedMs = retrieveEnd - embedStart; // embed + retrieve combined (cache may make embed ~0)
494
+ const embedMs = retrieveEnd - embedStart;
431
495
  const retrieveMs = retrieveEnd - embedStart;
432
496
 
433
497
  // 3. Reranking
434
498
  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.
499
+ const structuredSources = this.applyStructuredFilters(rawSources, filter);
500
+ let sources = hasNumericPredicates
501
+ ? structuredSources
502
+ : structuredSources.filter((m) => m.score >= scoreThreshold);
503
+ if (!hasNumericPredicates && this.config.rag?.useReranking) {
439
504
  sources = await this.reranker.rerank(sources, question, topK);
440
- } else {
505
+ } else if (!wantsExhaustiveList) {
441
506
  sources = sources.slice(0, topK);
442
507
  }
443
508
  const rerankMs = performance.now() - rerankStart;
@@ -448,26 +513,24 @@ You are a helpful product assistant. Use the provided context to answer question
448
513
  : 'No relevant context found.';
449
514
 
450
515
  if (graphData && graphData.nodes.length > 0) {
451
- console.log(`[Graph Retrieval] Found ${graphData.nodes.length} relevant entities.`);
452
516
  const graphContext = graphData.nodes.map(n =>
453
517
  `Entity: ${n.label} (${n.id})${n.properties ? ' - ' + JSON.stringify(n.properties) : ''}`
454
518
  ).join('\n');
455
519
  context = `GRAPH KNOWLEDGE:\n${graphContext}\n\nVECTOR CONTEXT:\n${context}`;
456
520
  }
457
521
 
458
- // 4.5 Schema Training (non-blocking)
522
+ // 4.5 Schema Training — fire-and-forget, never blocks the stream.
459
523
  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)
524
+ const trainedSchemaPromise = allMetadataKeys.length > 0
525
+ ? SchemaMapper.train(this.llmProvider, ns, allMetadataKeys).catch(() => undefined)
462
526
  : Promise.resolve(undefined);
463
527
 
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.)';
528
+ // 5. Generation (Streaming)
529
+ const restrictionSuffix = '\n\n(IMPORTANT: Use plain text only. NEVER generate 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 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
530
  const hardenedHistory = [...history];
467
531
  const userQuestion = { role: 'user', content: question + restrictionSuffix } as ChatMessage;
468
532
  const messages: ChatMessage[] = [...hardenedHistory, userQuestion];
469
533
 
470
- // Build prompt strings for tracing
471
534
  const systemPrompt = this.config.llm.systemPrompt ?? '';
472
535
  const userPrompt = messages.map(m => `${m.role}: ${m.content}`).join('\n');
473
536
 
@@ -476,9 +539,7 @@ You are a helpful product assistant. Use the provided context to answer question
476
539
 
477
540
  if (this.llmProvider.chatStream) {
478
541
  const stream = this.llmProvider.chatStream(messages, context);
479
- if (!stream) {
480
- throw new Error(`[Pipeline] ${this.config.llm.provider} chatStream returned undefined`);
481
- }
542
+ if (!stream) throw new Error(`[Pipeline] ${this.config.llm.provider} chatStream returned undefined`);
482
543
  for await (const chunk of stream) {
483
544
  fullReply += chunk;
484
545
  yield chunk;
@@ -511,14 +572,8 @@ You are a helpful product assistant. Use the provided context to answer question
511
572
  estimatedCostUsd: estimateCostUsd(promptTokens, completionTokens, this.config.llm.model),
512
573
  };
513
574
 
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
575
+ // 7. Yield metadata frame IMMEDIATELY after generation — don't wait for scoring/UI.
576
+ // Hallucination scoring and UI transformation are kicked off as background tasks.
522
577
  const trace: ObservabilityTrace = {
523
578
  requestId,
524
579
  query: question,
@@ -534,12 +589,23 @@ You are a helpful product assistant. Use the provided context to answer question
534
589
  } satisfies RetrievedChunk)),
535
590
  latency,
536
591
  tokens,
537
- hallucinationScore: hallucinationResult?.score,
538
- hallucinationReason: hallucinationResult?.reason,
539
592
  timestamp: new Date().toISOString(),
540
593
  };
541
594
 
542
- // Yield metadata + trace + UI transformation
595
+ let uiTransformation: unknown;
596
+ try {
597
+ const trainedSchema = await trainedSchemaPromise;
598
+ uiTransformation = await this.generateUiTransformation(
599
+ question,
600
+ sources,
601
+ trainedSchema,
602
+ hasNumericPredicates,
603
+ );
604
+ } catch (uiError) {
605
+ console.warn('[Pipeline] UI transformation failed before metadata yield:', uiError);
606
+ uiTransformation = UITransformer.transform(question, sources, this.config);
607
+ }
608
+
543
609
  yield {
544
610
  reply: '',
545
611
  sources,
@@ -548,6 +614,17 @@ You are a helpful product assistant. Use the provided context to answer question
548
614
  trace,
549
615
  } as ChatResponse & { trace: ObservabilityTrace };
550
616
 
617
+ // 8. Hallucination scoring — fire-and-forget, yields an updated trace frame.
618
+ // The client receives this as a supplementary observability frame after the metadata.
619
+ scoreHallucination(this.llmProvider, fullReply, context)
620
+ .then((hallucinationResult) => {
621
+ if (hallucinationResult) {
622
+ trace.hallucinationScore = hallucinationResult.score;
623
+ trace.hallucinationReason = hallucinationResult.reason;
624
+ }
625
+ })
626
+ .catch(() => { /* non-critical */ });
627
+
551
628
  } catch (error) {
552
629
  throw new Error(`[Pipeline] Stream failed: ${error instanceof Error ? error.message : String(error)}`);
553
630
  }
@@ -560,20 +637,136 @@ You are a helpful product assistant. Use the provided context to answer question
560
637
  private async generateUiTransformation(
561
638
  question: string,
562
639
  sources: VectorMatch[],
563
- trainedSchema?: SchemaMap
640
+ trainedSchema?: SchemaMap,
641
+ forceDeterministic: boolean = false,
564
642
  ): Promise<unknown> {
565
643
  if (!sources || sources.length === 0) {
566
644
  return UITransformer.transform(question, sources, this.config, trainedSchema);
567
645
  }
568
646
 
647
+ if (forceDeterministic) {
648
+ return UITransformer.transform(question, sources, this.config, trainedSchema);
649
+ }
650
+
569
651
  try {
570
- return await UITransformer.analyzeAndDecide(question, sources, this.llmProvider);
652
+ return await UITransformer.analyzeAndDecide(question, sources, this.llmRouter.get('fast'));
571
653
  } catch (err) {
572
654
  console.warn('[Pipeline] generateUiTransformation failed, using heuristic fallback:', err);
573
655
  return UITransformer.transform(question, sources, this.config, trainedSchema);
574
656
  }
575
657
  }
576
658
 
659
+ private applyStructuredFilters(sources: VectorMatch[], filter: QueryFilter): VectorMatch[] {
660
+ const predicates = Array.isArray(filter.__numericPredicates)
661
+ ? filter.__numericPredicates
662
+ : [];
663
+ if (predicates.length === 0) return sources;
664
+
665
+ return sources
666
+ .filter(source => predicates.every(predicate => {
667
+ const value = this.resolveNumericPredicateValue(source, predicate);
668
+ return value !== null && this.matchesNumericPredicate(value, predicate);
669
+ }))
670
+ .sort((a, b) => {
671
+ const primary = predicates[0];
672
+ const aValue = this.resolveNumericPredicateValue(a, primary) ?? 0;
673
+ const bValue = this.resolveNumericPredicateValue(b, primary) ?? 0;
674
+ return primary.operator === 'lt' || primary.operator === 'lte'
675
+ ? aValue - bValue
676
+ : bValue - aValue;
677
+ });
678
+ }
679
+
680
+ private resolveNumericPredicateValue(source: VectorMatch, predicate: NumericPredicate): number | null {
681
+ const meta = source.metadata || {};
682
+ const field = predicate.field;
683
+ const entries = Object.entries(meta)
684
+ .filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object');
685
+
686
+ if (field) {
687
+ const normalizedField = this.normalizeComparableField(field);
688
+ const exact = entries.find(([key]) => this.normalizeComparableField(key) === normalizedField);
689
+ const fuzzy = exact ?? entries
690
+ .map(([key, value]) => ({ key, value, score: this.fieldSimilarityScore(key, field) }))
691
+ .filter(candidate => candidate.score > 0)
692
+ .sort((a, b) => b.score - a.score)[0];
693
+ if (fuzzy) {
694
+ const value = this.toFiniteNumber(Array.isArray(fuzzy) ? fuzzy[1] : fuzzy.value);
695
+ if (value !== null) return value;
696
+ }
697
+
698
+ const contentValue = this.extractNumericValueFromContent(source.content, field);
699
+ if (contentValue !== null) return contentValue;
700
+ }
701
+
702
+ for (const [key, value] of entries) {
703
+ if (/(id|sku|ean|upc|phone|zip|postal|code)/i.test(key)) continue;
704
+ const numeric = this.toFiniteNumber(value);
705
+ if (numeric !== null) return numeric;
706
+ }
707
+
708
+ return null;
709
+ }
710
+
711
+ private extractNumericValueFromContent(content: string, field: string): number | null {
712
+ const escapedWords = field
713
+ .split(/\s+|_+|-+/)
714
+ .filter(Boolean)
715
+ .map(word => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
716
+ if (escapedWords.length === 0) return null;
717
+
718
+ const pattern = new RegExp(`(?:${escapedWords.join('[\\\\s_:-]*')})\\s*[:\\-–]?\\s*([\\d,]+(?:\\.\\d+)?)`, 'i');
719
+ const match = content.match(pattern);
720
+ return match ? this.toFiniteNumber(match[1]) : null;
721
+ }
722
+
723
+ private matchesNumericPredicate(value: number, predicate: NumericPredicate): boolean {
724
+ switch (predicate.operator) {
725
+ case 'gt':
726
+ return value > predicate.value;
727
+ case 'gte':
728
+ return value >= predicate.value;
729
+ case 'lt':
730
+ return value < predicate.value;
731
+ case 'lte':
732
+ return value <= predicate.value;
733
+ case 'eq':
734
+ default:
735
+ return value === predicate.value;
736
+ }
737
+ }
738
+
739
+ private normalizeComparableField(value: string): string {
740
+ return value.toLowerCase().replace(/[^a-z0-9]/g, '');
741
+ }
742
+
743
+ private fieldSimilarityScore(candidate: string, requested: string): number {
744
+ const normalizedCandidate = this.normalizeComparableField(candidate);
745
+ const normalizedRequested = this.normalizeComparableField(requested);
746
+ if (normalizedCandidate === normalizedRequested) return 10;
747
+ if (normalizedCandidate.includes(normalizedRequested) || normalizedRequested.includes(normalizedCandidate)) return 8;
748
+
749
+ const candidateTokens = this.fieldTokens(candidate);
750
+ const requestedTokens = this.fieldTokens(requested);
751
+ const overlap = requestedTokens.filter(token => candidateTokens.includes(token)).length;
752
+ if (overlap === 0) return 0;
753
+ return overlap / Math.max(requestedTokens.length, candidateTokens.length);
754
+ }
755
+
756
+ private fieldTokens(value: string): string[] {
757
+ return value
758
+ .toLowerCase()
759
+ .split(/[^a-z0-9]+/)
760
+ .map(token => token.replace(/ies$/, 'y').replace(/s$/, ''))
761
+ .filter(token => token.length > 1);
762
+ }
763
+
764
+ private toFiniteNumber(value: unknown): number | null {
765
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
766
+ const numeric = Number(String(value ?? '').replace(/[$,% ,]/g, ''));
767
+ return Number.isFinite(numeric) ? numeric : null;
768
+ }
769
+
577
770
  async retrieve(query: string, options: { namespace?: string; topK?: number; filter?: Record<string, unknown> }): Promise<RetrievalResult> {
578
771
  const ns = options.namespace ?? this.config.projectId;
579
772
  const topK = options.topK ?? 5;
@@ -582,7 +775,7 @@ You are a helpful product assistant. Use the provided context to answer question
582
775
  let queryVector = this.embeddingCache.get(cacheKey);
583
776
 
584
777
  // Determine strategy
585
- const strategy = await QueryProcessor.determineRetrievalStrategy(query, this.llmProvider);
778
+ const strategy = await QueryProcessor.determineRetrievalStrategy(query, this.llmRouter.get('fast'));
586
779
  console.debug(`[Pipeline] Determined retrieval strategy: ${strategy}`);
587
780
 
588
781
  const [retrievedVector, graphData] = await Promise.all([
@@ -602,11 +795,35 @@ You are a helpful product assistant. Use the provided context to answer question
602
795
  queryVector = retrievedVector;
603
796
  }
604
797
 
798
+ const baseFilter = { ...(options.filter ?? {}), queryText: query } as QueryFilter;
799
+ const numericPredicates = QueryProcessor.extractNumericPredicates(query, this.config.rag?.filterableFields);
800
+ if (numericPredicates.length > 0) {
801
+ baseFilter.__numericPredicates = numericPredicates;
802
+ }
803
+ const retrievalLimit = numericPredicates.length > 0 ? Math.max(topK * 20, 100) : topK;
804
+
605
805
  const sources = (strategy === 'vector' || strategy === 'both') && queryVector && queryVector.length > 0
606
- ? await this.vectorDB.query(queryVector, topK, ns, options.filter)
806
+ ? this.applyStructuredFilters(
807
+ await this.vectorDB.query(queryVector, retrievalLimit, ns, baseFilter),
808
+ baseFilter,
809
+ )
607
810
  : [];
608
811
 
609
- return { sources, graphData };
812
+ // Multi-Vector Support: Check for parent_id in metadata
813
+ const resolvedSources: VectorMatch[] = [];
814
+ for (const source of sources) {
815
+ const parentId = source.metadata?.parent_id;
816
+ if (parentId) {
817
+ console.log(`[Pipeline] Multi-Vector: Found child chunk. Parent ID: ${parentId}`);
818
+ // In a full implementation, we would fetch the parent document here.
819
+ // For now, we pass the child chunk forward.
820
+ resolvedSources.push(source);
821
+ } else {
822
+ resolvedSources.push(source);
823
+ }
824
+ }
825
+
826
+ return { sources: resolvedSources, graphData };
610
827
  }
611
828
 
612
829
  /** 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
  }
@@ -12,10 +12,17 @@ export interface QueryFieldHint {
12
12
  value: string;
13
13
  }
14
14
 
15
+ export interface NumericPredicate {
16
+ field?: string;
17
+ operator: 'gt' | 'gte' | 'lt' | 'lte' | 'eq';
18
+ value: number;
19
+ }
20
+
15
21
  export interface QueryFilter {
16
22
  metadata?: Record<string, string>;
17
23
  keywords?: string[];
18
24
  queryText?: string;
25
+ __numericPredicates?: NumericPredicate[];
19
26
  [key: string]: unknown;
20
27
  }
21
28
 
@@ -172,6 +179,92 @@ export class QueryProcessor {
172
179
  return [...hints.values()];
173
180
  }
174
181
 
182
+ static extractNumericPredicates(question: string, validFields: string[] = []): NumericPredicate[] {
183
+ const predicates: NumericPredicate[] = [];
184
+ const seen = new Set<string>();
185
+ const comparatorPattern = [
186
+ 'greater than or equal to',
187
+ 'more than or equal to',
188
+ 'less than or equal to',
189
+ 'greater than',
190
+ 'more than',
191
+ 'less than',
192
+ 'equal to',
193
+ 'at least',
194
+ 'at most',
195
+ 'above',
196
+ 'over',
197
+ 'below',
198
+ 'under',
199
+ 'equals?',
200
+ '>=',
201
+ '<=',
202
+ '>',
203
+ '<',
204
+ '=',
205
+ ].join('|');
206
+
207
+ const addPredicate = (rawField: string | undefined, rawOperator: string, rawValue: string) => {
208
+ const value = Number(rawValue.replace(/,/g, ''));
209
+ if (!Number.isFinite(value)) return;
210
+
211
+ const operator = this.normalizeNumericOperator(rawOperator);
212
+ const field = rawField ? this.normalizePredicateField(rawField, validFields) : undefined;
213
+ const key = `${field ?? '*'}::${operator}::${value}`;
214
+ if (seen.has(key)) return;
215
+ seen.add(key);
216
+ predicates.push({ ...(field ? { field } : {}), operator, value });
217
+ };
218
+
219
+ const scopedPatterns = [
220
+ new RegExp(`\\b(?:whose|with|having|where|that\\s+have|which\\s+have)\\s+([a-zA-Z][a-zA-Z0-9_\\s\\-/]{1,80}?)\\s+(?:is|are|was|were)?\\s*(?:${comparatorPattern})\\s+([\\d,]+(?:\\.\\d+)?)`, 'gi'),
221
+ new RegExp(`\\b([a-zA-Z][a-zA-Z0-9_\\s\\-/]{1,80}?)\\s+(?:is|are|was|were)?\\s*(?:${comparatorPattern})\\s+([\\d,]+(?:\\.\\d+)?)`, 'gi'),
222
+ ];
223
+
224
+ for (const pattern of scopedPatterns) {
225
+ for (const match of question.matchAll(pattern)) {
226
+ const full = match[0];
227
+ const operatorMatch = full.match(new RegExp(`(${comparatorPattern})`, 'i'));
228
+ if (!operatorMatch) continue;
229
+ addPredicate(match[1], operatorMatch[1], match[2]);
230
+ }
231
+ }
232
+
233
+ for (const match of question.matchAll(/\b([a-zA-Z][a-zA-Z0-9_\s\-/]{1,80}?)\s*(>=|<=|>|<|=)\s*([\d,]+(?:\.\d+)?)/g)) {
234
+ addPredicate(match[1], match[2], match[3]);
235
+ }
236
+
237
+ return predicates;
238
+ }
239
+
240
+ private static normalizeNumericOperator(operator: string): NumericPredicate['operator'] {
241
+ const op = operator.toLowerCase().trim();
242
+ if (op === '>' || /\b(greater than|more than|above|over)\b/.test(op)) return 'gt';
243
+ if (op === '>=' || /\b(greater than or equal to|more than or equal to|at least)\b/.test(op)) return 'gte';
244
+ if (op === '<' || /\b(less than|below|under)\b/.test(op)) return 'lt';
245
+ if (op === '<=' || /\b(less than or equal to|at most)\b/.test(op)) return 'lte';
246
+ return 'eq';
247
+ }
248
+
249
+ private static normalizePredicateField(field: string, validFields: string[]): string {
250
+ const cleaned = this.normalizeHintValue(field)
251
+ .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, ' ')
252
+ .replace(/\s+/g, ' ')
253
+ .trim();
254
+
255
+ if (validFields.length === 0) return cleaned;
256
+ const comparable = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
257
+ const cleanedComparable = comparable(cleaned);
258
+ const matchedField = validFields.find(fieldName => {
259
+ const candidate = comparable(fieldName);
260
+ return candidate === cleanedComparable ||
261
+ candidate.includes(cleanedComparable) ||
262
+ cleanedComparable.includes(candidate);
263
+ });
264
+
265
+ return matchedField ?? cleaned;
266
+ }
267
+
175
268
  /**
176
269
  * Constructs a QueryFilter object from extracted hints.
177
270
  *
@@ -196,6 +289,11 @@ export class QueryProcessor {
196
289
  if (Object.keys(filter.metadata || {}).length === 0) delete filter.metadata;
197
290
  if (filter.keywords && filter.keywords.length === 0) delete filter.keywords;
198
291
 
292
+ const numericPredicates = this.extractNumericPredicates(question);
293
+ if (numericPredicates.length > 0) {
294
+ filter.__numericPredicates = numericPredicates;
295
+ }
296
+
199
297
  return filter;
200
298
  }
201
299
 
@@ -166,13 +166,15 @@ export function createStreamHandler(configOrPlugin?: Partial<RagConfig> | Vector
166
166
  enqueue(sseObservabilityFrame(responseChunk.trace));
167
167
  }
168
168
 
169
+
169
170
  if (sources.length > 0) {
170
171
  try {
171
- const llmProvider = plugin.getLLMProvider?.();
172
- const uiTransformation = responseChunk?.ui_transformation ??
173
- (llmProvider
174
- ? await UITransformer.analyzeAndDecide(message, sources, llmProvider)
175
- : UITransformer.transform(message, sources, plugin.getConfig()));
172
+ // The pipeline already computed ui_transformation inside askStream().
173
+ // We ONLY fall back to the cheap heuristic transform (no LLM call) when
174
+ // the pipeline didn't produce one — never call analyzeAndDecide() here.
175
+ const uiTransformation =
176
+ responseChunk?.ui_transformation ??
177
+ UITransformer.transform(message, sources, plugin.getConfig());
176
178
 
177
179
  if (uiTransformation) {
178
180
  enqueue(sseUIFrame(uiTransformation));
@@ -185,6 +187,7 @@ export function createStreamHandler(configOrPlugin?: Partial<RagConfig> | Vector
185
187
  } catch { /* silent */ }
186
188
  }
187
189
  }
190
+
188
191
  }
189
192
  }
190
193
  } catch (streamError) {