@retrivora-ai/rag-engine 0.2.6 → 0.2.8

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.
@@ -1,11 +1,45 @@
1
-
2
-
3
-
4
1
  import { Pool, PoolClient } from 'pg';
5
2
  import { BaseVectorProvider } from './BaseVectorProvider';
6
3
  import { VectorMatch, UpsertDocument } from '../../types';
7
4
  import { VectorDBConfig } from '../../config/RagConfig';
8
5
 
6
+ interface QueryFieldHint {
7
+ field?: string;
8
+ value: string;
9
+ }
10
+
11
+ interface TableSearchConfig {
12
+ availableFields: string[];
13
+ searchableFields: string[];
14
+ normalizedFieldMap: Map<string, string>;
15
+ }
16
+
17
+ function normalizeFieldName(value: string): string {
18
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '');
19
+ }
20
+
21
+ function sanitizeFilterHints(rawFilter?: Record<string, unknown>): QueryFieldHint[] {
22
+ if (!Array.isArray(rawFilter?.__fieldHints)) return [];
23
+
24
+ return (rawFilter.__fieldHints as unknown[])
25
+ .filter((hint): hint is QueryFieldHint => typeof hint === 'object' && hint !== null)
26
+ .map((hint) => ({
27
+ value: typeof hint.value === 'string' ? hint.value.trim().toLowerCase() : '',
28
+ ...(typeof hint.field === 'string' && hint.field.trim()
29
+ ? { field: normalizeFieldName(hint.field) }
30
+ : {}),
31
+ }))
32
+ .filter((hint) => Boolean(hint.value));
33
+ }
34
+
35
+ function stripInternalFilterKeys(filter?: Record<string, unknown>): Record<string, unknown> {
36
+ if (!filter) return {};
37
+
38
+ return Object.fromEntries(
39
+ Object.entries(filter).filter(([key]) => !key.startsWith('__'))
40
+ );
41
+ }
42
+
9
43
  /**
10
44
  * MultiTablePostgresProvider — PostgreSQL implementation that searches across
11
45
  * multiple existing tables with pre-existing embeddings.
@@ -19,6 +53,7 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
19
53
  private readonly dimensions: number;
20
54
  private readonly connectionString: string;
21
55
  private tables: string[];
56
+ private tableSearchConfig = new Map<string, TableSearchConfig>();
22
57
 
23
58
  constructor(config: VectorDBConfig) {
24
59
  super(config);
@@ -79,6 +114,7 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
79
114
  if (this.tables.length === 0) {
80
115
  console.warn('[MultiTablePostgresProvider] No tables with "embedding" columns found in the database.');
81
116
  } else {
117
+ this.tableSearchConfig = await this.loadTableSearchConfig(client, this.tables);
82
118
  console.log(
83
119
  `[MultiTablePostgresProvider] Connected. Searching across ${this.tables.length} table(s): ${this.tables.join(', ')}`
84
120
  );
@@ -127,28 +163,73 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
127
163
  console.log(`[MultiTablePostgresProvider] --- Starting Multi-Table Search ---`);
128
164
 
129
165
  const queryText = _filter?.__queryText as string | undefined;
166
+ const fieldHints = sanitizeFilterHints(_filter);
167
+ const explicitFilter = stripInternalFilterKeys(_filter);
130
168
 
131
169
  const queryPromises = this.tables.map(async (table) => {
132
170
  try {
133
171
  let sqlQuery = '';
134
172
  let params: unknown[] = [];
173
+ const tableConfig = this.tableSearchConfig.get(table);
174
+ const availableFields = tableConfig?.availableFields ?? [];
175
+ const searchableFields = tableConfig?.searchableFields ?? [];
176
+ const normalizedFieldMap = tableConfig?.normalizedFieldMap ?? new Map<string, string>();
177
+ const whereConditions: string[] = [];
135
178
 
136
179
  if (queryText) {
180
+ const genericValueHints = fieldHints
181
+ .filter((hint) => !hint.field)
182
+ .map((hint) => hint.value);
183
+
184
+ params = genericValueHints.length > 0
185
+ ? [vectorLiteral, queryText, genericValueHints]
186
+ : [vectorLiteral, queryText];
187
+
188
+ const genericValueParamIndex = genericValueHints.length > 0 ? 3 : -1;
189
+
190
+ const exactValueScoreExpr = genericValueHints.length > 0 && searchableFields.length > 0
191
+ ? `GREATEST(${searchableFields.map((field) =>
192
+ `CASE WHEN LOWER(COALESCE(to_jsonb(t)->>'${field}', '')) = ANY($${genericValueParamIndex}::text[]) THEN 1 ELSE 0 END`
193
+ ).join(', ')})`
194
+ : '0';
195
+
196
+ const fieldSpecificChecks = fieldHints
197
+ .filter((hint) => hint.field)
198
+ .map((hint) => {
199
+ const column = hint.field ? normalizedFieldMap.get(hint.field) : undefined;
200
+ if (!column) return null;
201
+ params.push(hint.value);
202
+ const paramIndex = params.length;
203
+ return `CASE WHEN LOWER(COALESCE(to_jsonb(t)->>'${column}', '')) = $${paramIndex}::text THEN 1 ELSE 0 END`;
204
+ })
205
+ .filter((expr): expr is string => Boolean(expr));
206
+
207
+ const exactFieldScoreExpr = fieldSpecificChecks.length > 0
208
+ ? `GREATEST(${fieldSpecificChecks.join(', ')})`
209
+ : '0';
210
+
137
211
  // Actual Hybrid Search: Semantic Vector Score + Full-Text Keyword Rank
138
212
  // We convert plainto_tsquery's AND (&) operator to OR (|) so that natural language queries
139
- // (which contain words like "price", "product" not found in the row text) still match entities like "Router".
213
+ // (which contain words that are not present verbatim in the row text) still match the intended records.
140
214
  sqlQuery = `
141
215
  SELECT *,
142
216
  (1 - (embedding <=> $1::vector)) AS vector_score,
143
217
  COALESCE(ts_rank(to_tsvector('english', t.*::text), NULLIF(REPLACE(plainto_tsquery('english', $2)::text, '&', '|'), '')::tsquery), 0) AS keyword_score,
144
- ((1 - (embedding <=> $1::vector)) + (COALESCE(ts_rank(to_tsvector('english', t.*::text), NULLIF(REPLACE(plainto_tsquery('english', $2)::text, '&', '|'), '')::tsquery), 0) * 2.0)) AS hybrid_score
218
+ ${exactValueScoreExpr} AS exact_value_score,
219
+ ${exactFieldScoreExpr} AS exact_field_score,
220
+ (
221
+ (1 - (embedding <=> $1::vector)) +
222
+ (COALESCE(ts_rank(to_tsvector('english', t.*::text), NULLIF(REPLACE(plainto_tsquery('english', $2)::text, '&', '|'), '')::tsquery), 0) * 2.0) +
223
+ (${exactValueScoreExpr} * 3.0) +
224
+ (${exactFieldScoreExpr} * 5.0)
225
+ ) AS hybrid_score
145
226
  FROM "${table}" t
146
227
  ORDER BY hybrid_score DESC
147
228
  LIMIT 50
148
229
  `;
149
- params = [vectorLiteral, queryText];
150
230
  } else {
151
231
  // Fallback to Vector-only Search
232
+ params = [vectorLiteral];
152
233
  sqlQuery = `
153
234
  SELECT *,
154
235
  (1 - (embedding <=> $1::vector)) AS hybrid_score
@@ -156,7 +237,19 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
156
237
  ORDER BY hybrid_score DESC
157
238
  LIMIT 50
158
239
  `;
159
- params = [vectorLiteral];
240
+ }
241
+
242
+ for (const [key, value] of Object.entries(explicitFilter)) {
243
+ const column = availableFields.includes(key)
244
+ ? key
245
+ : normalizedFieldMap.get(normalizeFieldName(key));
246
+ if (!column) continue;
247
+ params.push(String(value));
248
+ whereConditions.push(`COALESCE(to_jsonb(t)->>'${column}', '') = $${params.length}::text`);
249
+ }
250
+
251
+ if (whereConditions.length > 0) {
252
+ sqlQuery = sqlQuery.replace(`FROM "${table}" t`, `FROM "${table}" t\n WHERE ${whereConditions.join(' AND ')}`);
160
253
  }
161
254
 
162
255
  const result = await this.pool.query(sqlQuery, params);
@@ -173,6 +266,8 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
173
266
  delete rest.embedding;
174
267
  delete rest.vector_score;
175
268
  delete rest.keyword_score;
269
+ delete rest.exact_value_score;
270
+ delete rest.exact_field_score;
176
271
 
177
272
  const content = `[TYPE: ${table.replace(/s$/, '').toUpperCase()}]\n` +
178
273
  Object.entries(rest)
@@ -202,32 +297,14 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
202
297
  allResults.push(...tableResults);
203
298
  }
204
299
 
205
- // 1. Group results by table to ensure we take the top ones from each
206
- const resultsByTable: Record<string, VectorMatch[]> = {};
207
- for (const res of allResults) {
208
- const table = res.metadata?.source_table as string;
209
- if (!resultsByTable[table]) resultsByTable[table] = [];
210
- resultsByTable[table].push(res);
211
- }
212
-
213
- // 2. Take top 3 from EACH table guaranteed, then fill the rest with overall best
214
- const balancedResults: VectorMatch[] = [];
215
- const tables = Object.keys(resultsByTable);
216
-
217
- // First, ensure every table's best match is included
218
- for (const table of tables) {
219
- balancedResults.push(...resultsByTable[table].slice(0, 3));
220
- }
221
-
222
- // 3. Sort the balanced list by score
223
- const finalSorted = balancedResults.sort((a, b) => b.score - a.score);
300
+ const finalSorted = allResults.sort((a, b) => b.score - a.score);
224
301
 
225
302
  if (finalSorted.length > 0) {
226
303
  console.log(`[MultiTablePostgresProvider] --- Search Complete ---`);
227
304
  console.log(`[MultiTablePostgresProvider] Final top match from "${finalSorted[0].metadata?.source_table ?? 'unknown'}" with score ${finalSorted[0].score.toFixed(4)}`);
228
305
  }
229
306
 
230
- return finalSorted.slice(0, Math.max(topK, 15));
307
+ return finalSorted.slice(0, topK);
231
308
  }
232
309
 
233
310
  async delete(_id: string | number, _namespace?: string): Promise<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
@@ -252,4 +329,67 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
252
329
  await this.pool.end();
253
330
  }
254
331
  }
332
+
333
+ private async loadTableSearchConfig(
334
+ client: PoolClient,
335
+ tables: string[]
336
+ ): Promise<Map<string, TableSearchConfig>> {
337
+ if (tables.length === 0) return new Map();
338
+
339
+ const configuredSearchFields = this.parseConfiguredSearchFields();
340
+ const result = await client.query(`
341
+ SELECT table_name, column_name, data_type
342
+ FROM information_schema.columns
343
+ WHERE table_schema = 'public'
344
+ AND table_name = ANY($1::text[])
345
+ ORDER BY ordinal_position
346
+ `, [tables]);
347
+
348
+ const rowsByTable = new Map<string, Array<{ column_name: string; data_type: string }>>();
349
+ for (const row of result.rows as Array<{ table_name: string; column_name: string; data_type: string }>) {
350
+ const rows = rowsByTable.get(row.table_name) ?? [];
351
+ rows.push({ column_name: row.column_name, data_type: row.data_type });
352
+ rowsByTable.set(row.table_name, rows);
353
+ }
354
+
355
+ const configByTable = new Map<string, TableSearchConfig>();
356
+ for (const table of tables) {
357
+ const columns = rowsByTable.get(table) ?? [];
358
+ const availableFields = columns
359
+ .filter(({ column_name }) => column_name !== 'embedding')
360
+ .map(({ column_name }) => column_name);
361
+
362
+ const inferredFields = columns
363
+ .filter(({ column_name, data_type }) =>
364
+ column_name !== 'embedding' &&
365
+ !['ARRAY', 'json', 'jsonb', 'bytea', 'tsvector', 'USER-DEFINED'].includes(data_type)
366
+ )
367
+ .map(({ column_name }) => column_name);
368
+
369
+ const searchableFields = configuredSearchFields.length > 0
370
+ ? configuredSearchFields.filter((field) => columns.some((column) => column.column_name === field))
371
+ : inferredFields;
372
+
373
+ const normalizedFieldMap = new Map<string, string>();
374
+ for (const field of availableFields) {
375
+ normalizedFieldMap.set(normalizeFieldName(field), field);
376
+ }
377
+
378
+ configByTable.set(table, {
379
+ availableFields,
380
+ searchableFields,
381
+ normalizedFieldMap,
382
+ });
383
+ }
384
+
385
+ return configByTable;
386
+ }
387
+
388
+ private parseConfiguredSearchFields(): string[] {
389
+ const raw = this.config.options?.searchFields;
390
+ if (Array.isArray(raw)) {
391
+ return raw.filter((field): field is string => typeof field === 'string' && field.trim().length > 0);
392
+ }
393
+ return [];
394
+ }
255
395
  }
@@ -3,6 +3,14 @@ import { VectorDBConfig } from '../../config/RagConfig';
3
3
  import { BaseVectorProvider } from './BaseVectorProvider';
4
4
  import { VectorMatch, UpsertDocument } from '../../types';
5
5
 
6
+ function stripInternalFilterKeys(filter?: Record<string, unknown>): Record<string, unknown> {
7
+ if (!filter) return {};
8
+
9
+ return Object.fromEntries(
10
+ Object.entries(filter).filter(([key]) => !key.startsWith('__'))
11
+ );
12
+ }
13
+
6
14
  /**
7
15
  * PostgreSQLProvider — PostgreSQL implementation using the pgvector extension.
8
16
  */
@@ -103,8 +111,9 @@ export class PostgreSQLProvider extends BaseVectorProvider {
103
111
  const params: unknown[] = [vectorLiteral, topK];
104
112
  if (namespace) params.push(namespace);
105
113
 
106
- if (filter && Object.keys(filter).length > 0) {
107
- const filterConditions = Object.entries(filter)
114
+ const publicFilter = stripInternalFilterKeys(filter);
115
+ if (Object.keys(publicFilter).length > 0) {
116
+ const filterConditions = Object.entries(publicFilter)
108
117
  .map(([key, val]) => {
109
118
  const paramIdx = params.length + 1;
110
119
  params.push(JSON.stringify(val));