@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.
- package/dist/{ILLMProvider-BOJFz3Na.d.mts → ILLMProvider-BQyKZLnd.d.mts} +10 -0
- package/dist/{ILLMProvider-BOJFz3Na.d.ts → ILLMProvider-BQyKZLnd.d.ts} +10 -0
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +1719 -467
- package/dist/handlers/index.mjs +1718 -466
- package/dist/{index-BwpcaziY.d.ts → index-A0GqPsaG.d.ts} +1 -1
- package/dist/{index-D3V9Et2M.d.mts → index-CDftK3qs.d.mts} +1 -1
- package/dist/index.css +26 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +246 -76
- package/dist/index.mjs +248 -76
- package/dist/server.d.mts +32 -5
- package/dist/server.d.ts +32 -5
- package/dist/server.js +1726 -671
- package/dist/server.mjs +1724 -669
- package/package.json +1 -1
- package/src/components/MarkdownComponents.tsx +3 -3
- package/src/components/MessageBubble.tsx +49 -2
- package/src/components/ProductCard.tsx +33 -6
- 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 +262 -45
- package/src/core/ProviderRegistry.ts +6 -0
- package/src/core/QueryProcessor.ts +98 -0
- package/src/handlers/index.ts +8 -5
- package/src/hooks/useRagChat.ts +53 -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 +12 -12
- 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/index.ts +26 -0
- package/src/utils/ProductExtractor.ts +5 -3
- package/src/utils/UITransformer.ts +1348 -489
- package/src/utils/synonyms.ts +2 -0
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();
|
|
@@ -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
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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;
|
|
494
|
+
const embedMs = retrieveEnd - embedStart;
|
|
431
495
|
const retrieveMs = retrieveEnd - embedStart;
|
|
432
496
|
|
|
433
497
|
// 3. Reranking
|
|
434
498
|
const rerankStart = performance.now();
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
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
|
|
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)
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
?
|
|
806
|
+
? this.applyStructuredFilters(
|
|
807
|
+
await this.vectorDB.query(queryVector, retrievalLimit, ns, baseFilter),
|
|
808
|
+
baseFilter,
|
|
809
|
+
)
|
|
607
810
|
: [];
|
|
608
811
|
|
|
609
|
-
|
|
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
|
|
package/src/handlers/index.ts
CHANGED
|
@@ -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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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) {
|