@nomideusz/svelte-search 0.1.3 → 0.2.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/CHANGELOG.md +51 -36
- package/LICENSE +21 -21
- package/README.md +297 -297
- package/dist/core/engine.d.ts +7 -0
- package/dist/core/engine.js +30 -24
- package/dist/core/indexer.js +19 -7
- package/dist/core/normalize.d.ts +7 -0
- package/dist/core/normalize.js +16 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/locales/pl.js +463 -37
- package/package.json +27 -11
package/dist/core/engine.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// Dialect support:
|
|
13
13
|
// - 'sqlite' (default): FTS5 MATCH, custom trigram tables, rowid joins
|
|
14
14
|
// - 'postgres': tsvector/tsquery @@, pg_trgm similarity(), PK joins
|
|
15
|
-
import { normalize, trigrams, trigramSimilarity, levenshteinSimilarity, hasGeoIntent, stripGeoIntent } from './normalize.js';
|
|
15
|
+
import { normalize, trigrams, trigramSimilarity, levenshteinSimilarity, bestWordSimilarity, hasGeoIntent, stripGeoIntent } from './normalize.js';
|
|
16
16
|
import { haversineKm, walkingMinutes, boundingBox } from './geo.js';
|
|
17
17
|
// ── Query timeout helper ───────────────────────────────────
|
|
18
18
|
function withTimeout(promise, ms, fallback) {
|
|
@@ -36,7 +36,7 @@ function ph(index, dialect) {
|
|
|
36
36
|
}
|
|
37
37
|
// ── Create search engine ───────────────────────────────────
|
|
38
38
|
export function createSearchEngine(config) {
|
|
39
|
-
const { db, adapter, locale, dialect = 'sqlite', ftsTimeoutMs = 5000, fuzzyTimeoutMs = 3000, primaryRadiusKm = 15, nearbyRadiusKm = 30, maxNearby = 5, qualityThreshold = 0.75, maxFtsTerms = 6, } = config;
|
|
39
|
+
const { db, adapter, locale, dialect = 'sqlite', ftsTimeoutMs = 5000, fuzzyTimeoutMs = 3000, primaryRadiusKm = 15, nearbyRadiusKm = 30, maxNearby = 5, qualityThreshold = 0.75, maxFtsTerms = 6, ftsColumnWeights, } = config;
|
|
40
40
|
const { tables, columns, trigramColumns } = adapter;
|
|
41
41
|
// ── Main search ────────────────────────────────────────
|
|
42
42
|
async function search(params) {
|
|
@@ -72,11 +72,13 @@ export function createSearchEngine(config) {
|
|
|
72
72
|
// Merge, score, and rank
|
|
73
73
|
const merged = deduplicateById([...ftsResults, ...fuzzyResults]);
|
|
74
74
|
const scored = scoreResults(merged, normalized, lat, lng, geoIntent);
|
|
75
|
-
// Quality gate — fuzzy-only results need high Levenshtein similarity
|
|
75
|
+
// Quality gate — fuzzy-only results need high Levenshtein similarity.
|
|
76
|
+
// Per-word: a typo of one word in a multi-word name ("triranta" for
|
|
77
|
+
// "Triratna Warszawa ...") must qualify against the word, not the field.
|
|
76
78
|
const qualified = scored.filter(r => {
|
|
77
79
|
if (r._hasFts)
|
|
78
80
|
return true;
|
|
79
|
-
const best = Math.max(
|
|
81
|
+
const best = Math.max(bestWordSimilarity(normalized, r._nameN || '', locale), bestWordSimilarity(normalized, r._locationN || '', locale), bestWordSimilarity(normalized, r._categoriesN || '', locale));
|
|
80
82
|
return best >= qualityThreshold;
|
|
81
83
|
});
|
|
82
84
|
qualified.sort((a, b) => b.score - a.score);
|
|
@@ -128,11 +130,15 @@ export function createSearchEngine(config) {
|
|
|
128
130
|
return ftsSearchSqlite(ftsQuery, locationSlug, categorySlug, limit);
|
|
129
131
|
}
|
|
130
132
|
async function ftsSearchSqlite(ftsQuery, locationSlug, categorySlug, limit) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
// Weighted bm25 when configured — same semantics as rank (lower = better)
|
|
134
|
+
const rankExpr = ftsColumnWeights?.length
|
|
135
|
+
? `bm25(${tables.fts}, ${ftsColumnWeights.join(', ')})`
|
|
136
|
+
: 'fts.rank';
|
|
137
|
+
let sql = `
|
|
138
|
+
SELECT s.*, ${rankExpr} AS _ftsRank
|
|
139
|
+
FROM ${tables.fts} fts
|
|
140
|
+
JOIN ${tables.entities} s ON s.rowid = fts.rowid
|
|
141
|
+
WHERE ${tables.fts} MATCH ?
|
|
136
142
|
`;
|
|
137
143
|
const args = [ftsQuery];
|
|
138
144
|
if (locationSlug && columns.locationSlug) {
|
|
@@ -144,7 +150,7 @@ export function createSearchEngine(config) {
|
|
|
144
150
|
sql += ` AND s.${columns.categoriesNormalized} LIKE ?`;
|
|
145
151
|
args.push(`%${catName}%`);
|
|
146
152
|
}
|
|
147
|
-
sql += ' ORDER BY
|
|
153
|
+
sql += ' ORDER BY _ftsRank LIMIT ?';
|
|
148
154
|
args.push(limit);
|
|
149
155
|
const result = await db.execute({ sql, args });
|
|
150
156
|
return result.rows;
|
|
@@ -155,10 +161,10 @@ export function createSearchEngine(config) {
|
|
|
155
161
|
const tsCol = tables.fts; // e.g. "search_vector"
|
|
156
162
|
const args = [ftsQuery];
|
|
157
163
|
let argIdx = 2;
|
|
158
|
-
let sql = `
|
|
159
|
-
SELECT s.*, ts_rank(s.${tsCol}, to_tsquery('simple', ${ph(1, dialect)})) AS "_ftsRank"
|
|
160
|
-
FROM ${tables.entities} s
|
|
161
|
-
WHERE s.${tsCol} @@ to_tsquery('simple', ${ph(1, dialect)})
|
|
164
|
+
let sql = `
|
|
165
|
+
SELECT s.*, ts_rank(s.${tsCol}, to_tsquery('simple', ${ph(1, dialect)})) AS "_ftsRank"
|
|
166
|
+
FROM ${tables.entities} s
|
|
167
|
+
WHERE s.${tsCol} @@ to_tsquery('simple', ${ph(1, dialect)})
|
|
162
168
|
`;
|
|
163
169
|
if (locationSlug && columns.locationSlug) {
|
|
164
170
|
sql += ` AND s.${columns.locationSlug} = ${ph(argIdx, dialect)}`;
|
|
@@ -188,12 +194,12 @@ export function createSearchEngine(config) {
|
|
|
188
194
|
if (queryTrigrams.length === 0)
|
|
189
195
|
return [];
|
|
190
196
|
const phs = queryTrigrams.map(() => '?').join(',');
|
|
191
|
-
let sql = `
|
|
192
|
-
SELECT s.*, NULL AS _ftsRank,
|
|
193
|
-
(COUNT(DISTINCT t.${trigramColumns.trigram}) * 1.0 / ?) AS _fuzzyScore
|
|
194
|
-
FROM ${tables.trigrams} t
|
|
195
|
-
JOIN ${tables.entities} s ON s.${columns.id} = t.${trigramColumns.entityId}
|
|
196
|
-
WHERE t.${trigramColumns.trigram} IN (${phs})
|
|
197
|
+
let sql = `
|
|
198
|
+
SELECT s.*, NULL AS _ftsRank,
|
|
199
|
+
(COUNT(DISTINCT t.${trigramColumns.trigram}) * 1.0 / ?) AS _fuzzyScore
|
|
200
|
+
FROM ${tables.trigrams} t
|
|
201
|
+
JOIN ${tables.entities} s ON s.${columns.id} = t.${trigramColumns.entityId}
|
|
202
|
+
WHERE t.${trigramColumns.trigram} IN (${phs})
|
|
197
203
|
`;
|
|
198
204
|
const args = [queryTrigrams.length, ...queryTrigrams];
|
|
199
205
|
if (locationSlug && columns.locationSlug) {
|
|
@@ -222,10 +228,10 @@ export function createSearchEngine(config) {
|
|
|
222
228
|
const simExpr = simCols.length === 1
|
|
223
229
|
? `similarity(${simCols[0]}, ${ph(1, dialect)})`
|
|
224
230
|
: `GREATEST(${simCols.map(c => `similarity(COALESCE(${c}, ''), ${ph(1, dialect)})`).join(', ')})`;
|
|
225
|
-
let sql = `
|
|
226
|
-
SELECT s.*, NULL AS "_ftsRank", ${simExpr} AS "_fuzzyScore"
|
|
227
|
-
FROM ${tables.entities} s
|
|
228
|
-
WHERE ${simExpr} > 0.1
|
|
231
|
+
let sql = `
|
|
232
|
+
SELECT s.*, NULL AS "_ftsRank", ${simExpr} AS "_fuzzyScore"
|
|
233
|
+
FROM ${tables.entities} s
|
|
234
|
+
WHERE ${simExpr} > 0.1
|
|
229
235
|
`;
|
|
230
236
|
if (locationSlug && columns.locationSlug) {
|
|
231
237
|
sql += ` AND s.${columns.locationSlug} = ${ph(argIdx, dialect)}`;
|
package/dist/core/indexer.js
CHANGED
|
@@ -141,15 +141,27 @@ export function createIndexer(config) {
|
|
|
141
141
|
* Call after changing the normalize() function or locale.
|
|
142
142
|
*/
|
|
143
143
|
async function renormalizeAll(updateRow) {
|
|
144
|
-
|
|
144
|
+
// Page the scan: a single SELECT * of the whole table can exceed the
|
|
145
|
+
// libsql/sqld HTTP response-size cap once the table grows past ~1.5k rows.
|
|
146
|
+
const PAGE = 500;
|
|
145
147
|
let count = 0;
|
|
146
|
-
for (
|
|
147
|
-
await
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
for (let offset = 0;; offset += PAGE) {
|
|
149
|
+
const result = await db.execute({
|
|
150
|
+
sql: `SELECT * FROM ${tables.entities} ORDER BY ${columns.id} LIMIT ${PAGE} OFFSET ${offset}`,
|
|
151
|
+
args: [],
|
|
152
|
+
});
|
|
153
|
+
if (result.rows.length === 0)
|
|
154
|
+
break;
|
|
155
|
+
for (const row of result.rows) {
|
|
156
|
+
await updateRow(db, row);
|
|
157
|
+
if (dialect === 'sqlite') {
|
|
158
|
+
const id = row[columns.id];
|
|
159
|
+
await indexTrigrams(id, row);
|
|
160
|
+
}
|
|
161
|
+
count++;
|
|
151
162
|
}
|
|
152
|
-
|
|
163
|
+
if (result.rows.length < PAGE)
|
|
164
|
+
break;
|
|
153
165
|
}
|
|
154
166
|
if (dialect === 'sqlite') {
|
|
155
167
|
await rebuildFts();
|
package/dist/core/normalize.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ export declare function trigramSimilarity(a: string, b: string, locale?: SearchL
|
|
|
11
11
|
export declare function levenshtein(a: string, b: string): number;
|
|
12
12
|
/** Normalized Levenshtein similarity. 0..1, higher = more similar. */
|
|
13
13
|
export declare function levenshteinSimilarity(a: string, b: string, locale?: SearchLocale): number;
|
|
14
|
+
/**
|
|
15
|
+
* Best Levenshtein similarity of `query` against a multi-word field: the max
|
|
16
|
+
* of whole-field similarity and per-word similarity. A typo of one word in a
|
|
17
|
+
* long field ("triranta" vs "triratna warszawa buddyzm...") scores near 0
|
|
18
|
+
* against the whole field but 0.75 against the word it matches.
|
|
19
|
+
*/
|
|
20
|
+
export declare function bestWordSimilarity(query: string, field: string, locale?: SearchLocale): number;
|
|
14
21
|
/** Detect postcode format: XX-XXX or XXXXX (Polish default, override via locale). */
|
|
15
22
|
export declare function isPostcode(text: string): boolean;
|
|
16
23
|
/** Detect "near me" intent using locale geo patterns. */
|
package/dist/core/normalize.js
CHANGED
|
@@ -75,6 +75,22 @@ export function levenshteinSimilarity(a, b, locale) {
|
|
|
75
75
|
const max = Math.max(na.length, nb.length);
|
|
76
76
|
return max === 0 ? 1 : 1 - levenshtein(na, nb) / max;
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Best Levenshtein similarity of `query` against a multi-word field: the max
|
|
80
|
+
* of whole-field similarity and per-word similarity. A typo of one word in a
|
|
81
|
+
* long field ("triranta" vs "triratna warszawa buddyzm...") scores near 0
|
|
82
|
+
* against the whole field but 0.75 against the word it matches.
|
|
83
|
+
*/
|
|
84
|
+
export function bestWordSimilarity(query, field, locale) {
|
|
85
|
+
if (!field)
|
|
86
|
+
return 0;
|
|
87
|
+
let best = levenshteinSimilarity(query, field, locale);
|
|
88
|
+
for (const w of normalize(field, locale).split(' ')) {
|
|
89
|
+
if (w)
|
|
90
|
+
best = Math.max(best, levenshteinSimilarity(query, w, locale));
|
|
91
|
+
}
|
|
92
|
+
return best;
|
|
93
|
+
}
|
|
78
94
|
/** Detect postcode format: XX-XXX or XXXXX (Polish default, override via locale). */
|
|
79
95
|
export function isPostcode(text) {
|
|
80
96
|
return /^\d{2}-?\d{3}$/.test(text.trim());
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { SqlDialect, DatabaseClient, SchemaAdapter, SearchParams, SearchResult, SearchResponse, AutocompleteResult, SearchLocale, ResolverContext, ResolverLookups, ResolverAction, TrackSearchEvent, } from './core/types.js';
|
|
2
2
|
export { haversineKm, walkingMinutes, boundingBox, formatDistance, formatWalkingTime, walkingRoute, } from './core/geo.js';
|
|
3
|
-
export { normalize, stripDiacriticsGeneric, trigrams, trigramSimilarity, levenshtein, levenshteinSimilarity, isPostcode, hasGeoIntent, stripGeoIntent, stripStopWords, MIN_SEARCH_TOKEN_LENGTH, } from './core/normalize.js';
|
|
3
|
+
export { normalize, stripDiacriticsGeneric, trigrams, trigramSimilarity, levenshtein, levenshteinSimilarity, bestWordSimilarity, isPostcode, hasGeoIntent, stripGeoIntent, stripStopWords, MIN_SEARCH_TOKEN_LENGTH, } from './core/normalize.js';
|
|
4
4
|
export { createSearchEngine, type SearchEngineConfig } from './core/engine.js';
|
|
5
5
|
export { createIndexer, createLookupsLoader, type IndexerConfig, type LoadLookupsConfig } from './core/indexer.js';
|
|
6
6
|
export { parseQuery, findMatchingArea, findNearestLocationWithEntities, type ParsedQuery } from './core/resolver.js';
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Geo utilities
|
|
4
4
|
export { haversineKm, walkingMinutes, boundingBox, formatDistance, formatWalkingTime, walkingRoute, } from './core/geo.js';
|
|
5
5
|
// Normalization & similarity
|
|
6
|
-
export { normalize, stripDiacriticsGeneric, trigrams, trigramSimilarity, levenshtein, levenshteinSimilarity, isPostcode, hasGeoIntent, stripGeoIntent, stripStopWords, MIN_SEARCH_TOKEN_LENGTH, } from './core/normalize.js';
|
|
6
|
+
export { normalize, stripDiacriticsGeneric, trigrams, trigramSimilarity, levenshtein, levenshteinSimilarity, bestWordSimilarity, isPostcode, hasGeoIntent, stripGeoIntent, stripStopWords, MIN_SEARCH_TOKEN_LENGTH, } from './core/normalize.js';
|
|
7
7
|
// Search engine
|
|
8
8
|
export { createSearchEngine } from './core/engine.js';
|
|
9
9
|
// Indexer
|