@vibeorm/extensions 2.2.2 → 2.3.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.
- package/README.md +52 -5
- package/dist/index.js +289 -29
- package/dist/index.js.map +3 -3
- package/dist/search/index.d.ts +28 -4
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +289 -29
- package/dist/search/index.js.map +3 -3
- package/package.json +4 -4
package/dist/search/index.js
CHANGED
|
@@ -85,10 +85,198 @@ async function pgExtensionCheck(params) {
|
|
|
85
85
|
|
|
86
86
|
// packages/extensions/src/search/index.ts
|
|
87
87
|
import { ParamCollector, getSqlDialect, renderExpr } from "@vibeorm/sql";
|
|
88
|
-
var VERSION = "2.0.0-alpha.
|
|
88
|
+
var VERSION = "2.0.0-alpha.2";
|
|
89
89
|
var SCORE_ALIAS = "_vibe_score";
|
|
90
90
|
var HIGHLIGHT_ALIAS = "_vibe_highlight";
|
|
91
91
|
var WEIGHT_LETTERS = ["A", "B", "C", "D"];
|
|
92
|
+
var BM25_TOKENIZERS = Object.freeze([
|
|
93
|
+
"unicode_words",
|
|
94
|
+
"simple",
|
|
95
|
+
"whitespace",
|
|
96
|
+
"ngram",
|
|
97
|
+
"edge_ngram",
|
|
98
|
+
"icu",
|
|
99
|
+
"literal_normalized",
|
|
100
|
+
"source_code",
|
|
101
|
+
"chinese_compatible",
|
|
102
|
+
"jieba"
|
|
103
|
+
]);
|
|
104
|
+
var STEMMER_LANGUAGES = Object.freeze([
|
|
105
|
+
"arabic",
|
|
106
|
+
"czech",
|
|
107
|
+
"danish",
|
|
108
|
+
"dutch",
|
|
109
|
+
"english",
|
|
110
|
+
"finnish",
|
|
111
|
+
"french",
|
|
112
|
+
"german",
|
|
113
|
+
"greek",
|
|
114
|
+
"hungarian",
|
|
115
|
+
"italian",
|
|
116
|
+
"norwegian",
|
|
117
|
+
"polish",
|
|
118
|
+
"portuguese",
|
|
119
|
+
"romanian",
|
|
120
|
+
"russian",
|
|
121
|
+
"spanish",
|
|
122
|
+
"swedish",
|
|
123
|
+
"tamil",
|
|
124
|
+
"turkish"
|
|
125
|
+
]);
|
|
126
|
+
var STOPWORD_LANGUAGES = Object.freeze([
|
|
127
|
+
"czech",
|
|
128
|
+
"danish",
|
|
129
|
+
"dutch",
|
|
130
|
+
"english",
|
|
131
|
+
"finnish",
|
|
132
|
+
"french",
|
|
133
|
+
"german",
|
|
134
|
+
"hungarian",
|
|
135
|
+
"italian",
|
|
136
|
+
"norwegian",
|
|
137
|
+
"polish",
|
|
138
|
+
"portuguese",
|
|
139
|
+
"russian",
|
|
140
|
+
"spanish",
|
|
141
|
+
"swedish"
|
|
142
|
+
]);
|
|
143
|
+
var TOKENIZER_KEYS = ["tokenizer", "minGram", "maxGram", "stemmer", "asciiFolding", "lowercase", "stopwordsLanguage"];
|
|
144
|
+
function isPositiveInteger(value) {
|
|
145
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
|
|
146
|
+
}
|
|
147
|
+
function readBm25Tokenizer(declaration) {
|
|
148
|
+
if (TOKENIZER_KEYS.every((key) => declaration[key] === undefined))
|
|
149
|
+
return { tokenizer: undefined };
|
|
150
|
+
const name = declaration["tokenizer"] ?? "unicode_words";
|
|
151
|
+
if (typeof name !== "string" || !BM25_TOKENIZERS.includes(name)) {
|
|
152
|
+
return { error: `tokenizer must be one of ${BM25_TOKENIZERS.join(", ")}` };
|
|
153
|
+
}
|
|
154
|
+
const grams = name === "ngram" || name === "edge_ngram";
|
|
155
|
+
const minGram = declaration["minGram"];
|
|
156
|
+
const maxGram = declaration["maxGram"];
|
|
157
|
+
if (grams) {
|
|
158
|
+
if (!isPositiveInteger(minGram) || !isPositiveInteger(maxGram) || minGram > maxGram) {
|
|
159
|
+
return { error: `${name} needs integer minGram and maxGram with 1 <= minGram <= maxGram` };
|
|
160
|
+
}
|
|
161
|
+
} else if (minGram !== undefined || maxGram !== undefined) {
|
|
162
|
+
return { error: "minGram/maxGram apply only to the ngram and edge_ngram tokenizers" };
|
|
163
|
+
}
|
|
164
|
+
const filters = [];
|
|
165
|
+
const lowercase = declaration["lowercase"];
|
|
166
|
+
if (lowercase !== undefined && typeof lowercase !== "boolean")
|
|
167
|
+
return { error: "lowercase must be a boolean" };
|
|
168
|
+
if (lowercase === false)
|
|
169
|
+
filters.push("lowercase=false");
|
|
170
|
+
const folding = declaration["asciiFolding"];
|
|
171
|
+
if (folding !== undefined && typeof folding !== "boolean")
|
|
172
|
+
return { error: "asciiFolding must be a boolean" };
|
|
173
|
+
if (folding === true)
|
|
174
|
+
filters.push("ascii_folding=true");
|
|
175
|
+
const stemmer = declaration["stemmer"];
|
|
176
|
+
if (stemmer !== undefined) {
|
|
177
|
+
if (typeof stemmer !== "string" || !STEMMER_LANGUAGES.includes(stemmer.toLowerCase())) {
|
|
178
|
+
return { error: `stemmer must be one of ${STEMMER_LANGUAGES.join(", ")}` };
|
|
179
|
+
}
|
|
180
|
+
filters.push(`stemmer=${stemmer.toLowerCase()}`);
|
|
181
|
+
}
|
|
182
|
+
const stopwords = declaration["stopwordsLanguage"];
|
|
183
|
+
if (stopwords !== undefined) {
|
|
184
|
+
if (typeof stopwords !== "string" || !STOPWORD_LANGUAGES.includes(stopwords.toLowerCase())) {
|
|
185
|
+
return { error: `stopwordsLanguage must be one of ${STOPWORD_LANGUAGES.join(", ")}` };
|
|
186
|
+
}
|
|
187
|
+
filters.push(`stopwords_language=${stopwords.toLowerCase()}`);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
tokenizer: {
|
|
191
|
+
name,
|
|
192
|
+
...grams ? { minGram, maxGram } : {},
|
|
193
|
+
filters
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function bm25TokenizerArguments(params) {
|
|
198
|
+
const { dialect, tokenizer } = params;
|
|
199
|
+
const args = [
|
|
200
|
+
...tokenizer.minGram === undefined ? [] : [String(tokenizer.minGram), String(tokenizer.maxGram)],
|
|
201
|
+
...tokenizer.filters.map((filter) => dialect.quoteStringLiteral({ value: filter }))
|
|
202
|
+
];
|
|
203
|
+
return args.length === 0 ? "" : `(${args.join(", ")})`;
|
|
204
|
+
}
|
|
205
|
+
function bm25TokenizedColumn(params) {
|
|
206
|
+
const { dialect, column, tokenizer } = params;
|
|
207
|
+
const quoted = dialect.quoteIdent({ name: column });
|
|
208
|
+
if (tokenizer === undefined)
|
|
209
|
+
return quoted;
|
|
210
|
+
return `(${quoted}::pdb.${tokenizer.name}${bm25TokenizerArguments({ dialect, tokenizer })})`;
|
|
211
|
+
}
|
|
212
|
+
function bm25SearchTokenizer(params) {
|
|
213
|
+
const { tokenizer } = params;
|
|
214
|
+
if (tokenizer === undefined || tokenizer.name !== "edge_ngram")
|
|
215
|
+
return;
|
|
216
|
+
return tokenizer.filters.length === 0 ? "unicode_words" : `unicode_words(${tokenizer.filters.join(",")})`;
|
|
217
|
+
}
|
|
218
|
+
var BM25_FILTER_SCALARS = Object.freeze(["Boolean", "Int", "BigInt", "Float", "Decimal", "DateTime"]);
|
|
219
|
+
var BM25_BOOST_LIMIT = 2048;
|
|
220
|
+
function bm25StringStorage(field) {
|
|
221
|
+
if (field.isUnsupported === true || field.isList || field.type !== "String")
|
|
222
|
+
return null;
|
|
223
|
+
const native = (field.nativeType ?? "").toLowerCase();
|
|
224
|
+
if (native === "" || native === "text" || native.startsWith("varchar"))
|
|
225
|
+
return "text";
|
|
226
|
+
if (native === "uuid")
|
|
227
|
+
return "uuid";
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
function describeField(field) {
|
|
231
|
+
const scalar = typeof field.type === "string" ? field.type : `enum ${field.type.enum}`;
|
|
232
|
+
return `${scalar}${field.isList ? "[]" : ""}${field.nativeType === undefined ? "" : ` @db.${field.nativeType}`}`;
|
|
233
|
+
}
|
|
234
|
+
function bm25IndexColumns(params) {
|
|
235
|
+
const { model, decl } = params;
|
|
236
|
+
const pkField = primaryKeyFieldOf({ model });
|
|
237
|
+
if (pkField === null) {
|
|
238
|
+
throw new VibeError2({
|
|
239
|
+
code: "VIBE_SCHEMA_INVALID",
|
|
240
|
+
message: `@@search with engine "bm25" needs a single-column primary key on "${model.name}" (key_field)`,
|
|
241
|
+
meta: { model: model.name }
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const searched = decl.fields.map((name) => {
|
|
245
|
+
const field = model.fields.find((candidate) => candidate.name === name);
|
|
246
|
+
if (field === undefined)
|
|
247
|
+
return columnNameOf({ model, field: name });
|
|
248
|
+
if (bm25StringStorage(field) !== "text") {
|
|
249
|
+
throw unsupportedCapability({
|
|
250
|
+
dialect: "postgres",
|
|
251
|
+
feature: `@@search field "${name}" (${describeField(field)}) on the bm25 index`,
|
|
252
|
+
hint: "ParadeDB tokenizes text and varchar columns; search another column or store this one as text"
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return field.dbName ?? field.name;
|
|
256
|
+
});
|
|
257
|
+
const filters = (decl.filterFields ?? []).map((name) => {
|
|
258
|
+
const field = model.fields.find((candidate) => candidate.name === name);
|
|
259
|
+
if (field === undefined || name === pkField || decl.fields.includes(name)) {
|
|
260
|
+
throw new VibeError2({
|
|
261
|
+
code: "VIBE_SCHEMA_INVALID",
|
|
262
|
+
message: `@@search filterFields on "${model.name}": "${name}" must be a scalar field that is neither the primary key nor a searched field`,
|
|
263
|
+
meta: { model: model.name, field: name }
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
const column = field.dbName ?? field.name;
|
|
267
|
+
const storage = bm25StringStorage(field);
|
|
268
|
+
if (storage !== null)
|
|
269
|
+
return { column, literal: storage === "text" };
|
|
270
|
+
if (!field.isList && typeof field.type === "string" && BM25_FILTER_SCALARS.includes(field.type))
|
|
271
|
+
return { column, literal: false };
|
|
272
|
+
throw unsupportedCapability({
|
|
273
|
+
dialect: "postgres",
|
|
274
|
+
feature: `@@search filterFields entry "${name}" (${describeField(field)}) on the bm25 index`,
|
|
275
|
+
hint: "ParadeDB pushes filters into the index for text/varchar (exact match / IN), uuid, Boolean, Int, BigInt, Float, Decimal and DateTime columns \u2014 filter other columns outside the index"
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
return { pkColumn: columnNameOf({ model, field: pkField }), searched, filters };
|
|
279
|
+
}
|
|
92
280
|
function resolveSearchEngine(params) {
|
|
93
281
|
const { dialect, provider, requested } = params;
|
|
94
282
|
const native = dialect === "postgres" ? "tsvector" : dialect === "sqlite" ? "fts5" : "fulltext";
|
|
@@ -123,6 +311,10 @@ function readDeclaration(params) {
|
|
|
123
311
|
if (params.declaration["weights"] !== undefined && (!Array.isArray(params.declaration["weights"]) || params.declaration["weights"].length !== fields.length || params.declaration["weights"].some((weight) => typeof weight !== "number" || !Number.isFinite(weight)))) {
|
|
124
312
|
throw new VibeError2({ code: "VIBE_SCHEMA_INVALID", message: "weights must be a finite number list matching the field list", meta: { model: params.model.name } });
|
|
125
313
|
}
|
|
314
|
+
const tokenizer = readBm25Tokenizer(params.declaration);
|
|
315
|
+
if ("error" in tokenizer) {
|
|
316
|
+
throw new VibeError2({ code: "VIBE_SCHEMA_INVALID", message: `@@search on "${params.model.name}": ${tokenizer.error}`, meta: { model: params.model.name } });
|
|
317
|
+
}
|
|
126
318
|
return {
|
|
127
319
|
fields,
|
|
128
320
|
weights: declNumberArray(params.declaration, "weights"),
|
|
@@ -130,7 +322,9 @@ function readDeclaration(params) {
|
|
|
130
322
|
language: declString(params.declaration, "language"),
|
|
131
323
|
columnName: declString(params.declaration, "columnName"),
|
|
132
324
|
indexName: declString(params.declaration, "indexName"),
|
|
133
|
-
rank: declRank(params.declaration)
|
|
325
|
+
rank: declRank(params.declaration),
|
|
326
|
+
tokenizer: tokenizer.tokenizer,
|
|
327
|
+
filterFields: declStringArray(params.declaration, "filterFields")
|
|
134
328
|
};
|
|
135
329
|
}
|
|
136
330
|
function weightLetters(params) {
|
|
@@ -168,11 +362,18 @@ function resolveArtifactNames(params) {
|
|
|
168
362
|
}
|
|
169
363
|
function resolveRank(params) {
|
|
170
364
|
const { decl, options, engine, dialect } = params;
|
|
171
|
-
if (decl.weights !== undefined && engine !== "tsvector") {
|
|
365
|
+
if (decl.weights !== undefined && engine !== "tsvector" && engine !== "bm25") {
|
|
172
366
|
throw unsupportedCapability({
|
|
173
367
|
dialect,
|
|
174
368
|
feature: `@@search weights on the ${engine} engine`,
|
|
175
|
-
hint: "weights are supported
|
|
369
|
+
hint: "weights are supported by tsvector (setweight classes) and bm25 (pdb.boost factors); omit weights for this engine"
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (decl.weights !== undefined && engine === "bm25" && decl.weights.some((weight) => Math.abs(weight) > BM25_BOOST_LIMIT)) {
|
|
373
|
+
throw new VibeError2({
|
|
374
|
+
code: "VIBE_SCHEMA_INVALID",
|
|
375
|
+
message: `@@search weights on the bm25 engine are pdb.boost factors and must stay within \xB1${BM25_BOOST_LIMIT}`,
|
|
376
|
+
meta: { weights: decl.weights }
|
|
176
377
|
});
|
|
177
378
|
}
|
|
178
379
|
if (decl.rank !== undefined && engine !== "tsvector") {
|
|
@@ -187,6 +388,25 @@ function resolveRank(params) {
|
|
|
187
388
|
const fallback = options["rank"];
|
|
188
389
|
return decl.rank ?? (fallback === "ts_rank" || fallback === "ts_rank_cd" ? fallback : undefined);
|
|
189
390
|
}
|
|
391
|
+
function assertBm25Declarations(params) {
|
|
392
|
+
const { decl, engine, dialect } = params;
|
|
393
|
+
if (engine === "bm25")
|
|
394
|
+
return;
|
|
395
|
+
if (decl.tokenizer !== undefined) {
|
|
396
|
+
throw unsupportedCapability({
|
|
397
|
+
dialect,
|
|
398
|
+
feature: `@@search tokenizer options on the ${engine} engine`,
|
|
399
|
+
hint: 'tokenizer, minGram, maxGram, stemmer, asciiFolding, lowercase and stopwordsLanguage configure a ParadeDB bm25 index \u2014 drop them, or opt in with search({ engine: "bm25" })'
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
if (decl.filterFields !== undefined) {
|
|
403
|
+
throw unsupportedCapability({
|
|
404
|
+
dialect,
|
|
405
|
+
feature: `@@search filterFields on the ${engine} engine`,
|
|
406
|
+
hint: 'filterFields add columns to a ParadeDB bm25 index for filter pushdown \u2014 drop them, or opt in with search({ engine: "bm25" })'
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
190
410
|
function tsvectorExpression(params) {
|
|
191
411
|
const { dialect, model, decl, language } = params;
|
|
192
412
|
const quote = (name) => dialect.quoteIdent({ name });
|
|
@@ -207,7 +427,7 @@ function searchArtifacts(ctx) {
|
|
|
207
427
|
artifacts.push({
|
|
208
428
|
id: "search:extension:pg_search",
|
|
209
429
|
owns: { kind: "postgresExtension", name: "pg_search" },
|
|
210
|
-
create: ["CREATE EXTENSION IF NOT EXISTS pg_search;"],
|
|
430
|
+
create: ["CREATE EXTENSION IF NOT EXISTS pg_search CASCADE;"],
|
|
211
431
|
drop: ["DROP EXTENSION IF EXISTS pg_search;"]
|
|
212
432
|
});
|
|
213
433
|
}
|
|
@@ -221,6 +441,7 @@ function searchArtifacts(ctx) {
|
|
|
221
441
|
const table = tableNameOf({ model });
|
|
222
442
|
const columns = decl.fields.map((field) => columnNameOf({ model, field }));
|
|
223
443
|
resolveRank({ decl, options: ctx.options, engine, dialect: ctx.dialect });
|
|
444
|
+
assertBm25Declarations({ decl, engine, dialect: ctx.dialect });
|
|
224
445
|
if (engine === "tsvector") {
|
|
225
446
|
const language = decl.language ?? ctx.options["language"] ?? "english";
|
|
226
447
|
const { tsvColumn, indexName } = resolveArtifactNames({ decl, engine, dialect: ctx.dialect });
|
|
@@ -243,21 +464,22 @@ function searchArtifacts(ctx) {
|
|
|
243
464
|
continue;
|
|
244
465
|
}
|
|
245
466
|
if (engine === "bm25") {
|
|
246
|
-
const
|
|
247
|
-
if (pkField === null) {
|
|
248
|
-
throw new VibeError2({
|
|
249
|
-
code: "VIBE_SCHEMA_INVALID",
|
|
250
|
-
message: `@@search with engine "bm25" needs a single-column primary key on "${modelName}" (key_field)`,
|
|
251
|
-
meta: { model: modelName }
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
const pkColumn = columnNameOf({ model, field: pkField });
|
|
467
|
+
const { pkColumn, searched, filters } = bm25IndexColumns({ model, decl });
|
|
255
468
|
const bm25Names = resolveArtifactNames({ decl, engine, dialect: ctx.dialect });
|
|
469
|
+
const indexed = [
|
|
470
|
+
quote(pkColumn),
|
|
471
|
+
...searched.map((column) => bm25TokenizedColumn({ dialect, column, tokenizer: decl.tokenizer })),
|
|
472
|
+
...filters.map((filter) => filter.literal ? `(${quote(filter.column)}::pdb.literal)` : quote(filter.column))
|
|
473
|
+
];
|
|
474
|
+
const withOptions = [`key_field=${dialect.quoteStringLiteral({ value: pkColumn })}`];
|
|
475
|
+
const searchTokenizer = bm25SearchTokenizer({ tokenizer: decl.tokenizer });
|
|
476
|
+
if (searchTokenizer !== undefined)
|
|
477
|
+
withOptions.push(`search_tokenizer=${dialect.quoteStringLiteral({ value: searchTokenizer })}`);
|
|
256
478
|
artifacts.push({
|
|
257
479
|
id: `search:${modelName}:index`,
|
|
258
480
|
owns: { kind: "index", table, name: bm25Names.indexName },
|
|
259
481
|
create: [
|
|
260
|
-
`CREATE INDEX IF NOT EXISTS ${quote(bm25Names.indexName)} ON ${quote(table)} USING bm25 (${
|
|
482
|
+
`CREATE INDEX IF NOT EXISTS ${quote(bm25Names.indexName)} ON ${quote(table)} USING bm25 (${indexed.join(", ")}) WITH (${withOptions.join(", ")});`
|
|
261
483
|
],
|
|
262
484
|
drop: [`DROP INDEX IF EXISTS ${quote(bm25Names.indexName)};`]
|
|
263
485
|
});
|
|
@@ -372,11 +594,26 @@ function createSearchDelegate(config) {
|
|
|
372
594
|
if (args.minScore !== undefined)
|
|
373
595
|
finiteNumber({ value: args.minScore, name: "minScore" });
|
|
374
596
|
const prefix = args.prefix === true;
|
|
375
|
-
|
|
597
|
+
const fuzzy = args.fuzzy;
|
|
598
|
+
if (fuzzy !== undefined && fuzzy !== 1 && fuzzy !== 2) {
|
|
599
|
+
throw new VibeError2({
|
|
600
|
+
code: "VIBE_VALIDATION",
|
|
601
|
+
message: "fuzzy must be 1 or 2 (ParadeDB caps the edit distance at 2)",
|
|
602
|
+
meta: { argument: "fuzzy" }
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
if (fuzzy !== undefined && config.engine !== "bm25") {
|
|
606
|
+
throw unsupportedCapability({
|
|
607
|
+
dialect: dialect.name,
|
|
608
|
+
feature: `search fuzzy matching on the "${config.engine}" engine`,
|
|
609
|
+
hint: "only the bm25 engine (ParadeDB pdb.fuzzy) matches within an edit distance \u2014 on postgres/pglite declare @@trgm and filter with similarTo"
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
if (prefix && config.engine === "bm25" && config.prefixIndex !== true && fuzzy === undefined) {
|
|
376
613
|
throw unsupportedCapability({
|
|
377
614
|
dialect: "postgres",
|
|
378
|
-
feature: 'search prefix mode on the "bm25" engine',
|
|
379
|
-
hint:
|
|
615
|
+
feature: 'search prefix mode on the "bm25" engine without an edge_ngram index',
|
|
616
|
+
hint: 'declare @@search(\u2026, tokenizer: "edge_ngram", minGram: 1, maxGram: 12) so every token is indexed as a prefix, or pass fuzzy: 1 | 2 for fuzzy prefix matching (pdb.fuzzy(n, t))'
|
|
380
617
|
});
|
|
381
618
|
}
|
|
382
619
|
let matchText = args.query;
|
|
@@ -384,7 +621,7 @@ function createSearchDelegate(config) {
|
|
|
384
621
|
const tokens = args.query.match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
385
622
|
if (tokens.length === 0)
|
|
386
623
|
return [];
|
|
387
|
-
matchText = config.engine === "tsvector" ? tokens.map((token) => `${token}:*`).join(" & ") : config.engine === "fts5" ? tokens.map((token) => `"${token}"*`).join(" ") : tokens.map((token) => `+${token}*`).join(" ");
|
|
624
|
+
matchText = config.engine === "tsvector" ? tokens.map((token) => `${token}:*`).join(" & ") : config.engine === "fts5" ? tokens.map((token) => `"${token}"*`).join(" ") : config.engine === "bm25" ? args.query : tokens.map((token) => `+${token}*`).join(" ");
|
|
388
625
|
}
|
|
389
626
|
const hlColumn = highlightColumn({ config, args });
|
|
390
627
|
const idOnly = args.select === "id";
|
|
@@ -455,31 +692,35 @@ function createSearchDelegate(config) {
|
|
|
455
692
|
text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
|
|
456
693
|
} else {
|
|
457
694
|
const pk = `${t}.${quote(config.pkColumn ?? "")}`;
|
|
695
|
+
const operator = prefix || fuzzy !== undefined ? "&&&" : "@@@";
|
|
696
|
+
const fuzzyCast = fuzzy === undefined ? "" : `::pdb.fuzzy(${fuzzy}${prefix ? ", t" : ""})`;
|
|
458
697
|
text = `SELECT ${projection}, paradedb.score(${pk}) AS ${quote(SCORE_ALIAS)}`;
|
|
459
698
|
if (hlColumn !== null) {
|
|
460
699
|
text += `, paradedb.snippet(${t}.${quote(hlColumn)}) AS ${quote(HIGHLIGHT_ALIAS)}`;
|
|
461
700
|
}
|
|
462
|
-
text += ` FROM ${quote(config.table)} AS ${t} WHERE (${config.fields.map((field) =>
|
|
701
|
+
text += ` FROM ${quote(config.table)} AS ${t} WHERE (${config.fields.map((field, index) => {
|
|
702
|
+
const boost = config.boosts?.[index];
|
|
703
|
+
const boostCast = boost === undefined || boost === 1 ? "" : `::pdb.boost(${boost})`;
|
|
704
|
+
return `${t}.${quote(field.column)} ${operator} ${collector.add(matchText)}${fuzzyCast}${boostCast}`;
|
|
705
|
+
}).join(" OR ")})`;
|
|
463
706
|
const where = userWhere("t");
|
|
464
707
|
if (where !== null)
|
|
465
708
|
text += ` AND (${where})`;
|
|
709
|
+
if (args.minScore !== undefined) {
|
|
710
|
+
text += ` AND paradedb.score(${pk}) >= ${collector.add(args.minScore)}`;
|
|
711
|
+
}
|
|
466
712
|
text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
|
|
467
713
|
}
|
|
468
714
|
text += ` LIMIT ${take}`;
|
|
469
715
|
if (skip !== null)
|
|
470
716
|
text += ` OFFSET ${skip}`;
|
|
471
717
|
const rows = await ctx.executeRows({ text, values: collector.values, method: "search" });
|
|
472
|
-
|
|
718
|
+
return shapeHits({
|
|
473
719
|
rows,
|
|
474
720
|
strip,
|
|
475
721
|
highlightsRequested: args.highlight !== undefined,
|
|
476
722
|
engineHasHighlights
|
|
477
723
|
});
|
|
478
|
-
if (config.engine === "bm25" && args.minScore !== undefined) {
|
|
479
|
-
const min = args.minScore;
|
|
480
|
-
hits = hits.filter((hit) => hit.score >= min);
|
|
481
|
-
}
|
|
482
|
-
return hits;
|
|
483
724
|
};
|
|
484
725
|
};
|
|
485
726
|
}
|
|
@@ -526,6 +767,19 @@ function search(options = {}) {
|
|
|
526
767
|
if (rank !== undefined && rank !== "ts_rank" && rank !== "ts_rank_cd") {
|
|
527
768
|
return { error: `rank must be "ts_rank" or "ts_rank_cd"` };
|
|
528
769
|
}
|
|
770
|
+
const tokenizer = readBm25Tokenizer(args.named);
|
|
771
|
+
if ("error" in tokenizer)
|
|
772
|
+
return { error: tokenizer.error };
|
|
773
|
+
const filterFields = args.named["filterFields"];
|
|
774
|
+
if (filterFields !== undefined) {
|
|
775
|
+
if (!Array.isArray(filterFields) || filterFields.length === 0 || filterFields.some((name) => typeof name !== "string")) {
|
|
776
|
+
return { error: "filterFields must be a non-empty field list, e.g. filterFields: [municipalityNumber, active]" };
|
|
777
|
+
}
|
|
778
|
+
const invalid = filterFields.find((name) => !fieldNames.includes(String(name)) || fields.includes(String(name)));
|
|
779
|
+
if (invalid !== undefined) {
|
|
780
|
+
return { error: `filterFields entry "${String(invalid)}" must be a field on the model that is not also searched` };
|
|
781
|
+
}
|
|
782
|
+
}
|
|
529
783
|
return { declaration: { fields, ...args.named } };
|
|
530
784
|
}
|
|
531
785
|
}
|
|
@@ -550,6 +804,9 @@ function search(options = {}) {
|
|
|
550
804
|
}
|
|
551
805
|
const pkField = primaryKeyFieldOf({ model });
|
|
552
806
|
const rank = resolveRank({ decl, options: extOptions, engine, dialect });
|
|
807
|
+
assertBm25Declarations({ decl, engine, dialect });
|
|
808
|
+
if (engine === "bm25")
|
|
809
|
+
bm25IndexColumns({ model, decl });
|
|
553
810
|
const names = resolveArtifactNames({ decl, engine, dialect });
|
|
554
811
|
const config = {
|
|
555
812
|
engine,
|
|
@@ -560,7 +817,9 @@ function search(options = {}) {
|
|
|
560
817
|
})),
|
|
561
818
|
...engine === "tsvector" ? { tsvColumn: names.tsvColumn, language, ...rank === undefined ? {} : { rank } } : {},
|
|
562
819
|
...engine === "fts5" ? { ftsTable: `${decl.base}_fts` } : {},
|
|
563
|
-
...pkField !== null ? { pkColumn: columnNameOf({ model, field: pkField }) } : {}
|
|
820
|
+
...pkField !== null ? { pkColumn: columnNameOf({ model, field: pkField }) } : {},
|
|
821
|
+
...engine === "bm25" && decl.weights !== undefined ? { boosts: decl.weights } : {},
|
|
822
|
+
...engine === "bm25" && decl.tokenizer?.name === "edge_ngram" ? { prefixIndex: true } : {}
|
|
564
823
|
};
|
|
565
824
|
const fieldUnion = decl.fields.map((field) => JSON.stringify(field)).join(" | ");
|
|
566
825
|
const argLines = [
|
|
@@ -570,7 +829,8 @@ function search(options = {}) {
|
|
|
570
829
|
` readonly skip?: number;`,
|
|
571
830
|
` readonly minScore?: number;`,
|
|
572
831
|
` readonly highlight?: { readonly field: ${fieldUnion} };`,
|
|
573
|
-
` readonly prefix?: boolean
|
|
832
|
+
` readonly prefix?: boolean;`,
|
|
833
|
+
...engine === "bm25" ? [` readonly fuzzy?: 1 | 2;`] : []
|
|
574
834
|
];
|
|
575
835
|
const dts = pkField === null ? [
|
|
576
836
|
`readonly search: (args: {`,
|
|
@@ -638,4 +898,4 @@ export {
|
|
|
638
898
|
search
|
|
639
899
|
};
|
|
640
900
|
|
|
641
|
-
//# debugId=
|
|
901
|
+
//# debugId=B339B57B135FF5BD64756E2164756E21
|