@retrivora-ai/rag-engine 1.0.9 → 1.1.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.
@@ -6,9 +6,14 @@ import { VectorMatch, UpsertDocument } from '../../types';
6
6
 
7
7
  /**
8
8
  * QdrantProvider — implementation for Qdrant using its REST API.
9
+ * Fully dynamic: automatically discovers and indexes schema from available data.
9
10
  */
10
11
  export class QdrantProvider extends BaseVectorProvider {
11
12
  private http: AxiosInstance;
13
+ private contentField: string;
14
+ private metadataField: string;
15
+ private isFlatPayload: boolean;
16
+ private schemaDiscovered = false;
12
17
 
13
18
  constructor(config: VectorDBConfig) {
14
19
  super(config);
@@ -16,8 +21,13 @@ export class QdrantProvider extends BaseVectorProvider {
16
21
  const baseUrl = opts.baseUrl as string;
17
22
  if (!baseUrl) throw new Error('[QdrantProvider] baseUrl is required');
18
23
 
24
+ this.contentField = (opts.contentField as string) || 'content';
25
+ this.metadataField = (opts.metadataField as string) || 'metadata';
26
+ this.isFlatPayload = !!opts.flatPayload;
27
+
19
28
  this.http = axios.create({
20
29
  baseURL: baseUrl,
30
+ timeout: 15000, // 15s timeout for vector DB operations
21
31
  headers: {
22
32
  'Content-Type': 'application/json',
23
33
  ...(opts.apiKey ? { 'api-key': opts.apiKey as string } : {}),
@@ -26,9 +36,76 @@ export class QdrantProvider extends BaseVectorProvider {
26
36
  }
27
37
 
28
38
  async initialize(): Promise<void> {
39
+ if (this.schemaDiscovered) return;
40
+
29
41
  await this.ping();
30
42
  await this.ensureCollection();
31
- await this.ensureIndex();
43
+
44
+ // 1. Discover schema from existing data
45
+ console.log(`[QdrantProvider] 🔍 Discovering schema for collection "${this.indexName}"...`);
46
+ const discoveredFields = await this.discoverSchema();
47
+
48
+ // 2. Add client-provided fields (if any) as fallback/override
49
+ const opts = this.config.options as Record<string, unknown>;
50
+ const configFields = (opts.filterableFields as string[]) || [];
51
+
52
+ const allFields = [...new Set([...discoveredFields, ...configFields])];
53
+
54
+ if (allFields.length > 0) {
55
+ console.log(`[QdrantProvider] 🛠 Ensuring indexes for ${allFields.length} discovered fields...`);
56
+ // Parallelize index creation for much faster startup
57
+ await Promise.all(
58
+ allFields.map(async (fieldInfo) => {
59
+ const [fieldName, schemaType] = fieldInfo.split(':');
60
+ return this.ensureIndex(fieldName, (schemaType as any) || 'keyword');
61
+ })
62
+ );
63
+ } else {
64
+ console.log(`[QdrantProvider] ℹ️ No fields discovered for indexing.`);
65
+ }
66
+
67
+ this.schemaDiscovered = true;
68
+ }
69
+
70
+ /**
71
+ * Samples points from the collection to discover available payload fields.
72
+ */
73
+ private async discoverSchema(): Promise<string[]> {
74
+ try {
75
+ // Fetch 20 points to get a representative sample of the schema
76
+ const { data } = await this.http.post(`/collections/${this.indexName}/points/scroll`, {
77
+ limit: 20,
78
+ with_payload: true,
79
+ with_vector: false
80
+ });
81
+
82
+ const points = data.result?.points || [];
83
+ const keys = new Set<string>();
84
+
85
+ for (const point of points) {
86
+ const payload = point.payload || {};
87
+
88
+ // Extract root keys
89
+ Object.keys(payload).forEach(k => {
90
+ if (k !== 'namespace' && k !== this.contentField && k !== this.metadataField) {
91
+ keys.add(k);
92
+ }
93
+ });
94
+
95
+ // Extract nested metadata keys if applicable
96
+ if (!this.isFlatPayload && payload[this.metadataField]) {
97
+ const meta = payload[this.metadataField];
98
+ if (typeof meta === 'object' && meta !== null) {
99
+ Object.keys(meta).forEach(k => keys.add(k));
100
+ }
101
+ }
102
+ }
103
+
104
+ return Array.from(keys);
105
+ } catch (err) {
106
+ console.warn(`[QdrantProvider] ⚠️ Schema discovery failed:`, err instanceof Error ? err.message : String(err));
107
+ return [];
108
+ }
32
109
  }
33
110
 
34
111
  /**
@@ -58,32 +135,25 @@ export class QdrantProvider extends BaseVectorProvider {
58
135
  }
59
136
 
60
137
  /**
61
- * Ensures that the 'namespace' field has a keyword index for efficient filtering.
62
- * Qdrant requires this for search filters to work in many configurations.
138
+ * Ensures that a payload field has an index.
63
139
  */
64
- private async ensureIndex(): Promise<void> {
140
+ private async ensureIndex(fieldName: string, schema: string = 'keyword'): Promise<void> {
141
+ let fullPath = fieldName;
142
+ if (!this.isFlatPayload && !fieldName.includes('.') && fieldName !== 'namespace' && fieldName !== this.contentField) {
143
+ fullPath = `${this.metadataField}.${fieldName}`;
144
+ }
145
+
65
146
  try {
66
147
  await this.http.put(`/collections/${this.indexName}/index`, {
67
- field_name: 'namespace',
68
- field_schema: 'keyword',
148
+ field_name: fullPath,
149
+ field_schema: schema,
69
150
  });
70
- console.log(`[QdrantProvider] ✅ Ensured keyword index for "namespace" on collection "${this.indexName}"`);
151
+ console.log(`[QdrantProvider] ✅ Ensured ${schema} index for "${fullPath}"`);
71
152
  } catch (err) {
72
153
  let status: number | undefined;
73
- let data: unknown;
74
-
75
- if (axios.isAxiosError(err)) {
76
- status = err.response?.status;
77
- data = err.response?.data;
78
- }
79
-
80
- // 409 means it already exists, which is fine.
81
- if (status === 409) {
82
- return;
83
- }
84
-
85
- const errorMessage = err instanceof Error ? err.message : String(err);
86
- console.warn(`[QdrantProvider] ⚠️ Could not ensure namespace index (Status: ${status}):`, JSON.stringify(data || errorMessage));
154
+ if (axios.isAxiosError(err)) status = err.response?.status;
155
+ if (status === 409) return;
156
+ console.warn(`[QdrantProvider] ⚠️ Could not ensure index for "${fullPath}":`, err instanceof Error ? err.message : String(err));
87
157
  }
88
158
  }
89
159
 
@@ -93,15 +163,24 @@ export class QdrantProvider extends BaseVectorProvider {
93
163
 
94
164
  async batchUpsert(docs: UpsertDocument[], namespace?: string): Promise<void> {
95
165
  const payload = {
96
- points: docs.map(doc => ({
97
- id: this.normalizeId(doc.id),
98
- vector: doc.vector,
99
- payload: {
100
- content: doc.content,
101
- metadata: doc.metadata || {},
166
+ points: docs.map(doc => {
167
+ const pointPayload: Record<string, any> = {
168
+ [this.contentField]: doc.content,
102
169
  ...(namespace ? { namespace } : {}),
103
- },
104
- })),
170
+ };
171
+
172
+ if (this.isFlatPayload) {
173
+ Object.assign(pointPayload, doc.metadata || {});
174
+ } else {
175
+ pointPayload[this.metadataField] = doc.metadata || {};
176
+ }
177
+
178
+ return {
179
+ id: this.normalizeId(doc.id),
180
+ vector: doc.vector,
181
+ payload: pointPayload,
182
+ };
183
+ }),
105
184
  };
106
185
  await this.http.put(`/collections/${this.indexName}/points`, payload);
107
186
  }
@@ -113,12 +192,18 @@ export class QdrantProvider extends BaseVectorProvider {
113
192
  must.push({ key: 'namespace', match: { value: namespace } });
114
193
  }
115
194
 
116
- // 1. Apply metadata filters
117
195
  const sanitizedFilter = this.sanitizeFilter(_filter);
118
196
  for (const [key, value] of Object.entries(sanitizedFilter)) {
119
- // Qdrant uses dot notation for nested payload fields.
120
- // Our payload structure is { content, metadata: { ... }, namespace }
121
- must.push({ key: `metadata.${key}`, match: { value: value } });
197
+ let filterKey = key;
198
+ if (!this.isFlatPayload && !key.includes('.') && key !== 'namespace' && key !== this.contentField) {
199
+ filterKey = `${this.metadataField}.${key}`;
200
+ }
201
+
202
+ if (Array.isArray(value)) {
203
+ must.push({ key: filterKey, match: { any: value } });
204
+ } else {
205
+ must.push({ key: filterKey, match: { value: value } });
206
+ }
122
207
  }
123
208
 
124
209
  const payload = {
@@ -132,31 +217,45 @@ export class QdrantProvider extends BaseVectorProvider {
132
217
  filter: must.length > 0 ? { must } : undefined,
133
218
  };
134
219
 
135
- console.log(`[QdrantProvider] 🔍 Searching "${this.indexName}" | Namespace: ${namespace || 'default'} | TopK: ${topK}`);
136
- if (must.length > (namespace ? 1 : 0)) {
137
- console.log(`[QdrantProvider] 🛠 Filters:`, JSON.stringify(must.filter(m => m.key !== 'namespace')));
138
- }
139
-
140
220
  const { data } = await this.http.post(`/collections/${this.indexName}/points/search`, payload);
141
221
 
142
- const results = (data.result || []).map((res: Record<string, unknown>) => ({
143
- id: res['id'] as string,
144
- score: res['score'] as number,
145
- content: (res['payload'] as Record<string, unknown>)?.content as string || '',
146
- metadata: (res['payload'] as Record<string, unknown>)?.metadata as Record<string, unknown> || {},
147
- }));
148
-
149
- if (results.length === 0) {
150
- console.warn(`[QdrantProvider] ⚠️ Retrieval returned 0 results. Check if data exists in namespace "${namespace}"`);
151
- } else {
152
- console.log(`[QdrantProvider] Found ${results.length} results (best score: ${results[0].score.toFixed(4)})`);
153
- }
222
+ const results = (data.result || []).map((res: Record<string, any>) => {
223
+ const p = res.payload || {};
224
+ let content = p[this.contentField] || '';
225
+
226
+ if (!content) {
227
+ const stringFields = Object.entries(p)
228
+ .filter(([_, v]) => typeof v === 'string')
229
+ .map(([k, v]) => ({ key: k, val: v as string }));
230
+
231
+ if (stringFields.length > 0) {
232
+ const bestMatch = stringFields.sort((a, b) => b.val.length - a.val.length)[0];
233
+ content = bestMatch.val;
234
+ } else {
235
+ content = JSON.stringify(p);
236
+ }
237
+ }
238
+
239
+ let metadata: Record<string, any> = {};
240
+ if (this.isFlatPayload) {
241
+ metadata = { ...p };
242
+ delete metadata[this.contentField];
243
+ delete metadata['namespace'];
244
+ } else {
245
+ metadata = p[this.metadataField] || p;
246
+ }
247
+
248
+ return {
249
+ id: res.id as string,
250
+ score: res.score as number,
251
+ content,
252
+ metadata,
253
+ };
254
+ });
154
255
 
155
256
  return results;
156
257
  }
157
258
 
158
-
159
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
160
259
  async delete(id: string | number, _namespace?: string): Promise<void> {
161
260
  await this.http.post(`/collections/${this.indexName}/points/delete`, {
162
261
  points: [this.normalizeId(id)],
@@ -180,21 +279,16 @@ export class QdrantProvider extends BaseVectorProvider {
180
279
  }
181
280
  }
182
281
 
183
- /**
184
- * Normalizes an ID into a format Qdrant accepts (UUID or integer).
185
- * For string IDs that aren't UUIDs, it generates a deterministic UUID v5-like hash.
186
- */
187
282
  private normalizeId(id: string | number): string | number {
188
283
  if (typeof id === 'number') return id;
189
-
190
- // Check if already a valid UUID
191
284
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
192
285
  if (uuidRegex.test(id)) return id.toLowerCase();
193
-
194
- // Hash string to deterministic UUID
195
286
  const hash = crypto.createHash('md5').update(id).digest('hex');
196
287
  return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`;
197
288
  }
198
289
 
199
290
  async disconnect(): Promise<void> {}
200
291
  }
292
+
293
+
294
+
@@ -1,174 +0,0 @@
1
- import {
2
- BaseVectorProvider
3
- } from "./chunk-IMP6FUCY.mjs";
4
- import {
5
- __spreadValues
6
- } from "./chunk-X4TOT24V.mjs";
7
-
8
- // src/providers/vectordb/QdrantProvider.ts
9
- import axios from "axios";
10
- import crypto from "crypto";
11
- var QdrantProvider = class extends BaseVectorProvider {
12
- constructor(config) {
13
- super(config);
14
- const opts = config.options;
15
- const baseUrl = opts.baseUrl;
16
- if (!baseUrl) throw new Error("[QdrantProvider] baseUrl is required");
17
- this.http = axios.create({
18
- baseURL: baseUrl,
19
- headers: __spreadValues({
20
- "Content-Type": "application/json"
21
- }, opts.apiKey ? { "api-key": opts.apiKey } : {})
22
- });
23
- }
24
- async initialize() {
25
- await this.ping();
26
- await this.ensureCollection();
27
- await this.ensureIndex();
28
- }
29
- /**
30
- * Ensures the collection exists. Creates it if missing.
31
- */
32
- async ensureCollection() {
33
- var _a;
34
- try {
35
- await this.http.get(`/collections/${this.indexName}`);
36
- console.log(`[QdrantProvider] \u2705 Collection "${this.indexName}" already exists.`);
37
- } catch (err) {
38
- if (axios.isAxiosError(err) && ((_a = err.response) == null ? void 0 : _a.status) === 404) {
39
- const opts = this.config.options;
40
- const dimensionsForCreate = opts.dimensions || 1536;
41
- console.log(`[QdrantProvider] \u23F3 Creating collection "${this.indexName}" (dimensions: ${dimensionsForCreate})...`);
42
- await this.http.put(`/collections/${this.indexName}`, {
43
- vectors: {
44
- size: dimensionsForCreate,
45
- distance: "Cosine"
46
- }
47
- });
48
- console.log(`[QdrantProvider] \u2705 Created collection "${this.indexName}"`);
49
- } else {
50
- throw err;
51
- }
52
- }
53
- }
54
- /**
55
- * Ensures that the 'namespace' field has a keyword index for efficient filtering.
56
- * Qdrant requires this for search filters to work in many configurations.
57
- */
58
- async ensureIndex() {
59
- var _a, _b;
60
- try {
61
- await this.http.put(`/collections/${this.indexName}/index`, {
62
- field_name: "namespace",
63
- field_schema: "keyword"
64
- });
65
- console.log(`[QdrantProvider] \u2705 Ensured keyword index for "namespace" on collection "${this.indexName}"`);
66
- } catch (err) {
67
- let status;
68
- let data;
69
- if (axios.isAxiosError(err)) {
70
- status = (_a = err.response) == null ? void 0 : _a.status;
71
- data = (_b = err.response) == null ? void 0 : _b.data;
72
- }
73
- if (status === 409) {
74
- return;
75
- }
76
- const errorMessage = err instanceof Error ? err.message : String(err);
77
- console.warn(`[QdrantProvider] \u26A0\uFE0F Could not ensure namespace index (Status: ${status}):`, JSON.stringify(data || errorMessage));
78
- }
79
- }
80
- async upsert(doc, namespace) {
81
- await this.batchUpsert([doc], namespace);
82
- }
83
- async batchUpsert(docs, namespace) {
84
- const payload = {
85
- points: docs.map((doc) => ({
86
- id: this.normalizeId(doc.id),
87
- vector: doc.vector,
88
- payload: __spreadValues({
89
- content: doc.content,
90
- metadata: doc.metadata || {}
91
- }, namespace ? { namespace } : {})
92
- }))
93
- };
94
- await this.http.put(`/collections/${this.indexName}/points`, payload);
95
- }
96
- async query(vector, topK, namespace, _filter) {
97
- const must = [];
98
- if (namespace) {
99
- must.push({ key: "namespace", match: { value: namespace } });
100
- }
101
- const sanitizedFilter = this.sanitizeFilter(_filter);
102
- for (const [key, value] of Object.entries(sanitizedFilter)) {
103
- must.push({ key: `metadata.${key}`, match: { value } });
104
- }
105
- const payload = {
106
- vector,
107
- limit: topK,
108
- with_payload: true,
109
- params: {
110
- hnsw_ef: this.config.options.efSearch || Math.max(topK * 20, 128),
111
- exact: false
112
- },
113
- filter: must.length > 0 ? { must } : void 0
114
- };
115
- console.log(`[QdrantProvider] \u{1F50D} Searching "${this.indexName}" | Namespace: ${namespace || "default"} | TopK: ${topK}`);
116
- if (must.length > (namespace ? 1 : 0)) {
117
- console.log(`[QdrantProvider] \u{1F6E0} Filters:`, JSON.stringify(must.filter((m) => m.key !== "namespace")));
118
- }
119
- const { data } = await this.http.post(`/collections/${this.indexName}/points/search`, payload);
120
- const results = (data.result || []).map((res) => {
121
- var _a, _b;
122
- return {
123
- id: res["id"],
124
- score: res["score"],
125
- content: ((_a = res["payload"]) == null ? void 0 : _a.content) || "",
126
- metadata: ((_b = res["payload"]) == null ? void 0 : _b.metadata) || {}
127
- };
128
- });
129
- if (results.length === 0) {
130
- console.warn(`[QdrantProvider] \u26A0\uFE0F Retrieval returned 0 results. Check if data exists in namespace "${namespace}"`);
131
- } else {
132
- console.log(`[QdrantProvider] \u2705 Found ${results.length} results (best score: ${results[0].score.toFixed(4)})`);
133
- }
134
- return results;
135
- }
136
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
137
- async delete(id, _namespace) {
138
- await this.http.post(`/collections/${this.indexName}/points/delete`, {
139
- points: [this.normalizeId(id)]
140
- });
141
- }
142
- async deleteNamespace(_namespace) {
143
- await this.http.post(`/collections/${this.indexName}/points/delete`, {
144
- filter: {
145
- must: [{ key: "namespace", match: { value: _namespace } }]
146
- }
147
- });
148
- }
149
- async ping() {
150
- try {
151
- await this.http.get("/healthz");
152
- return true;
153
- } catch (e) {
154
- return false;
155
- }
156
- }
157
- /**
158
- * Normalizes an ID into a format Qdrant accepts (UUID or integer).
159
- * For string IDs that aren't UUIDs, it generates a deterministic UUID v5-like hash.
160
- */
161
- normalizeId(id) {
162
- if (typeof id === "number") return id;
163
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
164
- if (uuidRegex.test(id)) return id.toLowerCase();
165
- const hash = crypto.createHash("md5").update(id).digest("hex");
166
- return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`;
167
- }
168
- async disconnect() {
169
- }
170
- };
171
-
172
- export {
173
- QdrantProvider
174
- };