@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.
@@ -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(levenshteinSimilarity(normalized, r._nameN || '', locale), levenshteinSimilarity(normalized, r._locationN || '', locale), levenshteinSimilarity(normalized, r._categoriesN || '', locale));
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
- let sql = `
132
- SELECT s.*, fts.rank AS _ftsRank
133
- FROM ${tables.fts} fts
134
- JOIN ${tables.entities} s ON s.rowid = fts.rowid
135
- WHERE ${tables.fts} MATCH ?
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 fts.rank LIMIT ?';
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)}`;
@@ -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
- const result = await db.execute({ sql: `SELECT * FROM ${tables.entities}`, args: [] });
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 (const row of result.rows) {
147
- await updateRow(db, row);
148
- if (dialect === 'sqlite') {
149
- const id = row[columns.id];
150
- await indexTrigrams(id, row);
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
- count++;
163
+ if (result.rows.length < PAGE)
164
+ break;
153
165
  }
154
166
  if (dialect === 'sqlite') {
155
167
  await rebuildFts();
@@ -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. */
@@ -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