@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.
- package/dist/{ILLMProvider-BOJFz3Na.d.mts → ILLMProvider-Bw2A28nU.d.mts} +12 -0
- package/dist/{ILLMProvider-BOJFz3Na.d.ts → ILLMProvider-Bw2A28nU.d.ts} +12 -0
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +1874 -542
- package/dist/handlers/index.mjs +1873 -541
- package/dist/{index-D3V9Et2M.d.mts → index-B70ZLkfG.d.mts} +1 -1
- package/dist/{index-BwpcaziY.d.ts → index-DVu-mkAM.d.ts} +1 -1
- package/dist/index.css +83 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +330 -106
- package/dist/index.mjs +333 -107
- package/dist/server.d.mts +32 -5
- package/dist/server.d.ts +32 -5
- package/dist/server.js +1871 -736
- package/dist/server.mjs +1870 -735
- package/package.json +1 -1
- package/src/components/ChatWindow.tsx +24 -14
- package/src/components/MarkdownComponents.tsx +3 -3
- package/src/components/MessageBubble.tsx +89 -7
- package/src/components/ProductCard.tsx +29 -2
- package/src/components/UIDispatcher.tsx +1 -0
- package/src/components/VisualizationRenderer.tsx +143 -11
- package/src/config/EmbeddingStrategy.ts +5 -4
- package/src/config/RagConfig.ts +10 -0
- package/src/config/serverConfig.ts +16 -1
- package/src/core/LLMRouter.ts +79 -0
- package/src/core/Pipeline.ts +295 -51
- package/src/core/ProviderRegistry.ts +6 -0
- package/src/core/QueryProcessor.ts +108 -9
- package/src/handlers/index.ts +37 -11
- package/src/hooks/useRagChat.ts +77 -17
- package/src/llm/providers/UniversalLLMAdapter.ts +110 -13
- package/src/providers/vectordb/ChromaDBProvider.ts +13 -2
- package/src/providers/vectordb/MilvusProvider.ts +18 -2
- package/src/providers/vectordb/MultiTablePostgresProvider.ts +48 -16
- package/src/providers/vectordb/PostgreSQLProvider.ts +1 -1
- package/src/providers/vectordb/QdrantProvider.ts +1 -1
- package/src/providers/vectordb/RedisProvider.ts +3 -4
- package/src/providers/vectordb/WeaviateProvider.ts +41 -3
- package/src/types/chat.ts +2 -0
- package/src/types/index.ts +26 -0
- package/src/utils/ProductExtractor.ts +5 -3
- package/src/utils/SchemaMapper.ts +6 -4
- package/src/utils/UITransformer.ts +1350 -490
- 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
|
+
}
|
package/src/core/Pipeline.ts
CHANGED
|
@@ -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
|
|
203
|
+
You are a helpful assistant. Use the provided context to answer questions accurately.
|
|
202
204
|
|
|
203
|
-
###
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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;
|
|
498
|
+
const embedMs = retrieveEnd - embedStart;
|
|
431
499
|
const retrieveMs = retrieveEnd - embedStart;
|
|
432
500
|
|
|
433
501
|
// 3. Reranking
|
|
434
502
|
const rerankStart = performance.now();
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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 =
|
|
447
|
-
?
|
|
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
|
|
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
|
|
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
|
-
//
|
|
465
|
-
const
|
|
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.
|
|
515
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
?
|
|
833
|
+
? this.applyStructuredFilters(
|
|
834
|
+
await this.vectorDB.query(queryVector, retrievalLimit, ns, baseFilter),
|
|
835
|
+
baseFilter,
|
|
836
|
+
)
|
|
607
837
|
: [];
|
|
608
838
|
|
|
609
|
-
|
|
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
|
}
|