@vibeorm/extensions 2.2.1 → 2.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.
- package/README.md +52 -5
- package/dist/index.js +311 -37
- package/dist/index.js.map +5 -5
- package/dist/search/index.d.ts +29 -4
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +298 -33
- package/dist/search/index.js.map +3 -3
- package/dist/trgm/index.js +3 -3
- package/dist/trgm/index.js.map +3 -3
- package/dist/vector/index.d.ts.map +1 -1
- package/dist/vector/index.js +12 -3
- package/dist/vector/index.js.map +3 -3
- package/package.json +4 -4
package/dist/search/index.d.ts
CHANGED
|
@@ -17,8 +17,17 @@
|
|
|
17
17
|
* `prefix: true` (per query) turns the text into per-token prefix matching
|
|
18
18
|
* for type-ahead boxes: unicode letter/number tokens, each matched as a
|
|
19
19
|
* prefix (tsvector `tok:*` via to_tsquery, fts5 `"tok"*`, fulltext BOOLEAN
|
|
20
|
-
* MODE `+tok*`).
|
|
21
|
-
*
|
|
20
|
+
* MODE `+tok*`). On bm25 the index tokenizer decides: an `edge_ngram`
|
|
21
|
+
* declaration (`@@search(…, tokenizer: "edge_ngram", minGram: 1, maxGram: 12)`)
|
|
22
|
+
* makes every token a prefix through `&&&`, and `fuzzy` prefix-matches within
|
|
23
|
+
* an edit distance on any tokenizer (`pdb.fuzzy(n, t)`).
|
|
24
|
+
*
|
|
25
|
+
* bm25 index options (`tokenizer`, `minGram`/`maxGram`, `stemmer`,
|
|
26
|
+
* `asciiFolding`, `lowercase`, `stopwordsLanguage`) are declared once per
|
|
27
|
+
* model and applied to every searched field as `pdb.<tokenizer>(…)` casts in
|
|
28
|
+
* the index column list; `weights` become per-field `pdb.boost(w)` factors.
|
|
29
|
+
* Every SQL shape here was verified against ParadeDB 0.25.7 (see
|
|
30
|
+
* `.ai/plans/26.09.18 - tripper-search-and-paradedb-migrations.md`).
|
|
22
31
|
*/
|
|
23
32
|
import type { Dialect, Provider, VibeExtension } from "@vibeorm/schema";
|
|
24
33
|
import type { ExtensionMountContext } from "@vibeorm/runtime";
|
|
@@ -67,6 +76,10 @@ export type SearchDelegateConfig = {
|
|
|
67
76
|
readonly rank?: "ts_rank" | "ts_rank_cd";
|
|
68
77
|
/** Single-column primary key: bm25's key_field and the `select: "id"` projection. */
|
|
69
78
|
readonly pkColumn?: string;
|
|
79
|
+
/** bm25 engine: per-field `pdb.boost` factors (the declared `weights`), parallel to `fields`. */
|
|
80
|
+
readonly boosts?: readonly number[];
|
|
81
|
+
/** bm25 engine: the index tokenizer is edge_ngram, so every query token already matches as a prefix. */
|
|
82
|
+
readonly prefixIndex?: boolean;
|
|
70
83
|
};
|
|
71
84
|
export type SearchArgs = {
|
|
72
85
|
readonly query: string;
|
|
@@ -86,10 +99,22 @@ export type SearchArgs = {
|
|
|
86
99
|
* type-ahead columns), fts5 `"piz"*`, fulltext `+piz*` in BOOLEAN MODE
|
|
87
100
|
* (InnoDB ignores tokens under `innodb_ft_min_token_size`, default 3). A
|
|
88
101
|
* query with no tokens resolves to `[]` without touching the database.
|
|
89
|
-
*
|
|
90
|
-
* prefix
|
|
102
|
+
* On bm25 the index decides: an `edge_ngram` tokenizer declaration makes
|
|
103
|
+
* every token a prefix (`&&&` match-all over the raw query); otherwise
|
|
104
|
+
* combine with `fuzzy` (`pdb.fuzzy(n, t)`), or the call is refused with
|
|
105
|
+
* `VIBE_UNSUPPORTED_CAPABILITY`.
|
|
91
106
|
*/
|
|
92
107
|
readonly prefix?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Typo tolerance (bm25 engine only): every query token matches within this
|
|
110
|
+
* Levenshtein edit distance — `1` accepts one insertion, deletion or
|
|
111
|
+
* substitution ("storgate" finds "Storgata"); ParadeDB caps it at `2`, and
|
|
112
|
+
* a transposition costs 2. Switches the match to `&&&` (every token must
|
|
113
|
+
* match) and combines with `prefix`. Other engines refuse it
|
|
114
|
+
* (`VIBE_UNSUPPORTED_CAPABILITY`) — use the trgm extension's `similarTo`
|
|
115
|
+
* there.
|
|
116
|
+
*/
|
|
117
|
+
readonly fuzzy?: 1 | 2;
|
|
93
118
|
/**
|
|
94
119
|
* Projection: `"row"` (default) returns full rows; `"id"` returns only the
|
|
95
120
|
* primary key — for repositories that re-read hits through their own typed
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/search/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/search/index.ts"],"names":[],"mappings":"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"}
|
package/dist/search/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// packages/extensions/src/search/index.ts
|
|
3
|
-
import { VibeError as VibeError2, defineExtension, unsupportedCapability } from "@vibeorm/schema";
|
|
3
|
+
import { VibeError as VibeError2, assertIdentifierFits, defineExtension, fitDefaultName, unsupportedCapability } from "@vibeorm/schema";
|
|
4
4
|
|
|
5
5
|
// packages/extensions/src/shared.ts
|
|
6
6
|
import { VibeError } from "@vibeorm/schema";
|
|
@@ -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) {
|
|
@@ -156,18 +350,30 @@ function resolveArtifactNames(params) {
|
|
|
156
350
|
hint: "only the tsvector engine materializes a generated column \u2014 drop columnName or target tsvector"
|
|
157
351
|
});
|
|
158
352
|
}
|
|
353
|
+
const remedy = "Shorten it, or set a shorter `name:` on the @@search declaration.";
|
|
354
|
+
if (decl.columnName !== undefined)
|
|
355
|
+
assertIdentifierFits({ name: decl.columnName, dialect, what: "@@search columnName", remedy });
|
|
356
|
+
if (decl.indexName !== undefined)
|
|
357
|
+
assertIdentifierFits({ name: decl.indexName, dialect, what: "@@search indexName", remedy });
|
|
159
358
|
return {
|
|
160
|
-
tsvColumn: decl.columnName ??
|
|
161
|
-
indexName: decl.indexName ??
|
|
359
|
+
tsvColumn: decl.columnName ?? fitDefaultName({ prefix: decl.base, suffix: "_tsv", dialect }),
|
|
360
|
+
indexName: decl.indexName ?? fitDefaultName({ prefix: decl.base, suffix: "_idx", dialect })
|
|
162
361
|
};
|
|
163
362
|
}
|
|
164
363
|
function resolveRank(params) {
|
|
165
364
|
const { decl, options, engine, dialect } = params;
|
|
166
|
-
if (decl.weights !== undefined && engine !== "tsvector") {
|
|
365
|
+
if (decl.weights !== undefined && engine !== "tsvector" && engine !== "bm25") {
|
|
167
366
|
throw unsupportedCapability({
|
|
168
367
|
dialect,
|
|
169
368
|
feature: `@@search weights on the ${engine} engine`,
|
|
170
|
-
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 }
|
|
171
377
|
});
|
|
172
378
|
}
|
|
173
379
|
if (decl.rank !== undefined && engine !== "tsvector") {
|
|
@@ -182,6 +388,25 @@ function resolveRank(params) {
|
|
|
182
388
|
const fallback = options["rank"];
|
|
183
389
|
return decl.rank ?? (fallback === "ts_rank" || fallback === "ts_rank_cd" ? fallback : undefined);
|
|
184
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
|
+
}
|
|
185
410
|
function tsvectorExpression(params) {
|
|
186
411
|
const { dialect, model, decl, language } = params;
|
|
187
412
|
const quote = (name) => dialect.quoteIdent({ name });
|
|
@@ -202,7 +427,7 @@ function searchArtifacts(ctx) {
|
|
|
202
427
|
artifacts.push({
|
|
203
428
|
id: "search:extension:pg_search",
|
|
204
429
|
owns: { kind: "postgresExtension", name: "pg_search" },
|
|
205
|
-
create: ["CREATE EXTENSION IF NOT EXISTS pg_search;"],
|
|
430
|
+
create: ["CREATE EXTENSION IF NOT EXISTS pg_search CASCADE;"],
|
|
206
431
|
drop: ["DROP EXTENSION IF EXISTS pg_search;"]
|
|
207
432
|
});
|
|
208
433
|
}
|
|
@@ -216,6 +441,7 @@ function searchArtifacts(ctx) {
|
|
|
216
441
|
const table = tableNameOf({ model });
|
|
217
442
|
const columns = decl.fields.map((field) => columnNameOf({ model, field }));
|
|
218
443
|
resolveRank({ decl, options: ctx.options, engine, dialect: ctx.dialect });
|
|
444
|
+
assertBm25Declarations({ decl, engine, dialect: ctx.dialect });
|
|
219
445
|
if (engine === "tsvector") {
|
|
220
446
|
const language = decl.language ?? ctx.options["language"] ?? "english";
|
|
221
447
|
const { tsvColumn, indexName } = resolveArtifactNames({ decl, engine, dialect: ctx.dialect });
|
|
@@ -238,21 +464,22 @@ function searchArtifacts(ctx) {
|
|
|
238
464
|
continue;
|
|
239
465
|
}
|
|
240
466
|
if (engine === "bm25") {
|
|
241
|
-
const
|
|
242
|
-
if (pkField === null) {
|
|
243
|
-
throw new VibeError2({
|
|
244
|
-
code: "VIBE_SCHEMA_INVALID",
|
|
245
|
-
message: `@@search with engine "bm25" needs a single-column primary key on "${modelName}" (key_field)`,
|
|
246
|
-
meta: { model: modelName }
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
const pkColumn = columnNameOf({ model, field: pkField });
|
|
467
|
+
const { pkColumn, searched, filters } = bm25IndexColumns({ model, decl });
|
|
250
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 })}`);
|
|
251
478
|
artifacts.push({
|
|
252
479
|
id: `search:${modelName}:index`,
|
|
253
480
|
owns: { kind: "index", table, name: bm25Names.indexName },
|
|
254
481
|
create: [
|
|
255
|
-
`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(", ")});`
|
|
256
483
|
],
|
|
257
484
|
drop: [`DROP INDEX IF EXISTS ${quote(bm25Names.indexName)};`]
|
|
258
485
|
});
|
|
@@ -282,7 +509,7 @@ function searchArtifacts(ctx) {
|
|
|
282
509
|
auxiliaryPrefixes: [`${fts}_`]
|
|
283
510
|
},
|
|
284
511
|
create: [
|
|
285
|
-
`CREATE VIRTUAL TABLE IF NOT EXISTS ${quote(fts)} USING fts5(${columnList}, content
|
|
512
|
+
`CREATE VIRTUAL TABLE IF NOT EXISTS ${quote(fts)} USING fts5(${columnList}, content=${dialect.quoteStringLiteral({ value: table })});`
|
|
286
513
|
],
|
|
287
514
|
drop: [`DROP TABLE IF EXISTS ${quote(fts)};`]
|
|
288
515
|
}, {
|
|
@@ -367,11 +594,26 @@ function createSearchDelegate(config) {
|
|
|
367
594
|
if (args.minScore !== undefined)
|
|
368
595
|
finiteNumber({ value: args.minScore, name: "minScore" });
|
|
369
596
|
const prefix = args.prefix === true;
|
|
370
|
-
|
|
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) {
|
|
371
613
|
throw unsupportedCapability({
|
|
372
614
|
dialect: "postgres",
|
|
373
|
-
feature: 'search prefix mode on the "bm25" engine',
|
|
374
|
-
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))'
|
|
375
617
|
});
|
|
376
618
|
}
|
|
377
619
|
let matchText = args.query;
|
|
@@ -379,7 +621,7 @@ function createSearchDelegate(config) {
|
|
|
379
621
|
const tokens = args.query.match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
380
622
|
if (tokens.length === 0)
|
|
381
623
|
return [];
|
|
382
|
-
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(" ");
|
|
383
625
|
}
|
|
384
626
|
const hlColumn = highlightColumn({ config, args });
|
|
385
627
|
const idOnly = args.select === "id";
|
|
@@ -450,31 +692,35 @@ function createSearchDelegate(config) {
|
|
|
450
692
|
text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
|
|
451
693
|
} else {
|
|
452
694
|
const pk = `${t}.${quote(config.pkColumn ?? "")}`;
|
|
695
|
+
const operator = prefix || fuzzy !== undefined ? "&&&" : "@@@";
|
|
696
|
+
const fuzzyCast = fuzzy === undefined ? "" : `::pdb.fuzzy(${fuzzy}${prefix ? ", t" : ""})`;
|
|
453
697
|
text = `SELECT ${projection}, paradedb.score(${pk}) AS ${quote(SCORE_ALIAS)}`;
|
|
454
698
|
if (hlColumn !== null) {
|
|
455
699
|
text += `, paradedb.snippet(${t}.${quote(hlColumn)}) AS ${quote(HIGHLIGHT_ALIAS)}`;
|
|
456
700
|
}
|
|
457
|
-
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 ")})`;
|
|
458
706
|
const where = userWhere("t");
|
|
459
707
|
if (where !== null)
|
|
460
708
|
text += ` AND (${where})`;
|
|
709
|
+
if (args.minScore !== undefined) {
|
|
710
|
+
text += ` AND paradedb.score(${pk}) >= ${collector.add(args.minScore)}`;
|
|
711
|
+
}
|
|
461
712
|
text += ` ORDER BY ${quote(SCORE_ALIAS)} DESC`;
|
|
462
713
|
}
|
|
463
714
|
text += ` LIMIT ${take}`;
|
|
464
715
|
if (skip !== null)
|
|
465
716
|
text += ` OFFSET ${skip}`;
|
|
466
717
|
const rows = await ctx.executeRows({ text, values: collector.values, method: "search" });
|
|
467
|
-
|
|
718
|
+
return shapeHits({
|
|
468
719
|
rows,
|
|
469
720
|
strip,
|
|
470
721
|
highlightsRequested: args.highlight !== undefined,
|
|
471
722
|
engineHasHighlights
|
|
472
723
|
});
|
|
473
|
-
if (config.engine === "bm25" && args.minScore !== undefined) {
|
|
474
|
-
const min = args.minScore;
|
|
475
|
-
hits = hits.filter((hit) => hit.score >= min);
|
|
476
|
-
}
|
|
477
|
-
return hits;
|
|
478
724
|
};
|
|
479
725
|
};
|
|
480
726
|
}
|
|
@@ -521,6 +767,19 @@ function search(options = {}) {
|
|
|
521
767
|
if (rank !== undefined && rank !== "ts_rank" && rank !== "ts_rank_cd") {
|
|
522
768
|
return { error: `rank must be "ts_rank" or "ts_rank_cd"` };
|
|
523
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
|
+
}
|
|
524
783
|
return { declaration: { fields, ...args.named } };
|
|
525
784
|
}
|
|
526
785
|
}
|
|
@@ -545,6 +804,9 @@ function search(options = {}) {
|
|
|
545
804
|
}
|
|
546
805
|
const pkField = primaryKeyFieldOf({ model });
|
|
547
806
|
const rank = resolveRank({ decl, options: extOptions, engine, dialect });
|
|
807
|
+
assertBm25Declarations({ decl, engine, dialect });
|
|
808
|
+
if (engine === "bm25")
|
|
809
|
+
bm25IndexColumns({ model, decl });
|
|
548
810
|
const names = resolveArtifactNames({ decl, engine, dialect });
|
|
549
811
|
const config = {
|
|
550
812
|
engine,
|
|
@@ -555,7 +817,9 @@ function search(options = {}) {
|
|
|
555
817
|
})),
|
|
556
818
|
...engine === "tsvector" ? { tsvColumn: names.tsvColumn, language, ...rank === undefined ? {} : { rank } } : {},
|
|
557
819
|
...engine === "fts5" ? { ftsTable: `${decl.base}_fts` } : {},
|
|
558
|
-
...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 } : {}
|
|
559
823
|
};
|
|
560
824
|
const fieldUnion = decl.fields.map((field) => JSON.stringify(field)).join(" | ");
|
|
561
825
|
const argLines = [
|
|
@@ -565,7 +829,8 @@ function search(options = {}) {
|
|
|
565
829
|
` readonly skip?: number;`,
|
|
566
830
|
` readonly minScore?: number;`,
|
|
567
831
|
` readonly highlight?: { readonly field: ${fieldUnion} };`,
|
|
568
|
-
` readonly prefix?: boolean
|
|
832
|
+
` readonly prefix?: boolean;`,
|
|
833
|
+
...engine === "bm25" ? [` readonly fuzzy?: 1 | 2;`] : []
|
|
569
834
|
];
|
|
570
835
|
const dts = pkField === null ? [
|
|
571
836
|
`readonly search: (args: {`,
|
|
@@ -633,4 +898,4 @@ export {
|
|
|
633
898
|
search
|
|
634
899
|
};
|
|
635
900
|
|
|
636
|
-
//# debugId=
|
|
901
|
+
//# debugId=B339B57B135FF5BD64756E2164756E21
|