@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/hooks/useRagChat.ts
CHANGED
|
@@ -128,10 +128,36 @@ export function useRagChat(
|
|
|
128
128
|
let sources: VectorMatch[] = [];
|
|
129
129
|
let uiTransformation: unknown = null;
|
|
130
130
|
let trace: ObservabilityTrace | undefined;
|
|
131
|
-
// Buffer for partial SSE frames that arrive split across reads
|
|
132
131
|
let buffer = '';
|
|
133
132
|
|
|
134
|
-
//
|
|
133
|
+
// ── RAF-based render batching ─────────────────────────────────────
|
|
134
|
+
// Accumulate text chunks in a ref and flush to React state at ~60fps
|
|
135
|
+
// instead of calling setMessages on every SSE frame. This eliminates
|
|
136
|
+
// O(N) ReactMarkdown re-parses where N = number of token chunks.
|
|
137
|
+
let pendingFlush = false;
|
|
138
|
+
const pendingContentRef = { current: '' };
|
|
139
|
+
|
|
140
|
+
const flushTextUpdate = (msgId: string) => {
|
|
141
|
+
pendingFlush = false;
|
|
142
|
+
const snapshot = pendingContentRef.current;
|
|
143
|
+
setMessages((prev) =>
|
|
144
|
+
prev.map((msg) =>
|
|
145
|
+
msg.id === msgId
|
|
146
|
+
? { ...msg, content: snapshot }
|
|
147
|
+
: msg
|
|
148
|
+
)
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const scheduleFlush = (msgId: string) => {
|
|
153
|
+
if (!pendingFlush) {
|
|
154
|
+
pendingFlush = true;
|
|
155
|
+
requestAnimationFrame(() => flushTextUpdate(msgId));
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
// ─────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
// Add a placeholder assistant message
|
|
135
161
|
const assistantMessageId = `assistant_${Date.now()}`;
|
|
136
162
|
setMessages((prev) => [
|
|
137
163
|
...prev,
|
|
@@ -149,40 +175,50 @@ export function useRagChat(
|
|
|
149
175
|
|
|
150
176
|
buffer += decoder.decode(value, { stream: true });
|
|
151
177
|
|
|
152
|
-
// Only process complete frames (ending with \n\n)
|
|
153
178
|
const lastBoundary = buffer.lastIndexOf('\n\n');
|
|
154
|
-
if (lastBoundary === -1) continue;
|
|
179
|
+
if (lastBoundary === -1) continue;
|
|
155
180
|
|
|
156
181
|
const complete = buffer.slice(0, lastBoundary + 2);
|
|
157
182
|
buffer = buffer.slice(lastBoundary + 2);
|
|
158
183
|
|
|
184
|
+
let hasNonTextFrame = false;
|
|
185
|
+
|
|
159
186
|
for (const frame of parseSseChunk(complete)) {
|
|
160
187
|
if (frame.type === 'text' && frame.text) {
|
|
161
188
|
assistantContent += frame.text;
|
|
189
|
+
pendingContentRef.current = assistantContent;
|
|
190
|
+
// Schedule a batched RAF flush instead of immediate setMessages
|
|
191
|
+
scheduleFlush(assistantMessageId);
|
|
162
192
|
} else if (frame.type === 'metadata') {
|
|
163
193
|
sources = frame.sources ?? [];
|
|
194
|
+
hasNonTextFrame = true;
|
|
164
195
|
} else if (frame.type === 'ui_transformation') {
|
|
165
196
|
uiTransformation = frame.data;
|
|
197
|
+
hasNonTextFrame = true;
|
|
166
198
|
} else if (frame.type === 'observability') {
|
|
167
199
|
trace = (frame as SseObservabilityFrame).data;
|
|
200
|
+
hasNonTextFrame = true;
|
|
168
201
|
} else if (frame.type === 'error') {
|
|
169
202
|
setError(frame.error || 'Stream error');
|
|
170
203
|
}
|
|
171
204
|
}
|
|
172
205
|
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
206
|
+
// Flush non-text metadata immediately (sources, UI transform, trace)
|
|
207
|
+
if (hasNonTextFrame) {
|
|
208
|
+
pendingFlush = false; // cancel pending text flush — we'll do a full update
|
|
209
|
+
setMessages((prev) =>
|
|
210
|
+
prev.map((msg) =>
|
|
211
|
+
msg.id === assistantMessageId
|
|
212
|
+
? {
|
|
213
|
+
...msg,
|
|
214
|
+
content: assistantContent,
|
|
215
|
+
sources: sources.length > 0 ? sources : msg.sources,
|
|
216
|
+
uiTransformation: uiTransformation || (msg as RagMessage).uiTransformation,
|
|
217
|
+
}
|
|
218
|
+
: msg
|
|
219
|
+
)
|
|
220
|
+
);
|
|
221
|
+
}
|
|
186
222
|
}
|
|
187
223
|
|
|
188
224
|
// Process any remaining buffered data
|
|
@@ -25,6 +25,9 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
25
25
|
private readonly systemPrompt: string;
|
|
26
26
|
private readonly maxTokens: number;
|
|
27
27
|
private readonly temperature: number;
|
|
28
|
+
private readonly baseUrl: string;
|
|
29
|
+
private readonly apiKey?: string;
|
|
30
|
+
private readonly resolvedHeaders: Record<string, string>;
|
|
28
31
|
|
|
29
32
|
constructor(config: LLMConfig | EmbeddingConfig) {
|
|
30
33
|
this.model = config.model;
|
|
@@ -46,19 +49,22 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
46
49
|
this.systemPrompt = llmConfig.systemPrompt ?? 'You are a helpful AI assistant. Use the provided context to answer the user.';
|
|
47
50
|
this.maxTokens = llmConfig.maxTokens ?? 1024;
|
|
48
51
|
this.temperature = llmConfig.temperature ?? 0;
|
|
52
|
+
this.apiKey = config.apiKey;
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
if (!baseUrl) {
|
|
54
|
+
this.baseUrl = llmConfig.baseUrl ?? this.opts.baseUrl ?? '';
|
|
55
|
+
if (!this.baseUrl) {
|
|
52
56
|
throw new Error('[UniversalLLMAdapter] baseUrl is required in config or config.options');
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
this.resolvedHeaders = {
|
|
60
|
+
'Content-Type': 'application/json',
|
|
61
|
+
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
|
|
62
|
+
...(this.opts.headers || {}),
|
|
63
|
+
};
|
|
64
|
+
|
|
55
65
|
this.http = axios.create({
|
|
56
|
-
baseURL: baseUrl,
|
|
57
|
-
headers:
|
|
58
|
-
'Content-Type': 'application/json',
|
|
59
|
-
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
|
|
60
|
-
...(this.opts.headers || {}),
|
|
61
|
-
},
|
|
66
|
+
baseURL: this.baseUrl,
|
|
67
|
+
headers: this.resolvedHeaders,
|
|
62
68
|
timeout: this.opts.timeout ?? 60_000,
|
|
63
69
|
});
|
|
64
70
|
}
|
|
@@ -73,7 +79,6 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
73
79
|
|
|
74
80
|
let payload: unknown;
|
|
75
81
|
if (this.opts.chatPayloadTemplate) {
|
|
76
|
-
// The user wants to map messages and parameters to their unique JSON shape
|
|
77
82
|
payload = buildPayload(this.opts.chatPayloadTemplate, {
|
|
78
83
|
model: this.model,
|
|
79
84
|
messages: formattedMessages,
|
|
@@ -81,7 +86,6 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
81
86
|
temperature: this.temperature,
|
|
82
87
|
});
|
|
83
88
|
} else {
|
|
84
|
-
// Fallback to OpenAI standard which covers 90% of open-source models (vLLM, LMStudio, Ollama, etc.)
|
|
85
89
|
payload = {
|
|
86
90
|
model: this.model,
|
|
87
91
|
messages: formattedMessages,
|
|
@@ -102,6 +106,102 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
102
106
|
return String(result);
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Streaming chat using native fetch + ReadableStream.
|
|
111
|
+
* Parses OpenAI-compatible SSE frames: `data: {...}\n\n`
|
|
112
|
+
* Works with vLLM, LMStudio, Together AI, Fireworks, and any OpenAI-compatible API.
|
|
113
|
+
*/
|
|
114
|
+
async *chatStream(messages: ChatMessage[], context?: string): AsyncIterable<string> {
|
|
115
|
+
const path = this.opts.chatPath ?? '/chat/completions';
|
|
116
|
+
const url = `${this.baseUrl.replace(/\/$/, '')}${path}`;
|
|
117
|
+
// Delta extract path — same root as chat but with delta instead of message
|
|
118
|
+
const extractPath = (this.opts.responseExtractPath ?? 'choices[0].message.content')
|
|
119
|
+
.replace('message.content', 'delta.content');
|
|
120
|
+
|
|
121
|
+
const formattedMessages = [
|
|
122
|
+
{ role: 'system', content: `${this.systemPrompt}\n\nContext:\n${context ?? 'None'}` },
|
|
123
|
+
...messages,
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
let payload: unknown;
|
|
127
|
+
if (this.opts.chatPayloadTemplate) {
|
|
128
|
+
payload = buildPayload(this.opts.chatPayloadTemplate, {
|
|
129
|
+
model: this.model,
|
|
130
|
+
messages: formattedMessages,
|
|
131
|
+
maxTokens: this.maxTokens,
|
|
132
|
+
temperature: this.temperature,
|
|
133
|
+
});
|
|
134
|
+
// Inject stream: true into the payload if it's an object
|
|
135
|
+
if (typeof payload === 'object' && payload !== null) {
|
|
136
|
+
(payload as Record<string, unknown>).stream = true;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
payload = {
|
|
140
|
+
model: this.model,
|
|
141
|
+
messages: formattedMessages,
|
|
142
|
+
max_tokens: this.maxTokens,
|
|
143
|
+
temperature: this.temperature,
|
|
144
|
+
stream: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const response = await fetch(url, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: this.resolvedHeaders,
|
|
151
|
+
body: JSON.stringify(payload),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
const errorText = await response.text().catch(() => response.statusText);
|
|
156
|
+
throw new Error(`[UniversalLLMAdapter] Streaming request failed (${response.status}): ${errorText}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!response.body) {
|
|
160
|
+
throw new Error('[UniversalLLMAdapter] Response body is null — server did not send a streaming response.');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const reader = response.body.getReader();
|
|
164
|
+
const decoder = new TextDecoder('utf-8');
|
|
165
|
+
let buffer = '';
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
while (true) {
|
|
169
|
+
const { done, value } = await reader.read();
|
|
170
|
+
if (done) break;
|
|
171
|
+
|
|
172
|
+
buffer += decoder.decode(value, { stream: true });
|
|
173
|
+
const lines = buffer.split('\n');
|
|
174
|
+
buffer = lines.pop() ?? '';
|
|
175
|
+
|
|
176
|
+
for (const line of lines) {
|
|
177
|
+
const trimmed = line.trim();
|
|
178
|
+
if (!trimmed || trimmed === 'data: [DONE]') continue;
|
|
179
|
+
if (!trimmed.startsWith('data:')) continue;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const json = JSON.parse(trimmed.slice(5).trim());
|
|
183
|
+
const text = resolvePath(json, extractPath);
|
|
184
|
+
if (text && typeof text === 'string') yield text;
|
|
185
|
+
} catch {
|
|
186
|
+
// Malformed SSE line — skip
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Flush remaining buffer
|
|
192
|
+
if (buffer.trim() && buffer.trim() !== 'data: [DONE]') {
|
|
193
|
+
const jsonStr = buffer.replace(/^data:\s*/, '').trim();
|
|
194
|
+
try {
|
|
195
|
+
const json = JSON.parse(jsonStr);
|
|
196
|
+
const text = resolvePath(json, extractPath);
|
|
197
|
+
if (text && typeof text === 'string') yield text;
|
|
198
|
+
} catch { /* ignore final partial line */ }
|
|
199
|
+
}
|
|
200
|
+
} finally {
|
|
201
|
+
reader.releaseLock();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
105
205
|
async embed(text: string): Promise<number[]> {
|
|
106
206
|
const path = this.opts.embedPath ?? '/embeddings';
|
|
107
207
|
|
|
@@ -112,7 +212,6 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
112
212
|
input: text,
|
|
113
213
|
});
|
|
114
214
|
} else {
|
|
115
|
-
// OpenAI standard fallback
|
|
116
215
|
payload = {
|
|
117
216
|
model: this.model,
|
|
118
217
|
input: text,
|
|
@@ -133,8 +232,6 @@ export class UniversalLLMAdapter implements ILLMProvider {
|
|
|
133
232
|
|
|
134
233
|
async batchEmbed(texts: string[]): Promise<number[][]> {
|
|
135
234
|
const vectors: number[][] = [];
|
|
136
|
-
// Sequential fallback if the provider doesn't have a specific batch template
|
|
137
|
-
// In production, we should add a batchEmbedPayloadTemplate to UniversalLLMOptions
|
|
138
235
|
for (const text of texts) {
|
|
139
236
|
vectors.push(await this.embed(text));
|
|
140
237
|
}
|
|
@@ -70,12 +70,23 @@ export class ChromaDBProvider extends BaseVectorProvider {
|
|
|
70
70
|
await this.http.post(`/api/v1/collections/${this.collectionId}/add`, payload);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
74
73
|
async query(vector: number[], topK: number, namespace?: string, _filter?: Record<string, unknown>): Promise<VectorMatch[]> {
|
|
74
|
+
const sanitizedFilter = this.sanitizeFilter(_filter);
|
|
75
|
+
const whereClauses: Record<string, unknown>[] = [];
|
|
76
|
+
if (namespace) whereClauses.push({ namespace: { $eq: namespace } });
|
|
77
|
+
Object.entries(sanitizedFilter).forEach(([key, value]) => {
|
|
78
|
+
if (key === 'namespace') return;
|
|
79
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
80
|
+
whereClauses.push({ [key]: { $eq: value } });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
75
84
|
const payload = {
|
|
76
85
|
query_embeddings: [vector],
|
|
77
86
|
n_results: topK,
|
|
78
|
-
where:
|
|
87
|
+
where: whereClauses.length > 1
|
|
88
|
+
? { $and: whereClauses }
|
|
89
|
+
: whereClauses[0],
|
|
79
90
|
};
|
|
80
91
|
const { data } = await this.http.post(`/api/v1/collections/${this.collectionId}/query`, payload);
|
|
81
92
|
|
|
@@ -68,13 +68,29 @@ export class MilvusProvider extends BaseVectorProvider {
|
|
|
68
68
|
await this.http.post('/v1/vector/upsert', payload);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
72
71
|
async query(vector: number[], topK: number, namespace?: string, _filter?: Record<string, unknown>): Promise<VectorMatch[]> {
|
|
72
|
+
const sanitizedFilter = this.sanitizeFilter(_filter);
|
|
73
|
+
|
|
74
|
+
// Build Milvus boolean expression from filter fields.
|
|
75
|
+
// Namespace filtering is expressed as a field equality check.
|
|
76
|
+
const filterParts: string[] = [];
|
|
77
|
+
if (namespace) {
|
|
78
|
+
filterParts.push(`namespace == "${namespace}"`);
|
|
79
|
+
}
|
|
80
|
+
for (const [key, value] of Object.entries(sanitizedFilter)) {
|
|
81
|
+
if (key === 'queryText' || key === 'keywords') continue;
|
|
82
|
+
if (typeof value === 'string') {
|
|
83
|
+
filterParts.push(`metadata["${key}"] == "${value.replace(/"/g, '\\"')}"`);
|
|
84
|
+
} else if (typeof value === 'number') {
|
|
85
|
+
filterParts.push(`metadata["${key}"] == ${value}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
const payload = {
|
|
74
90
|
collectionName: this.indexName,
|
|
75
91
|
vector: vector,
|
|
76
92
|
limit: topK,
|
|
77
|
-
filter:
|
|
93
|
+
filter: filterParts.length > 0 ? filterParts.join(' && ') : undefined,
|
|
78
94
|
outputFields: ['content', 'metadata'],
|
|
79
95
|
searchParams: {
|
|
80
96
|
nprobe: (this.config.options.nprobe as number) || 16,
|
|
@@ -235,12 +235,9 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
|
|
|
235
235
|
console.log(`[MultiTablePostgresProvider] --- Starting Multi-Table Search ---`);
|
|
236
236
|
|
|
237
237
|
const queryText = _filter?.queryText as string | undefined;
|
|
238
|
-
const entityHints = Array.isArray(_filter?.
|
|
239
|
-
? (_filter.
|
|
240
|
-
.
|
|
241
|
-
typeof hint === 'object' && hint !== null && typeof (hint as Record<string, unknown>).value === 'string'
|
|
242
|
-
)
|
|
243
|
-
.map((hint) => hint.value.trim().toLowerCase())
|
|
238
|
+
const entityHints = Array.isArray(_filter?.keywords)
|
|
239
|
+
? (_filter.keywords as string[])
|
|
240
|
+
.map((k) => k.trim().toLowerCase())
|
|
244
241
|
.filter(Boolean)
|
|
245
242
|
: [];
|
|
246
243
|
|
|
@@ -252,15 +249,17 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
|
|
|
252
249
|
if (entityHints.length > 0) {
|
|
253
250
|
// Use extracted entities as high-signal keywords
|
|
254
251
|
return entityHints
|
|
255
|
-
.map(h => h.
|
|
252
|
+
.map(h => h.replace(/\s+/g, ' & ')) // Replace spaces with & for valid tsquery
|
|
256
253
|
.join(' & ');
|
|
257
254
|
}
|
|
258
255
|
|
|
259
256
|
if (queryText) {
|
|
260
257
|
// Fallback to cleaned raw text if no high-signal hints were extracted
|
|
258
|
+
// Added 'get', 'details', 'information', 'info' to stop words
|
|
261
259
|
return queryText
|
|
262
260
|
.toLowerCase()
|
|
263
|
-
.replace(/\b(show|me|the|product[s]?|item[s]?|card[s]?|of|category|find|list|browse|what|are|display|all|under|for|in|with|about)\b/g, '')
|
|
261
|
+
.replace(/\b(show|me|the|product[s]?|item[s]?|card[s]?|organization[s]?|comp(?:any|anies)|of|category|find|list|browse|what|are|display|all|whose|that|which|have|has|having|greater|than|more|less|equal|equals|above|below|over|under|at|least|most|for|in|with|about|get|details|information|info)\b/g, '')
|
|
262
|
+
.replace(/\b\d+(?:\.\d+)?\b/g, '')
|
|
264
263
|
.trim()
|
|
265
264
|
.replace(/\s+/g, ' & ');
|
|
266
265
|
}
|
|
@@ -268,13 +267,14 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
|
|
|
268
267
|
};
|
|
269
268
|
|
|
270
269
|
const dynamicKeywordQuery = getDynamicKeywordQuery();
|
|
270
|
+
const tableLimit = Math.max(topK, 50);
|
|
271
271
|
|
|
272
272
|
const queryPromises = this.tables.map(async (table) => {
|
|
273
273
|
try {
|
|
274
274
|
let sqlQuery = '';
|
|
275
275
|
let params: unknown[] = [];
|
|
276
276
|
|
|
277
|
-
if (queryText) {
|
|
277
|
+
if (queryText && dynamicKeywordQuery) {
|
|
278
278
|
const hasEntityHints = entityHints.length > 0;
|
|
279
279
|
const exactNameScoreExpr = hasEntityHints
|
|
280
280
|
? `+ (
|
|
@@ -295,17 +295,17 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
|
|
|
295
295
|
((1 - (embedding <=> $1::vector)) + (COALESCE(ts_rank(to_tsvector('english', (to_jsonb(t) - 'embedding')::text), to_tsquery('english', $2)), 0) * 2.0) ${exactNameScoreExpr}) AS hybrid_score
|
|
296
296
|
FROM "${table}" t
|
|
297
297
|
ORDER BY hybrid_score DESC
|
|
298
|
-
LIMIT
|
|
298
|
+
LIMIT ${tableLimit}
|
|
299
299
|
`;
|
|
300
300
|
params = [vectorLiteral, dynamicKeywordQuery];
|
|
301
301
|
} else {
|
|
302
302
|
// Fallback to Vector-only Search
|
|
303
303
|
sqlQuery = `
|
|
304
304
|
SELECT *,
|
|
305
|
-
|
|
305
|
+
(1 - (embedding <=> $1::vector)) AS hybrid_score
|
|
306
306
|
FROM "${table}" t
|
|
307
307
|
ORDER BY hybrid_score DESC
|
|
308
|
-
LIMIT
|
|
308
|
+
LIMIT ${tableLimit}
|
|
309
309
|
`;
|
|
310
310
|
params = [vectorLiteral];
|
|
311
311
|
}
|
|
@@ -170,7 +170,7 @@ export class PostgreSQLProvider extends BaseVectorProvider {
|
|
|
170
170
|
const filterConditions = Object.entries(publicFilter)
|
|
171
171
|
.map(([key, val]) => {
|
|
172
172
|
const paramIdx = params.length + 1;
|
|
173
|
-
params.push(
|
|
173
|
+
params.push(String(val));
|
|
174
174
|
return `metadata->>'${key}' = $${paramIdx}`;
|
|
175
175
|
})
|
|
176
176
|
.join(' AND ');
|
|
@@ -211,7 +211,7 @@ export class QdrantProvider extends BaseVectorProvider {
|
|
|
211
211
|
limit: topK,
|
|
212
212
|
with_payload: true,
|
|
213
213
|
params: {
|
|
214
|
-
hnsw_ef: (this.config.options
|
|
214
|
+
hnsw_ef: ((this.config.options as Record<string, unknown>)?.efSearch as number | undefined) || Math.max(topK * 20, 128),
|
|
215
215
|
exact: false
|
|
216
216
|
},
|
|
217
217
|
filter: must.length > 0 ? { must } : undefined,
|
|
@@ -90,16 +90,15 @@ export class RedisProvider extends BaseVectorProvider {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
94
|
-
* Returns
|
|
93
|
+
* Check reachability via a PING command.
|
|
94
|
+
* Returns false on connection failure so health checks surface real problems.
|
|
95
95
|
*/
|
|
96
96
|
async ping(): Promise<boolean> {
|
|
97
97
|
try {
|
|
98
98
|
await this.http.post('/', ['PING']);
|
|
99
99
|
return true;
|
|
100
100
|
} catch {
|
|
101
|
-
|
|
102
|
-
return true;
|
|
101
|
+
return false;
|
|
103
102
|
}
|
|
104
103
|
}
|
|
105
104
|
|
|
@@ -40,6 +40,7 @@ export class WeaviateProvider extends BaseVectorProvider {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
async upsert(doc: UpsertDocument, namespace?: string): Promise<void> {
|
|
43
|
+
const primitiveMetadata = this.extractPrimitiveMetadata(doc.metadata);
|
|
43
44
|
const payload = {
|
|
44
45
|
class: this.indexName,
|
|
45
46
|
id: doc.id,
|
|
@@ -47,6 +48,7 @@ export class WeaviateProvider extends BaseVectorProvider {
|
|
|
47
48
|
properties: {
|
|
48
49
|
content: doc.content,
|
|
49
50
|
metadata: JSON.stringify(doc.metadata || {}),
|
|
51
|
+
...primitiveMetadata,
|
|
50
52
|
namespace: namespace || '',
|
|
51
53
|
},
|
|
52
54
|
};
|
|
@@ -62,6 +64,7 @@ export class WeaviateProvider extends BaseVectorProvider {
|
|
|
62
64
|
properties: {
|
|
63
65
|
content: doc.content,
|
|
64
66
|
metadata: JSON.stringify(doc.metadata || {}),
|
|
67
|
+
...this.extractPrimitiveMetadata(doc.metadata),
|
|
65
68
|
namespace: namespace || '',
|
|
66
69
|
},
|
|
67
70
|
})),
|
|
@@ -70,12 +73,14 @@ export class WeaviateProvider extends BaseVectorProvider {
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
async query(vector: number[], topK: number, namespace?: string, _filter?: Record<string, unknown>): Promise<VectorMatch[]> {
|
|
76
|
+
// IMPORTANT: read queryText BEFORE calling sanitizeFilter() because sanitizeFilter strips it.
|
|
77
|
+
const queryText = _filter?.queryText as string | undefined;
|
|
73
78
|
const sanitizedFilter = this.sanitizeFilter(_filter);
|
|
74
|
-
const queryText = sanitizedFilter.queryText as string;
|
|
75
79
|
|
|
76
|
-
const searchParams = queryText
|
|
80
|
+
const searchParams = queryText
|
|
77
81
|
? `hybrid: { query: ${JSON.stringify(queryText)}, alpha: 0.5 }`
|
|
78
82
|
: `nearVector: { vector: ${JSON.stringify(vector)} }`;
|
|
83
|
+
const where = this.buildWhereFilter(namespace, sanitizedFilter);
|
|
79
84
|
|
|
80
85
|
const graphqlQuery = {
|
|
81
86
|
query: `
|
|
@@ -84,7 +89,7 @@ export class WeaviateProvider extends BaseVectorProvider {
|
|
|
84
89
|
${this.indexName}(
|
|
85
90
|
${searchParams}
|
|
86
91
|
limit: ${topK}
|
|
87
|
-
${
|
|
92
|
+
${where ? `where: ${where}` : ''}
|
|
88
93
|
) {
|
|
89
94
|
content
|
|
90
95
|
metadata
|
|
@@ -132,4 +137,37 @@ export class WeaviateProvider extends BaseVectorProvider {
|
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
async disconnect(): Promise<void> {}
|
|
140
|
+
|
|
141
|
+
private extractPrimitiveMetadata(metadata?: Record<string, unknown>): Record<string, string | number | boolean> {
|
|
142
|
+
const result: Record<string, string | number | boolean> = {};
|
|
143
|
+
Object.entries(metadata || {}).forEach(([key, value]) => {
|
|
144
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
145
|
+
result[key] = value;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private buildWhereFilter(namespace?: string, filter?: Record<string, unknown>): string | undefined {
|
|
152
|
+
const operands: string[] = [];
|
|
153
|
+
if (namespace) operands.push(this.weaviateOperand('namespace', namespace));
|
|
154
|
+
|
|
155
|
+
Object.entries(filter || {}).forEach(([key, value]) => {
|
|
156
|
+
if (key === 'namespace') return;
|
|
157
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
158
|
+
operands.push(this.weaviateOperand(key, value));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (operands.length === 0) return undefined;
|
|
163
|
+
if (operands.length === 1) return operands[0];
|
|
164
|
+
return `{ operator: And, operands: [${operands.join(', ')}] }`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private weaviateOperand(key: string, value: string | number | boolean): string {
|
|
168
|
+
const path = `path: [${JSON.stringify(key)}], operator: Equal`;
|
|
169
|
+
if (typeof value === 'number') return `{ ${path}, valueNumber: ${value} }`;
|
|
170
|
+
if (typeof value === 'boolean') return `{ ${path}, valueBoolean: ${value} }`;
|
|
171
|
+
return `{ ${path}, valueString: ${JSON.stringify(value)} }`;
|
|
172
|
+
}
|
|
135
173
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -125,7 +125,12 @@ export type VisualizationType =
|
|
|
125
125
|
| 'pie_chart'
|
|
126
126
|
| 'bar_chart'
|
|
127
127
|
| 'line_chart'
|
|
128
|
+
| 'histogram'
|
|
129
|
+
| 'horizontal_bar'
|
|
130
|
+
| 'scatter_plot'
|
|
128
131
|
| 'radar_chart'
|
|
132
|
+
| 'metric_card'
|
|
133
|
+
| 'geo_map'
|
|
129
134
|
| 'table'
|
|
130
135
|
| 'product_carousel'
|
|
131
136
|
| 'carousel'
|
|
@@ -173,6 +178,27 @@ export interface LineChartDataPoint {
|
|
|
173
178
|
[key: string]: unknown;
|
|
174
179
|
}
|
|
175
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Scatter plot data point
|
|
183
|
+
*/
|
|
184
|
+
export interface ScatterPlotDataPoint {
|
|
185
|
+
x: number;
|
|
186
|
+
y: number;
|
|
187
|
+
label?: string;
|
|
188
|
+
[key: string]: unknown;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* KPI / metric card representation
|
|
193
|
+
*/
|
|
194
|
+
export interface MetricCardData {
|
|
195
|
+
label: string;
|
|
196
|
+
value: number;
|
|
197
|
+
operation?: 'sum' | 'average' | 'count' | 'min' | 'max' | 'median';
|
|
198
|
+
unit?: string;
|
|
199
|
+
details?: Record<string, string | number | boolean>;
|
|
200
|
+
}
|
|
201
|
+
|
|
176
202
|
/**
|
|
177
203
|
* Table representation
|
|
178
204
|
*/
|
|
@@ -199,11 +199,13 @@ export function extractProductsFromSources(sources: VectorMatch[] | undefined, i
|
|
|
199
199
|
.filter(s => {
|
|
200
200
|
const m = s.metadata ?? {};
|
|
201
201
|
const keys = Object.keys(m).map(k => k.toLowerCase());
|
|
202
|
-
const
|
|
203
|
-
['price', 'image', 'img', 'thumbnail', 'images', '
|
|
202
|
+
const hasStrongProductKey = keys.some(k =>
|
|
203
|
+
['price', 'image', 'img', 'thumbnail', 'images', 'product', 'sku', 'cost'].includes(k)
|
|
204
204
|
);
|
|
205
|
+
const hasProductIdentity = keys.some(k => ['brand', 'model'].includes(k)) &&
|
|
206
|
+
keys.some(k => ['name', 'title', 'product_name', 'product'].includes(k));
|
|
205
207
|
const hasPricePattern = /\$\s*\d+/.test(s.content);
|
|
206
|
-
return
|
|
208
|
+
return hasStrongProductKey || hasProductIdentity || hasPricePattern || m.type === 'product';
|
|
207
209
|
})
|
|
208
210
|
.map(s => {
|
|
209
211
|
const m = (s.metadata ?? {}) as Record<string, unknown>;
|