@retrivora-ai/rag-engine 1.8.9 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/{ILLMProvider-CbUtvWAW.d.mts → ILLMProvider-BQyKZLnd.d.mts} +10 -2
  2. package/dist/{ILLMProvider-CbUtvWAW.d.ts → ILLMProvider-BQyKZLnd.d.ts} +10 -2
  3. package/dist/handlers/index.d.mts +2 -2
  4. package/dist/handlers/index.d.ts +2 -2
  5. package/dist/handlers/index.js +1726 -522
  6. package/dist/handlers/index.mjs +1725 -521
  7. package/dist/{index-DgzcnqZs.d.ts → index-A0GqPsaG.d.ts} +1 -1
  8. package/dist/{index-c_y_5qdw.d.mts → index-CDftK3qs.d.mts} +1 -1
  9. package/dist/index.css +26 -0
  10. package/dist/index.d.mts +2 -2
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.js +246 -76
  13. package/dist/index.mjs +248 -76
  14. package/dist/server.d.mts +32 -5
  15. package/dist/server.d.ts +32 -5
  16. package/dist/server.js +1735 -733
  17. package/dist/server.mjs +1733 -731
  18. package/package.json +1 -1
  19. package/src/components/MarkdownComponents.tsx +3 -3
  20. package/src/components/MessageBubble.tsx +49 -2
  21. package/src/components/ProductCard.tsx +33 -6
  22. package/src/components/UIDispatcher.tsx +1 -0
  23. package/src/components/VisualizationRenderer.tsx +143 -11
  24. package/src/config/EmbeddingStrategy.ts +5 -4
  25. package/src/config/RagConfig.ts +10 -2
  26. package/src/config/serverConfig.ts +16 -1
  27. package/src/core/LLMRouter.ts +79 -0
  28. package/src/core/Pipeline.ts +262 -45
  29. package/src/core/ProviderRegistry.ts +6 -0
  30. package/src/core/QueryProcessor.ts +98 -0
  31. package/src/handlers/index.ts +8 -5
  32. package/src/hooks/useRagChat.ts +53 -17
  33. package/src/llm/providers/UniversalLLMAdapter.ts +110 -13
  34. package/src/providers/vectordb/ChromaDBProvider.ts +13 -6
  35. package/src/providers/vectordb/MilvusProvider.ts +18 -2
  36. package/src/providers/vectordb/MultiTablePostgresProvider.ts +16 -29
  37. package/src/providers/vectordb/PostgreSQLProvider.ts +4 -15
  38. package/src/providers/vectordb/QdrantProvider.ts +2 -5
  39. package/src/providers/vectordb/RedisProvider.ts +3 -4
  40. package/src/providers/vectordb/WeaviateProvider.ts +41 -3
  41. package/src/types/index.ts +26 -0
  42. package/src/utils/ProductExtractor.ts +5 -3
  43. package/src/utils/UITransformer.ts +1345 -534
  44. package/src/utils/synonyms.ts +2 -0
@@ -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
- // Add a placeholder assistant message that we will update incrementally
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; // Wait for more data
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
- // Update the assistant message with accumulated content
174
- setMessages((prev) =>
175
- prev.map((msg) =>
176
- msg.id === assistantMessageId
177
- ? {
178
- ...msg,
179
- content: assistantContent,
180
- sources: sources.length > 0 ? sources : msg.sources,
181
- uiTransformation: uiTransformation || (msg as RagMessage).uiTransformation
182
- }
183
- : msg
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
- const baseUrl = llmConfig.baseUrl ?? this.opts.baseUrl;
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
  }
@@ -41,13 +41,9 @@ export class ChromaDBProvider extends BaseVectorProvider {
41
41
  } catch (err) {
42
42
  if (axios.isAxiosError(err) && err.response?.status === 404) {
43
43
  // Create the collection if it doesn't exist
44
- const metric = this.config.distanceMetric || 'cosine';
45
- const space = metric === 'euclidean' ? 'l2' : metric === 'dotproduct' ? 'ip' : 'cosine';
46
-
47
44
  console.log(`[ChromaDBProvider] ⏳ Collection "${this.indexName}" not found. Creating...`);
48
45
  const { data } = await this.http.post('/api/v1/collections', {
49
46
  name: this.indexName,
50
- metadata: { "hnsw:space": space }
51
47
  });
52
48
  this.collectionId = data.id;
53
49
  console.log(`[ChromaDBProvider] ✅ Created collection "${this.indexName}" (id: ${this.collectionId})`);
@@ -74,12 +70,23 @@ export class ChromaDBProvider extends BaseVectorProvider {
74
70
  await this.http.post(`/api/v1/collections/${this.collectionId}/add`, payload);
75
71
  }
76
72
 
77
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
78
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
+
79
84
  const payload = {
80
85
  query_embeddings: [vector],
81
86
  n_results: topK,
82
- where: namespace ? { namespace: { $eq: namespace } } : undefined,
87
+ where: whereClauses.length > 1
88
+ ? { $and: whereClauses }
89
+ : whereClauses[0],
83
90
  };
84
91
  const { data } = await this.http.post(`/api/v1/collections/${this.collectionId}/query`, payload);
85
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: namespace ? `namespace == "${namespace}"` : undefined,
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,
@@ -66,13 +66,10 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
66
66
  embedding VECTOR(${this.dimensions})
67
67
  )
68
68
  `);
69
- const metric = this.config.distanceMetric || 'cosine';
70
- const indexOps = metric === 'euclidean' ? 'vector_l2_ops' : metric === 'dotproduct' ? 'vector_ip_ops' : 'vector_cosine_ops';
71
-
72
69
  await client.query(`
73
70
  CREATE INDEX IF NOT EXISTS ${this.uploadTable}_embedding_idx
74
71
  ON ${this.uploadTable}
75
- USING hnsw (embedding ${indexOps})
72
+ USING hnsw (embedding vector_cosine_ops)
76
73
  `);
77
74
 
78
75
  const res = await client.query(`
@@ -150,13 +147,10 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
150
147
  await client.query(createTableSql);
151
148
 
152
149
  // Create Index
153
- const metric = this.config.distanceMetric || 'cosine';
154
- const indexOps = metric === 'euclidean' ? 'vector_l2_ops' : metric === 'dotproduct' ? 'vector_ip_ops' : 'vector_cosine_ops';
155
-
156
150
  await client.query(`
157
151
  CREATE INDEX IF NOT EXISTS "${tableName}_embedding_idx"
158
152
  ON "${tableName}"
159
- USING hnsw (embedding ${indexOps})
153
+ USING hnsw (embedding vector_cosine_ops)
160
154
  `);
161
155
 
162
156
  // Ensure this new table is added to our search scope
@@ -241,12 +235,9 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
241
235
  console.log(`[MultiTablePostgresProvider] --- Starting Multi-Table Search ---`);
242
236
 
243
237
  const queryText = _filter?.queryText as string | undefined;
244
- const entityHints = Array.isArray(_filter?.__entityHints)
245
- ? (_filter.__entityHints as unknown[])
246
- .filter((hint): hint is { value: string; field?: string } =>
247
- typeof hint === 'object' && hint !== null && typeof (hint as Record<string, unknown>).value === 'string'
248
- )
249
- .map((hint) => hint.value.trim().toLowerCase())
238
+ const entityHints = Array.isArray(_filter?.keywords)
239
+ ? (_filter.keywords as string[])
240
+ .map((k) => k.trim().toLowerCase())
250
241
  .filter(Boolean)
251
242
  : [];
252
243
 
@@ -258,15 +249,17 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
258
249
  if (entityHints.length > 0) {
259
250
  // Use extracted entities as high-signal keywords
260
251
  return entityHints
261
- .map(h => h.includes(' ') ? `"${h}"` : h) // Wrap multi-word hints in quotes
252
+ .map(h => h.replace(/\s+/g, ' & ')) // Replace spaces with & for valid tsquery
262
253
  .join(' & ');
263
254
  }
264
255
 
265
256
  if (queryText) {
266
257
  // Fallback to cleaned raw text if no high-signal hints were extracted
258
+ // Added 'get', 'details', 'information', 'info' to stop words
267
259
  return queryText
268
260
  .toLowerCase()
269
- .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, '')
270
263
  .trim()
271
264
  .replace(/\s+/g, ' & ');
272
265
  }
@@ -274,20 +267,14 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
274
267
  };
275
268
 
276
269
  const dynamicKeywordQuery = getDynamicKeywordQuery();
270
+ const tableLimit = Math.max(topK, 50);
277
271
 
278
272
  const queryPromises = this.tables.map(async (table) => {
279
273
  try {
280
274
  let sqlQuery = '';
281
275
  let params: unknown[] = [];
282
276
 
283
- const metric = this.config.distanceMetric || 'cosine';
284
- const scoreExpr = metric === 'euclidean'
285
- ? `1 / (1 + (embedding <-> $1::vector))`
286
- : metric === 'dotproduct'
287
- ? `(embedding <#> $1::vector) * -1`
288
- : `1 - (embedding <=> $1::vector)`;
289
-
290
- if (queryText) {
277
+ if (queryText && dynamicKeywordQuery) {
291
278
  const hasEntityHints = entityHints.length > 0;
292
279
  const exactNameScoreExpr = hasEntityHints
293
280
  ? `+ (
@@ -303,22 +290,22 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
303
290
  // Actual Hybrid Search: Semantic Vector Score + Full-Text Keyword Rank
304
291
  sqlQuery = `
305
292
  SELECT *,
306
- (${scoreExpr}) AS vector_score,
293
+ (1 - (embedding <=> $1::vector)) AS vector_score,
307
294
  COALESCE(ts_rank(to_tsvector('english', (to_jsonb(t) - 'embedding')::text), to_tsquery('english', $2)), 0) AS keyword_score,
308
- ((${scoreExpr}) + (COALESCE(ts_rank(to_tsvector('english', (to_jsonb(t) - 'embedding')::text), to_tsquery('english', $2)), 0) * 2.0) ${exactNameScoreExpr}) AS hybrid_score
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
309
296
  FROM "${table}" t
310
297
  ORDER BY hybrid_score DESC
311
- LIMIT 50
298
+ LIMIT ${tableLimit}
312
299
  `;
313
300
  params = [vectorLiteral, dynamicKeywordQuery];
314
301
  } else {
315
302
  // Fallback to Vector-only Search
316
303
  sqlQuery = `
317
304
  SELECT *,
318
- (${scoreExpr}) AS hybrid_score
305
+ (1 - (embedding <=> $1::vector)) AS hybrid_score
319
306
  FROM "${table}" t
320
307
  ORDER BY hybrid_score DESC
321
- LIMIT 50
308
+ LIMIT ${tableLimit}
322
309
  `;
323
310
  params = [vectorLiteral];
324
311
  }
@@ -97,13 +97,10 @@ export class PostgreSQLProvider extends BaseVectorProvider {
97
97
  embedding VECTOR(${this.dimensions})
98
98
  )
99
99
  `);
100
- const metric = this.config.distanceMetric || 'cosine';
101
- const indexOps = metric === 'euclidean' ? 'vector_l2_ops' : metric === 'dotproduct' ? 'vector_ip_ops' : 'vector_cosine_ops';
102
-
103
100
  await client.query(`
104
101
  CREATE INDEX IF NOT EXISTS ${this.tableName}_embedding_idx
105
102
  ON ${this.tableName}
106
- USING hnsw (embedding ${indexOps})
103
+ USING hnsw (embedding vector_cosine_ops)
107
104
  `);
108
105
  } finally {
109
106
  client.release();
@@ -173,7 +170,7 @@ export class PostgreSQLProvider extends BaseVectorProvider {
173
170
  const filterConditions = Object.entries(publicFilter)
174
171
  .map(([key, val]) => {
175
172
  const paramIdx = params.length + 1;
176
- params.push(JSON.stringify(val));
173
+ params.push(String(val));
177
174
  return `metadata->>'${key}' = $${paramIdx}`;
178
175
  })
179
176
  .join(' AND ');
@@ -185,19 +182,11 @@ export class PostgreSQLProvider extends BaseVectorProvider {
185
182
  const efSearch = (this.config.options.efSearch as number) || Math.max(topK * 10, 40);
186
183
  await client.query(`SET LOCAL hnsw.ef_search = ${efSearch}`);
187
184
 
188
- const metric = this.config.distanceMetric || 'cosine';
189
- const queryOp = metric === 'euclidean' ? '<->' : metric === 'dotproduct' ? '<#>' : '<=>';
190
- const scoreExpr = metric === 'euclidean'
191
- ? `1 / (1 + (embedding <-> $1::vector))`
192
- : metric === 'dotproduct'
193
- ? `(embedding <#> $1::vector) * -1`
194
- : `1 - (embedding <=> $1::vector)`;
195
-
196
185
  const result = await client.query(
197
- `SELECT id, content, metadata, ${scoreExpr} AS score
186
+ `SELECT id, content, metadata, 1 - (embedding <=> $1::vector) AS score
198
187
  FROM ${this.tableName}
199
188
  ${whereClause}
200
- ORDER BY embedding ${queryOp} $1::vector
189
+ ORDER BY embedding <=> $1::vector
201
190
  LIMIT $2`,
202
191
  params
203
192
  );
@@ -120,14 +120,11 @@ export class QdrantProvider extends BaseVectorProvider {
120
120
  const opts = this.config.options as Record<string, unknown>;
121
121
  const dimensionsForCreate = (opts.dimensions as number) || 1536;
122
122
 
123
- const metric = this.config.distanceMetric || 'cosine';
124
- const distance = metric === 'euclidean' ? 'Euclid' : metric === 'dotproduct' ? 'Dot' : 'Cosine';
125
-
126
123
  console.log(`[QdrantProvider] ⏳ Creating collection "${this.indexName}" (dimensions: ${dimensionsForCreate})...`);
127
124
  await this.http.put(`/collections/${this.indexName}`, {
128
125
  vectors: {
129
126
  size: dimensionsForCreate,
130
- distance: distance,
127
+ distance: 'Cosine',
131
128
  },
132
129
  });
133
130
  console.log(`[QdrantProvider] ✅ Created collection "${this.indexName}"`);
@@ -214,7 +211,7 @@ export class QdrantProvider extends BaseVectorProvider {
214
211
  limit: topK,
215
212
  with_payload: true,
216
213
  params: {
217
- hnsw_ef: (this.config.options.efSearch as number) || Math.max(topK * 20, 128),
214
+ hnsw_ef: ((this.config.options as Record<string, unknown>)?.efSearch as number | undefined) || Math.max(topK * 20, 128),
218
215
  exact: false
219
216
  },
220
217
  filter: must.length > 0 ? { must } : undefined,
@@ -90,16 +90,15 @@ export class RedisProvider extends BaseVectorProvider {
90
90
  }
91
91
 
92
92
  /**
93
- * Redis is TCP-based and has no HTTP health endpoint.
94
- * Returns true; actual connectivity is validated on the first operation.
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
- // If REST ping fails, assume connectivity will be validated on first use
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
- ${namespace ? `where: { path: ["namespace"], operator: Equal, valueString: "${namespace}" }` : ''}
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
  }