@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.
- package/dist/{ILLMProvider-CbUtvWAW.d.mts → ILLMProvider-BQyKZLnd.d.mts} +10 -2
- package/dist/{ILLMProvider-CbUtvWAW.d.ts → ILLMProvider-BQyKZLnd.d.ts} +10 -2
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +1726 -522
- package/dist/handlers/index.mjs +1725 -521
- package/dist/{index-DgzcnqZs.d.ts → index-A0GqPsaG.d.ts} +1 -1
- package/dist/{index-c_y_5qdw.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 +1735 -733
- package/dist/server.mjs +1733 -731
- 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 -2
- 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 -6
- package/src/providers/vectordb/MilvusProvider.ts +18 -2
- package/src/providers/vectordb/MultiTablePostgresProvider.ts +16 -29
- package/src/providers/vectordb/PostgreSQLProvider.ts +4 -15
- package/src/providers/vectordb/QdrantProvider.ts +2 -5
- 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 +1345 -534
- 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
|
}
|
|
@@ -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:
|
|
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:
|
|
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
|
|
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
|
|
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?.
|
|
245
|
-
? (_filter.
|
|
246
|
-
.
|
|
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.
|
|
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
|
-
|
|
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
|
-
($
|
|
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
|
-
(($
|
|
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
|
|
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
|
-
|
|
305
|
+
(1 - (embedding <=> $1::vector)) AS hybrid_score
|
|
319
306
|
FROM "${table}" t
|
|
320
307
|
ORDER BY hybrid_score DESC
|
|
321
|
-
LIMIT
|
|
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
|
|
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(
|
|
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, $
|
|
186
|
+
`SELECT id, content, metadata, 1 - (embedding <=> $1::vector) AS score
|
|
198
187
|
FROM ${this.tableName}
|
|
199
188
|
${whereClause}
|
|
200
|
-
ORDER BY embedding
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
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
|
}
|