@retrivora-ai/rag-engine 0.2.7 → 0.2.9

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.
@@ -7,30 +7,119 @@ import { BatchProcessor, BatchOptions } from './BatchProcessor';
7
7
  import { EmbeddingStrategyResolver } from '../config/EmbeddingStrategy';
8
8
  import { IngestDocument, ChatResponse } from '../types';
9
9
 
10
- function extractEntityHints(question: string): string[] {
11
- const hints = new Set<string>();
10
+ interface QueryFieldHint {
11
+ field?: string;
12
+ value: string;
13
+ }
14
+
15
+ interface QueryFilter {
16
+ metadata?: Record<string, string>;
17
+ keywords?: string[];
18
+ queryText?: string;
19
+ }
20
+
21
+ function normalizeHintValue(value: string): string {
22
+ return value.replace(/\s+/g, ' ').trim();
23
+ }
24
+
25
+ function isLikelyPromptPhrase(value: string): boolean {
26
+ return /^(what|which|who|where|when|why|how)\b/i.test(value.trim());
27
+ }
28
+
29
+ function extractQueryFieldHints(question: string): QueryFieldHint[] {
30
+ if (!question.trim()) return [];
31
+
32
+ const hints = new Map<string, QueryFieldHint>();
33
+
34
+ const addHint = (value: string, field?: string) => {
35
+ const normalizedValue = normalizeHintValue(value);
36
+ if (!normalizedValue) return;
37
+
38
+ const normalizedField = field
39
+ ? field
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9]+/g, ' ')
42
+ .trim()
43
+ : undefined;
44
+
45
+ const key = `${normalizedField ?? '*'}::${normalizedValue.toLowerCase()}`;
46
+ if (!hints.has(key)) {
47
+ hints.set(key, {
48
+ value: normalizedValue,
49
+ ...(normalizedField ? { field: normalizedField } : {}),
50
+ });
51
+ }
52
+ };
12
53
 
13
54
  for (const match of question.matchAll(/["']([^"']{2,100})["']/g)) {
14
- const value = match[1].trim();
15
- if (value) hints.add(value);
55
+ addHint(match[1]);
16
56
  }
17
57
 
18
- const namedEntityPatterns = [
19
- /\b(?:product\s+name|named|called)\s+([a-z0-9][\w\s-]{0,80})$/i,
20
- /\b(?:product\s+name|named|called)\s+([a-z0-9][\w\s-]{0,80})(?=[?.!,]|$)/i,
58
+ const naturalQuestionPatterns = [
59
+ /\b(?:what|which)\s+(?:is|are|was|were)\s+(?:the\s+)?([^?.!,]{1,60}?)\s+of\s+["']?([^"'\n?.!,]{2,120})["']?(?=[?.!,]|$)/gi,
60
+ /\b(?:who|what)\s+(?:is|are|was|were)\s+["']?([^"'\n?.!,]{2,120})["']?(?=[?.!,]|$)/gi,
61
+ /\b(?:about|for|regarding)\s+["']?([^"'\n?.!,]{2,120})["']?(?=[?.!,]|$)/gi,
21
62
  ];
22
63
 
23
- for (const pattern of namedEntityPatterns) {
24
- const match = question.match(pattern);
25
- const value = match?.[1]?.trim();
26
- if (value) {
27
- hints.add(value);
64
+ for (const pattern of naturalQuestionPatterns) {
65
+ for (const match of question.matchAll(pattern)) {
66
+ const value = match[2] ?? match[1];
67
+ if (value) addHint(value);
28
68
  }
29
69
  }
30
70
 
31
- return [...hints]
32
- .map((hint) => hint.replace(/\s+/g, ' ').trim())
33
- .filter(Boolean);
71
+ const fieldPattern = `([^\\n:=?.!,]{1,60}?)`;
72
+ const valuePattern = `([^\\n?.!,]{1,120}?)`;
73
+ const fieldValuePatterns = [
74
+ new RegExp(`\\b${fieldPattern}\\s*(?:=|:)\\s*["']?${valuePattern}["']?(?=[?.!,]|$)`, 'gi'),
75
+ new RegExp(`\\b${fieldPattern}\\s+(?:is|are|was|were|equals?|equal to|named|called)\\s+["']?${valuePattern}["']?(?=[?.!,]|$)`, 'gi'),
76
+ new RegExp(`\\bwith\\s+${fieldPattern}\\s+["']?${valuePattern}["']?(?=[?.!,]|$)`, 'gi'),
77
+ ];
78
+
79
+ for (const pattern of fieldValuePatterns) {
80
+ for (const match of question.matchAll(pattern)) {
81
+ const field = normalizeHintValue(match[1] ?? '');
82
+ const value = match[2] ?? '';
83
+
84
+ if (field && !isLikelyPromptPhrase(field)) {
85
+ addHint(value, field);
86
+ } else {
87
+ addHint(value);
88
+ }
89
+ }
90
+ }
91
+
92
+ for (const match of question.matchAll(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3}\b/g)) {
93
+ addHint(match[0]);
94
+ }
95
+
96
+ return [...hints.values()];
97
+ }
98
+
99
+ function buildQueryFilter(question: string, hints: QueryFieldHint[]): QueryFilter {
100
+ const filter: QueryFilter = { metadata: {}, keywords: [], queryText: question };
101
+
102
+ for (const hint of hints) {
103
+ if (hint.field) {
104
+ // prefer last-seen value for a field; providers may interpret filters differently
105
+ filter.metadata![hint.field] = hint.value;
106
+ } else {
107
+ // treat as keyword / named entity
108
+ filter.keywords!.push(hint.value);
109
+ }
110
+ }
111
+
112
+ // Also extract quoted phrases (if not already captured) as keywords
113
+ for (const match of question.matchAll(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3}\b/g)) {
114
+ const term = normalizeHintValue(match[0]);
115
+ if (term && !filter.keywords!.includes(term)) filter.keywords!.push(term);
116
+ }
117
+
118
+ // Remove empty metadata object when no fields present to keep filter minimal
119
+ if (Object.keys(filter.metadata || {}).length === 0) delete filter.metadata;
120
+ if (filter.keywords && filter.keywords.length === 0) delete filter.keywords;
121
+
122
+ return filter;
34
123
  }
35
124
 
36
125
  /**
@@ -161,12 +250,16 @@ export class Pipeline {
161
250
 
162
251
  try {
163
252
  const queryVector = await this.embeddingProvider.embed(question, { taskType: 'query' });
164
- const entityHints = extractEntityHints(question);
165
- // Pass the original text to the vector DB so it can perform hybrid (keyword) search if supported
166
- const rawMatches = await this.vectorDB.query(queryVector, topK, ns, {
167
- __queryText: question,
168
- __entityHints: entityHints,
169
- });
253
+ const fieldHints = extractQueryFieldHints(question);
254
+ // Build a provider-agnostic filter from hints + original query text. Providers
255
+ // may choose to use `queryText` for hybrid keyword matching and `metadata`
256
+ // for exact/filtered metadata queries.
257
+ const filter = buildQueryFilter(question, fieldHints) as Record<string, unknown>;
258
+
259
+ // Keep the raw hints available as well for providers that want richer parsing
260
+ filter.__fieldHints = fieldHints;
261
+
262
+ const rawMatches = await this.vectorDB.query(queryVector, topK, ns, filter);
170
263
 
171
264
  const sources = rawMatches.filter((m) => m.score >= scoreThreshold);
172
265
  const context = sources.length
@@ -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
  * MongoDBProvider — MongoDB Atlas Vector Search implementation.
8
16
  */
@@ -67,6 +75,7 @@ export class MongoDBProvider extends BaseVectorProvider {
67
75
  }
68
76
 
69
77
  async query(vector: number[], topK: number, namespace?: string, filter?: Record<string, unknown>): Promise<VectorMatch[]> {
78
+ const publicFilter = stripInternalFilterKeys(filter);
70
79
  const pipeline: Record<string, unknown>[] = [
71
80
  {
72
81
  $vectorSearch: {
@@ -75,7 +84,9 @@ export class MongoDBProvider extends BaseVectorProvider {
75
84
  queryVector: vector,
76
85
  numCandidates: Math.max(topK * 10, 100),
77
86
  limit: topK,
78
- ...(filter || namespace ? { filter: { ...(filter || {}), ...(namespace ? { namespace } : {}) } } : {}),
87
+ ...(Object.keys(publicFilter).length > 0 || namespace
88
+ ? { filter: { ...publicFilter, ...(namespace ? { namespace } : {}) } }
89
+ : {}),
79
90
  },
80
91
  },
81
92
  {
@@ -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));