@retrivora-ai/rag-engine 0.2.7 → 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.
@@ -3,7 +3,42 @@ import { BaseVectorProvider } from './BaseVectorProvider';
3
3
  import { VectorMatch, UpsertDocument } from '../../types';
4
4
  import { VectorDBConfig } from '../../config/RagConfig';
5
5
 
6
- const EXACT_MATCH_FIELDS = ['name', 'product_name', 'productname', 'title'] as const;
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
+ }
7
42
 
8
43
  /**
9
44
  * MultiTablePostgresProvider — PostgreSQL implementation that searches across
@@ -18,6 +53,7 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
18
53
  private readonly dimensions: number;
19
54
  private readonly connectionString: string;
20
55
  private tables: string[];
56
+ private tableSearchConfig = new Map<string, TableSearchConfig>();
21
57
 
22
58
  constructor(config: VectorDBConfig) {
23
59
  super(config);
@@ -78,6 +114,7 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
78
114
  if (this.tables.length === 0) {
79
115
  console.warn('[MultiTablePostgresProvider] No tables with "embedding" columns found in the database.');
80
116
  } else {
117
+ this.tableSearchConfig = await this.loadTableSearchConfig(client, this.tables);
81
118
  console.log(
82
119
  `[MultiTablePostgresProvider] Connected. Searching across ${this.tables.length} table(s): ${this.tables.join(', ')}`
83
120
  );
@@ -126,44 +163,73 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
126
163
  console.log(`[MultiTablePostgresProvider] --- Starting Multi-Table Search ---`);
127
164
 
128
165
  const queryText = _filter?.__queryText as string | undefined;
129
- const entityHints = Array.isArray(_filter?.__entityHints)
130
- ? (_filter.__entityHints as unknown[])
131
- .filter((hint): hint is string => typeof hint === 'string')
132
- .map((hint) => hint.trim().toLowerCase())
133
- .filter(Boolean)
134
- : [];
166
+ const fieldHints = sanitizeFilterHints(_filter);
167
+ const explicitFilter = stripInternalFilterKeys(_filter);
135
168
 
136
169
  const queryPromises = this.tables.map(async (table) => {
137
170
  try {
138
171
  let sqlQuery = '';
139
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[] = [];
140
178
 
141
179
  if (queryText) {
142
- const hasEntityHints = entityHints.length > 0;
143
- const exactNameScoreExpr = hasEntityHints
144
- ? `GREATEST(${EXACT_MATCH_FIELDS.map((field) =>
145
- `CASE WHEN LOWER(COALESCE(to_jsonb(t)->>'${field}', '')) = ANY($3::text[]) THEN 1 ELSE 0 END`
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`
146
193
  ).join(', ')})`
147
194
  : '0';
148
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
+
149
211
  // Actual Hybrid Search: Semantic Vector Score + Full-Text Keyword Rank
150
212
  // We convert plainto_tsquery's AND (&) operator to OR (|) so that natural language queries
151
- // (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.
152
214
  sqlQuery = `
153
215
  SELECT *,
154
216
  (1 - (embedding <=> $1::vector)) AS vector_score,
155
217
  COALESCE(ts_rank(to_tsvector('english', t.*::text), NULLIF(REPLACE(plainto_tsquery('english', $2)::text, '&', '|'), '')::tsquery), 0) AS keyword_score,
156
- ${exactNameScoreExpr} AS exact_name_score,
157
- ((1 - (embedding <=> $1::vector)) + (COALESCE(ts_rank(to_tsvector('english', t.*::text), NULLIF(REPLACE(plainto_tsquery('english', $2)::text, '&', '|'), '')::tsquery), 0) * 2.0) + (${exactNameScoreExpr} * 5.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
158
226
  FROM "${table}" t
159
227
  ORDER BY hybrid_score DESC
160
228
  LIMIT 50
161
229
  `;
162
- params = hasEntityHints
163
- ? [vectorLiteral, queryText, entityHints]
164
- : [vectorLiteral, queryText];
165
230
  } else {
166
231
  // Fallback to Vector-only Search
232
+ params = [vectorLiteral];
167
233
  sqlQuery = `
168
234
  SELECT *,
169
235
  (1 - (embedding <=> $1::vector)) AS hybrid_score
@@ -171,7 +237,19 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
171
237
  ORDER BY hybrid_score DESC
172
238
  LIMIT 50
173
239
  `;
174
- 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 ')}`);
175
253
  }
176
254
 
177
255
  const result = await this.pool.query(sqlQuery, params);
@@ -188,7 +266,8 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
188
266
  delete rest.embedding;
189
267
  delete rest.vector_score;
190
268
  delete rest.keyword_score;
191
- delete rest.exact_name_score;
269
+ delete rest.exact_value_score;
270
+ delete rest.exact_field_score;
192
271
 
193
272
  const content = `[TYPE: ${table.replace(/s$/, '').toUpperCase()}]\n` +
194
273
  Object.entries(rest)
@@ -250,4 +329,67 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
250
329
  await this.pool.end();
251
330
  }
252
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
+ }
253
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));