@retrivora-ai/rag-engine 0.2.9 → 0.3.0

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.
@@ -37,9 +37,9 @@ function extractQueryFieldHints(question: string): QueryFieldHint[] {
37
37
 
38
38
  const normalizedField = field
39
39
  ? field
40
- .toLowerCase()
41
- .replace(/[^a-z0-9]+/g, ' ')
42
- .trim()
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9]+/g, ' ')
42
+ .trim()
43
43
  : undefined;
44
44
 
45
45
  const key = `${normalizedField ?? '*'}::${normalizedValue.toLowerCase()}`;
@@ -61,6 +61,43 @@ function extractQueryFieldHints(question: string): QueryFieldHint[] {
61
61
  /\b(?:about|for|regarding)\s+["']?([^"'\n?.!,]{2,120})["']?(?=[?.!,]|$)/gi,
62
62
  ];
63
63
 
64
+ // Targeted patterns: map common person/company question forms to a `name` metadata hint
65
+ const personCompanyPatterns = [
66
+ /\bcompany(?:\s+name)?\s+(?:of|for)\s+["']?([^"'\n?.!,]{2,120})["']?(?=[?.!,]|$)/gi,
67
+ /\b(?:which|what)\s+company\s+does\s+["']?([^"'\n?.!,]{2,120})["']?\s+work(?:\s+for|\s+at)?(?=[?.!,]|$)/gi,
68
+ /\bwhere\s+does\s+["']?([^"'\n?.!,]{2,120})["']?\s+work(?:\s+for|\s+at)?(?=[?.!,]|$)/gi,
69
+ ];
70
+
71
+ for (const pattern of personCompanyPatterns) {
72
+ for (const match of question.matchAll(pattern)) {
73
+ const name = match[1];
74
+ if (name) addHint(name, 'name');
75
+ }
76
+ }
77
+
78
+ // Universal patterns: capture common data types across domains so the system
79
+ // can construct metadata filters for arbitrary datasets. These are intentionally
80
+ // generic and provider-agnostic (e.g., `email`, `phone`, `date`, `amount`, `id`).
81
+ const universalPatterns: Array<{ regex: RegExp; field?: string; group?: number }> = [
82
+ { regex: /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/gi, field: 'email', group: 1 },
83
+ { regex: /(\+?\d[\d\-\.\s\(\)]{6,}\d)/g, field: 'phone', group: 1 },
84
+ { regex: /(\b\d{4}-\d{2}-\d{2}\b|\b\d{1,2}\/\d{1,2}\/\d{2,4}\b)/g, field: 'date', group: 1 },
85
+ { regex: /(\$\s?\d{1,3}(?:,\d{3})*(?:\.\d+)?)/g, field: 'amount', group: 1 },
86
+ { regex: /\b(ID|id|identifier)[: ]\s*([A-Za-z0-9\-]{3,})\b/gi, field: 'id', group: 2 },
87
+ // Generic quoted phrase / proper-noun sequences as keywords (already partially handled above)
88
+ { regex: /"([^"]{2,120})"/g, group: 1 },
89
+ { regex: /'([^']{2,120})'/g, group: 1 },
90
+ ];
91
+
92
+ for (const p of universalPatterns) {
93
+ for (const match of question.matchAll(p.regex)) {
94
+ const val = p.group ? match[p.group] ?? match[0] : match[0];
95
+ if (!val) continue;
96
+ if (p.field) addHint(val, p.field);
97
+ else addHint(val);
98
+ }
99
+ }
100
+
64
101
  for (const pattern of naturalQuestionPatterns) {
65
102
  for (const match of question.matchAll(pattern)) {
66
103
  const value = match[2] ?? match[1];
@@ -89,7 +126,7 @@ function extractQueryFieldHints(question: string): QueryFieldHint[] {
89
126
  }
90
127
  }
91
128
 
92
- for (const match of question.matchAll(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3}\b/g)) {
129
+ for (const match of question.matchAll(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3}\b/g)) {
93
130
  addHint(match[0]);
94
131
  }
95
132
 
@@ -257,7 +294,7 @@ export class Pipeline {
257
294
  const filter = buildQueryFilter(question, fieldHints) as Record<string, unknown>;
258
295
 
259
296
  // Keep the raw hints available as well for providers that want richer parsing
260
- filter.__fieldHints = fieldHints;
297
+ filter.__entityHints = fieldHints;
261
298
 
262
299
  const rawMatches = await this.vectorDB.query(queryVector, topK, ns, filter);
263
300
 
@@ -1,44 +1,11 @@
1
+
2
+
3
+
1
4
  import { Pool, PoolClient } from 'pg';
2
5
  import { BaseVectorProvider } from './BaseVectorProvider';
3
6
  import { VectorMatch, UpsertDocument } from '../../types';
4
7
  import { VectorDBConfig } from '../../config/RagConfig';
5
8
 
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
11
  * MultiTablePostgresProvider — PostgreSQL implementation that searches across
@@ -53,7 +20,7 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
53
20
  private readonly dimensions: number;
54
21
  private readonly connectionString: string;
55
22
  private tables: string[];
56
- private tableSearchConfig = new Map<string, TableSearchConfig>();
23
+ private searchFields: string[];
57
24
 
58
25
  constructor(config: VectorDBConfig) {
59
26
  super(config);
@@ -80,6 +47,16 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
80
47
  'Set VECTOR_DB_TABLES as a comma-separated list of table names or pass options.tables.'
81
48
  );
82
49
  }
50
+
51
+ // Exact match fields can come from options.searchFields or be derived from VECTOR_DB_SEARCH_FIELDS env var
52
+ const rawSearchFields: string | string[] =
53
+ (opts.searchFields as string | string[]) ??
54
+ process.env.VECTOR_DB_SEARCH_FIELDS ??
55
+ ['name', 'product_name', 'productname', 'title'];
56
+
57
+ this.searchFields = typeof rawSearchFields === 'string'
58
+ ? rawSearchFields.split(',').map((f) => f.trim()).filter(Boolean)
59
+ : rawSearchFields;
83
60
  }
84
61
 
85
62
  async initialize(): Promise<void> {
@@ -114,7 +91,6 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
114
91
  if (this.tables.length === 0) {
115
92
  console.warn('[MultiTablePostgresProvider] No tables with "embedding" columns found in the database.');
116
93
  } else {
117
- this.tableSearchConfig = await this.loadTableSearchConfig(client, this.tables);
118
94
  console.log(
119
95
  `[MultiTablePostgresProvider] Connected. Searching across ${this.tables.length} table(s): ${this.tables.join(', ')}`
120
96
  );
@@ -162,74 +138,47 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
162
138
 
163
139
  console.log(`[MultiTablePostgresProvider] --- Starting Multi-Table Search ---`);
164
140
 
165
- const queryText = _filter?.__queryText as string | undefined;
166
- const fieldHints = sanitizeFilterHints(_filter);
167
- const explicitFilter = stripInternalFilterKeys(_filter);
141
+ const queryText = _filter?.queryText as string | undefined;
142
+ const entityHints = Array.isArray(_filter?.__entityHints)
143
+ ? (_filter.__entityHints as unknown[])
144
+ .filter((hint): hint is { value: string; field?: string } =>
145
+ typeof hint === 'object' && hint !== null && typeof (hint as Record<string, unknown>).value === 'string'
146
+ )
147
+ .map((hint) => hint.value.trim().toLowerCase())
148
+ .filter(Boolean)
149
+ : [];
168
150
 
169
151
  const queryPromises = this.tables.map(async (table) => {
170
152
  try {
171
153
  let sqlQuery = '';
172
154
  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[] = [];
178
155
 
179
156
  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';
157
+ const hasEntityHints = entityHints.length > 0;
158
+ const exactNameScoreExpr = hasEntityHints
159
+ ? `+ (
160
+ SELECT COALESCE(MAX(
161
+ CASE WHEN LOWER(val) IN (${entityHints.map((h) => `'${h.replace(/'/g, "''")}'`).join(', ')})
162
+ THEN 1.0 ELSE 0.0 END
163
+ ), 0)
164
+ FROM jsonb_each_text(to_jsonb(t)) AS kv(key, val)
165
+ WHERE key IN (${this.searchFields.map(f => `'${f}'`).join(', ')})
166
+ ) * 3.0`
167
+ : '';
210
168
 
211
169
  // Actual Hybrid Search: Semantic Vector Score + Full-Text Keyword Rank
212
- // We convert plainto_tsquery's AND (&) operator to OR (|) so that natural language queries
213
- // (which contain words that are not present verbatim in the row text) still match the intended records.
214
170
  sqlQuery = `
215
171
  SELECT *,
216
172
  (1 - (embedding <=> $1::vector)) AS vector_score,
217
173
  COALESCE(ts_rank(to_tsvector('english', t.*::text), NULLIF(REPLACE(plainto_tsquery('english', $2)::text, '&', '|'), '')::tsquery), 0) AS keyword_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
174
+ ((1 - (embedding <=> $1::vector)) + (COALESCE(ts_rank(to_tsvector('english', t.*::text), NULLIF(REPLACE(plainto_tsquery('english', $2)::text, '&', '|'), '')::tsquery), 0) * 2.0) ${exactNameScoreExpr}) AS hybrid_score
226
175
  FROM "${table}" t
227
176
  ORDER BY hybrid_score DESC
228
177
  LIMIT 50
229
178
  `;
179
+ params = [vectorLiteral, queryText];
230
180
  } else {
231
181
  // Fallback to Vector-only Search
232
- params = [vectorLiteral];
233
182
  sqlQuery = `
234
183
  SELECT *,
235
184
  (1 - (embedding <=> $1::vector)) AS hybrid_score
@@ -237,19 +186,7 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
237
186
  ORDER BY hybrid_score DESC
238
187
  LIMIT 50
239
188
  `;
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 ')}`);
189
+ params = [vectorLiteral];
253
190
  }
254
191
 
255
192
  const result = await this.pool.query(sqlQuery, params);
@@ -266,8 +203,7 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
266
203
  delete rest.embedding;
267
204
  delete rest.vector_score;
268
205
  delete rest.keyword_score;
269
- delete rest.exact_value_score;
270
- delete rest.exact_field_score;
206
+ delete rest.exact_name_score;
271
207
 
272
208
  const content = `[TYPE: ${table.replace(/s$/, '').toUpperCase()}]\n` +
273
209
  Object.entries(rest)
@@ -297,14 +233,32 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
297
233
  allResults.push(...tableResults);
298
234
  }
299
235
 
300
- const finalSorted = allResults.sort((a, b) => b.score - a.score);
236
+ // 1. Group results by table to ensure we take the top ones from each
237
+ const resultsByTable: Record<string, VectorMatch[]> = {};
238
+ for (const res of allResults) {
239
+ const table = res.metadata?.source_table as string;
240
+ if (!resultsByTable[table]) resultsByTable[table] = [];
241
+ resultsByTable[table].push(res);
242
+ }
243
+
244
+ // 2. Take top 3 from EACH table guaranteed, then fill the rest with overall best
245
+ const balancedResults: VectorMatch[] = [];
246
+ const tables = Object.keys(resultsByTable);
247
+
248
+ // First, ensure every table's best match is included
249
+ for (const table of tables) {
250
+ balancedResults.push(...resultsByTable[table].slice(0, 3));
251
+ }
252
+
253
+ // 3. Sort the balanced list by score
254
+ const finalSorted = balancedResults.sort((a, b) => b.score - a.score);
301
255
 
302
256
  if (finalSorted.length > 0) {
303
257
  console.log(`[MultiTablePostgresProvider] --- Search Complete ---`);
304
258
  console.log(`[MultiTablePostgresProvider] Final top match from "${finalSorted[0].metadata?.source_table ?? 'unknown'}" with score ${finalSorted[0].score.toFixed(4)}`);
305
259
  }
306
260
 
307
- return finalSorted.slice(0, topK);
261
+ return finalSorted.slice(0, Math.max(topK, 15));
308
262
  }
309
263
 
310
264
  async delete(_id: string | number, _namespace?: string): Promise<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
@@ -329,67 +283,4 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
329
283
  await this.pool.end();
330
284
  }
331
285
  }
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
- }
395
286
  }