pgkiln 0.68.0 → 0.70.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.
Files changed (65) hide show
  1. package/db/migrations/135_fulltext_search.sql +60 -0
  2. package/db/migrations/136_vector_search.sql +252 -0
  3. package/docs/apex-feature-parity.md +5 -4
  4. package/docs/guide/03-builder.md +76 -0
  5. package/docs/guide/04-pages-and-regions.md +86 -1
  6. package/docs/guide/09-reference.md +19 -0
  7. package/docs/guide/12-development.md +41 -2
  8. package/docs/guide/15-extensions.md +1 -1
  9. package/examples/hr/README.md +2 -0
  10. package/examples/hr/hr_71_fulltext_search.sql +43 -0
  11. package/examples/hr/hr_72_vector_search.sql +158 -0
  12. package/package.json +1 -1
  13. package/public/builder.css +3 -0
  14. package/src/ai/embeddings.ts +197 -0
  15. package/src/ai/service.ts +13 -9
  16. package/src/app.ts +3 -0
  17. package/src/builder/advisor.ts +79 -3
  18. package/src/builder/ai-agents.ts +13 -5
  19. package/src/builder/components.ts +32 -1
  20. package/src/builder/fulltext.ts +160 -0
  21. package/src/builder/home.ts +1 -0
  22. package/src/builder/i18n/de.ts +54 -0
  23. package/src/builder/i18n/en.ts +55 -0
  24. package/src/builder/i18n/es.ts +54 -0
  25. package/src/builder/i18n/fr.ts +54 -0
  26. package/src/builder/i18n/it.ts +54 -0
  27. package/src/builder/i18n/ja.ts +54 -0
  28. package/src/builder/i18n/ko.ts +54 -0
  29. package/src/builder/i18n/nl.ts +54 -0
  30. package/src/builder/i18n/pt.ts +54 -0
  31. package/src/builder/i18n/zh-cn.ts +54 -0
  32. package/src/builder/i18n/zh-tw.ts +54 -0
  33. package/src/builder/object-actions.ts +2 -0
  34. package/src/builder/shared.ts +1 -1
  35. package/src/builder/sql.ts +6 -0
  36. package/src/builder/table-editor.ts +2 -0
  37. package/src/builder/vector-column.ts +229 -0
  38. package/src/builder/vector-providers.ts +286 -0
  39. package/src/cli/replace.ts +3 -1
  40. package/src/i18n/ar.ts +1 -0
  41. package/src/i18n/cs.ts +1 -0
  42. package/src/i18n/da.ts +1 -0
  43. package/src/i18n/de.ts +1 -0
  44. package/src/i18n/el.ts +1 -0
  45. package/src/i18n/es.ts +1 -0
  46. package/src/i18n/fi.ts +1 -0
  47. package/src/i18n/fr.ts +1 -0
  48. package/src/i18n/he.ts +1 -0
  49. package/src/i18n/it.ts +1 -0
  50. package/src/i18n/ja.ts +1 -0
  51. package/src/i18n/ko.ts +1 -0
  52. package/src/i18n/nb.ts +1 -0
  53. package/src/i18n/pl.ts +1 -0
  54. package/src/i18n/pt.ts +1 -0
  55. package/src/i18n/ru.ts +1 -0
  56. package/src/i18n/sv.ts +1 -0
  57. package/src/i18n/tr.ts +1 -0
  58. package/src/i18n/uk.ts +1 -0
  59. package/src/i18n/zh.ts +1 -0
  60. package/src/i18n.ts +2 -0
  61. package/src/runtime/ai-agents.ts +53 -2
  62. package/src/runtime/report.ts +26 -2
  63. package/src/runtime/search.ts +290 -23
  64. package/src/server.ts +3 -0
  65. package/src/vector.ts +288 -0
@@ -17,6 +17,7 @@ import { REPORT_CHART_KINDS } from './charts.ts';
17
17
  import { ComputeError, computeNameOk, computeSql, type Computation } from './compute.ts';
18
18
  import { GRID_VIEWS, renderView, VIEWS, type View } from './report-views.ts';
19
19
  import { columnTemplates } from './template-components.ts';
20
+ import { textSearchConfig } from './search.ts';
20
21
  import { facetFilterSql, facetFilters, hidesColumns, reportFacetDefs, searchSql, userColumnNames } from './facet-state.ts';
21
22
  import { resolveRestRegion } from './rest-sources.ts';
22
23
  import { checksumValid, signText } from '../security.ts';
@@ -409,6 +410,18 @@ export async function withComputations(ctx: PageContext, r: Region, src: string,
409
410
  return { src: exprs.length ? `select "__s".*, ${exprs.join(', ')} from (\n${src}\n) "__s"` : src, errors };
410
411
  }
411
412
 
413
+ /**
414
+ * (135) The tsvector column a report's search also uses: the one its attributes name (`fulltext`, which may be a
415
+ * hidden column: the developer chose it), else the only tsvector column the user may see; null when there is none.
416
+ */
417
+ export function fulltextColumn(r: Pick<Region, 'config'>, fields: { name: string; dataTypeID: number }[], userCols: Map<string, number>) {
418
+ const TSVECTOR = 3614;
419
+ const named = typeof r.config.fulltext === 'string' ? r.config.fulltext : null;
420
+ if (named) return fields.find((f) => f.name === named && f.dataTypeID === TSVECTOR)?.name ?? null;
421
+ const shown = fields.filter((f) => f.dataTypeID === TSVECTOR && userCols.has(f.name));
422
+ return shown.length === 1 ? shown[0].name : null;
423
+ }
424
+
412
425
  /**
413
426
  * The report's query with the user's search, filters and facets applied:
414
427
  * the source, the WHERE clause, and the result's column names (only looked
@@ -422,14 +435,25 @@ export async function filtered(ctx: PageContext, r: Region, st: ReportState) {
422
435
  const p = new SqlParams();
423
436
  const hides = hidesColumns(r);
424
437
  const facets = facetFilters(ctx.params, r.id, reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions), undefined, appDateReader(ctx));
425
- const needCols = st.filters.length || facets.length || st.breakCol || st.aggregates.length || st.highlights.length || st.view !== 'report' || st.area || st.near || keysetColumns(r) || (hides && (st.search || st.sort)) || (st.search && st.searchCol);
438
+ const needCols = st.filters.length || facets.length || st.breakCol || st.aggregates.length || st.highlights.length || st.view !== 'report' || st.area || st.near || keysetColumns(r) || (hides && (st.search || st.sort)) || st.search;
426
439
  // column name → type oid
427
440
  const fields = needCols ? await fieldsOf(ctx, src) : [];
428
441
  const cols = new Map<string, number>(fields.map((f) => [f.name, f.dataTypeID]));
429
442
  // the columns the user may choose (filters, highlights, control break, aggregates, views, sorting): not the hidden ones
430
443
  const userCols = new Map<string, number>(userColumnNames(r, [...cols.keys()]).map((n) => [n, cols.get(n)!]));
431
444
  // (sprint 76) the search in one column the user may see, or in all
432
- if (st.search) where.push(searchSql(st.search, p, st.searchCol && userCols.has(st.searchCol) ? [st.searchCol] : hides ? [...userCols.keys()] : undefined));
445
+ if (st.search) {
446
+ const oneCol = !!st.searchCol && userCols.has(st.searchCol);
447
+ let cond = searchSql(st.search, p, oneCol ? [st.searchCol!] : hides ? [...userCols.keys()] : undefined);
448
+ // (135) a tsvector column in the source: the search also finds rows by full-text search (stemmed words,
449
+ // "phrases", or, -word), with a text search configuration from pg_ts_config as a query parameter
450
+ const fts = oneCol ? null : fulltextColumn(r, fields, userCols);
451
+ if (fts) {
452
+ const cfg = await textSearchConfig(ctx.client!, typeof r.config.fulltext_config === 'string' ? r.config.fulltext_config : null, ctx.app.language);
453
+ cond = `(${cond} or ${q(fts)} @@ websearch_to_tsquery(${p.add(cfg, 'regconfig')}, ${p.add(st.search, 'text')}))`;
454
+ }
455
+ where.push(cond);
456
+ }
433
457
  for (const f of st.filters) if (userCols.has(f.column)) where.push(OPERATORS[f.op].sql(q(f.column), f.value));
434
458
  for (const f of facets) {
435
459
  const cond = facetFilterSql(f, cols, p);
@@ -10,8 +10,22 @@
10
10
  // - sql: a SELECT (binds allowed), wrapped as a subquery;
11
11
  // - list: the entries of a list (Shared Components → Lists) the user may see.
12
12
  //
13
- // Every word of the search text must be found in one of the searchable
14
- // columns (ILIKE, % and _ escaped, the text as a query parameter). The
13
+ // Search type standard: every word of the search text must be found in one
14
+ // of the searchable columns (ILIKE, % and _ escaped, the text as a query
15
+ // parameter). Search type fulltext (135): the source's tsvector matched
16
+ // with websearch_to_tsquery (quotes, or, -word), the best match first
17
+ // (ts_rank_cd), title, subtitle and description highlighted by ts_headline
18
+ // (between private-use markers that only then become <mark>, everything
19
+ // else escaped); the text search configuration is checked against
20
+ // pg_ts_config and passed as a parameter. With pg_trgm installed, a
21
+ // configuration may suggest the closest words when nothing is found. Search
22
+ // type vector (136): the search text embedded by the configuration's vector
23
+ // provider (src/ai/embeddings.ts), the rows nearest by the vector column
24
+ // (pgvector's cosine, L2 or inner product distance, operators
25
+ // schema-qualified, the vector a query parameter), at least the minimum
26
+ // similarity. Search type hybrid (136): the full-text and the vector
27
+ // rankings merged by reciprocal rank fusion (1 / (60 + rank), summed per
28
+ // key). Without pgvector both say what is missing. The
15
29
  // queries run as the application's role in the page's transaction, each in a
16
30
  // savepoint, so one configuration that fails shows its error and the others
17
31
  // still answer. Without JavaScript the field is a GET form; app.js searches
@@ -22,7 +36,9 @@ import { runtime, savepoint } from '../db.ts';
22
36
  import { html, raw, type Raw } from '../html.ts';
23
37
  import { icon } from '../icons.ts';
24
38
  import type { Region } from '../metadata.ts';
25
- import { bindValues, publicError, stripSemicolon, type PageContext } from './context.ts';
39
+ import { embedQuery, vectorLiteral, vectorProviderFor } from '../ai/embeddings.ts';
40
+ import { DISTANCES, distanceSql, isDistance, PGVECTOR_MISSING, vectorSchema, type Distance } from '../vector.ts';
41
+ import { bindValues, dbg, publicError, stripSemicolon, type PageContext } from './context.ts';
26
42
  import { fillItems, linkAttrs } from './links.ts';
27
43
  import { listTree, type ListNode } from './lists.ts';
28
44
 
@@ -42,6 +58,24 @@ export interface SearchConfig {
42
58
  icon: string | null;
43
59
  link: { page?: number; items?: Record<string, string> } | null;
44
60
  max_results: number;
61
+ /** (135) standard: ILIKE per word · fulltext: PostgreSQL full-text search · (136) vector, hybrid */
62
+ search_type?: (typeof SEARCH_TYPES)[number];
63
+ /** (135) fulltext: a tsvector column or expression; empty: to_tsvector over the searchable columns */
64
+ fts_vector?: string | null;
65
+ /** (135) fulltext: the text search configuration; empty: the application language's */
66
+ fts_config?: string | null;
67
+ /** (135) with pg_trgm: suggest the closest words when nothing is found */
68
+ did_you_mean?: boolean;
69
+ /** (136) vector, hybrid: the vector provider (static ID) that embeds the search text */
70
+ vector_provider?: string | null;
71
+ /** (136) vector, hybrid: the source's vector column */
72
+ vector_column?: string | null;
73
+ /** (136) hybrid: the key that matches the full-text and the vector results */
74
+ key_column?: string | null;
75
+ /** (136) vector, hybrid: cosine, l2 or inner_product */
76
+ distance?: Distance;
77
+ /** (136) vector, hybrid: at least this similarity */
78
+ min_similarity?: number | null;
45
79
  }
46
80
 
47
81
  export interface SearchRegionConfig {
@@ -58,6 +92,57 @@ const MAX_TEXT = 200;
58
92
  const COLUMN = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
59
93
  const TABLE = /^([A-Za-z_][A-Za-z0-9_$]{0,62}\.)?[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
60
94
 
95
+ /** A client, a pool or pgkiln's Db: something that runs a query with parameters. */
96
+ type Queryable = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> };
97
+
98
+ /** (135) The search types; (136) vector and hybrid. */
99
+ export const SEARCH_TYPES = ['standard', 'fulltext', 'vector', 'hybrid'] as const;
100
+ /** (136) Rows each ranking of a hybrid search contributes, and reciprocal rank fusion's constant. */
101
+ const HYBRID_DEPTH = 50;
102
+ const RRF_K = 60;
103
+ const TS_CONFIG = /^[a-z_][a-z0-9_]{0,62}$/;
104
+
105
+ /** (135) The text search configuration of a language: pgkiln's languages that PostgreSQL has a stemmer for. */
106
+ const TS_BY_LANGUAGE: Record<string, string> = {
107
+ ar: 'arabic', ca: 'catalan', da: 'danish', de: 'german', el: 'greek', en: 'english', es: 'spanish', eu: 'basque', fi: 'finnish',
108
+ fr: 'french', ga: 'irish', hi: 'hindi', hu: 'hungarian', hy: 'armenian', id: 'indonesian', it: 'italian', lt: 'lithuanian',
109
+ nb: 'norwegian', ne: 'nepali', nl: 'dutch', nn: 'norwegian', no: 'norwegian', pt: 'portuguese', ro: 'romanian', ru: 'russian',
110
+ sr: 'serbian', sv: 'swedish', ta: 'tamil', tr: 'turkish', yi: 'yiddish',
111
+ };
112
+ export const tsConfigFor = (language: string | null | undefined) => TS_BY_LANGUAGE[String(language ?? '').toLowerCase().split(/[-_]/)[0]] ?? 'simple';
113
+
114
+ /**
115
+ * (135) The text search configuration to use: the one asked for, else the language's, if it exists in
116
+ * pg_ts_config; else simple. Only a name that exists is returned (and it is still passed as a parameter).
117
+ */
118
+ export async function textSearchConfig(db: Queryable, wanted: string | null | undefined, language: string | null | undefined): Promise<string> {
119
+ const names = [wanted, tsConfigFor(language)].filter((x): x is string => !!x && TS_CONFIG.test(x));
120
+ if (!names.length) return 'simple';
121
+ const found = (await db.query('select cfgname from pg_catalog.pg_ts_config where cfgname = any($1::text[])', [names])).rows.map((r: { cfgname: string }) => r.cfgname);
122
+ return names.find((n) => found.includes(n)) ?? 'simple';
123
+ }
124
+
125
+ /** (135) The schema of pg_trgm when it is installed (its functions are called schema-qualified), else null. */
126
+ export async function trigramSchema(db: Queryable): Promise<string | null> {
127
+ return (await db.query(`select n.nspname as s from pg_catalog.pg_extension e join pg_catalog.pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_trgm'`)).rows[0]?.s ?? null;
128
+ }
129
+
130
+ /** (135) The markers ts_headline puts around a matched word (private-use characters, removed from the text first). */
131
+ export const HL_START = '\uE000';
132
+ export const HL_STOP = '\uE001';
133
+
134
+ /** (135) A ts_headline result as HTML: everything escaped, only the text between the markers in <mark>. */
135
+ export function headlined(value: string): Raw {
136
+ const parts: Raw[] = [];
137
+ let last = 0;
138
+ for (const m of value.matchAll(/\uE000([^\uE000\uE001]*)\uE001/g)) {
139
+ parts.push(html`${value.slice(last, m.index).replace(/[\uE000\uE001]/g, '')}${m[1] ? html`<mark>${m[1]}</mark>` : ''}`);
140
+ last = m.index! + m[0].length;
141
+ }
142
+ parts.push(html`${value.slice(last).replace(/[\uE000\uE001]/g, '')}`);
143
+ return html`${parts}`;
144
+ }
145
+
61
146
  /** The columns a configuration names (for the Advisor's check of its source). */
62
147
  export const configColumns = (c: Pick<SearchConfig, 'title_column' | 'subtitle_column' | 'description_column' | 'badge_column' | 'searchable_columns' | 'icon'>) =>
63
148
  [...new Set([c.title_column, c.subtitle_column, c.description_column, c.badge_column, ...(c.searchable_columns ?? []), /^#(.+)#$/.exec(c.icon ?? '')?.[1]]
@@ -87,6 +172,26 @@ export function searchConfigProblems(c: Partial<SearchConfig>): string[] {
87
172
  if (c.source_type === 'table' && !TABLE.test((c.source ?? '').trim())) out.push('The source of a table configuration is a table or view name, e.g. sales.customers.');
88
173
  if (c.icon && !/^(#[A-Za-z_][A-Za-z0-9_$]*#|[a-z0-9-]{1,40})$/.test(c.icon)) out.push('The icon is an icon name, or #COLUMN# for a column that holds icon names.');
89
174
  if (c.link && (typeof c.link !== 'object' || !Number.isInteger(c.link.page))) out.push('The link is {"page": 3, "items": {"P3_ID": "#ID#"}}.');
175
+ if (c.search_type && !SEARCH_TYPES.includes(c.search_type)) out.push(`The search type is one of ${SEARCH_TYPES.join(', ')}.`);
176
+ if (c.search_type === 'fulltext') {
177
+ if (c.source_type === 'list') out.push('Full-text search needs a table or SQL source.');
178
+ if (c.fts_config && !TS_CONFIG.test(c.fts_config)) out.push('The text search configuration is a name, e.g. english, dutch or simple.');
179
+ if (c.fts_vector && /;|--|\/\*/.test(c.fts_vector)) out.push('The tsvector is one column or expression (no ; or comments).');
180
+ }
181
+ if (c.search_type === 'vector' || c.search_type === 'hybrid') {
182
+ if (c.source_type === 'list') out.push('Vector search needs a table or SQL source.');
183
+ if (!c.vector_provider || !/^[A-Z][A-Z0-9_]{0,59}$/.test(c.vector_provider)) out.push('Choose the vector provider that embeds the search text.');
184
+ if (!c.vector_column || !COLUMN.test(c.vector_column)) out.push('Name the vector column of the source (e.g. embedding).');
185
+ if (c.distance !== undefined && c.distance !== null && !isDistance(c.distance)) out.push('The distance is cosine, l2 or inner_product.');
186
+ if (c.min_similarity !== undefined && c.min_similarity !== null && !(Number.isFinite(Number(c.min_similarity)) && Number(c.min_similarity) >= -1 && Number(c.min_similarity) <= 1))
187
+ out.push('The minimum similarity is a number from -1 to 1 (empty: none).');
188
+ }
189
+ if (c.search_type === 'hybrid') {
190
+ if (!c.key_column || !COLUMN.test(c.key_column)) out.push('A hybrid search needs the key column that matches full-text and vector results (e.g. id).');
191
+ if (c.fts_config && !TS_CONFIG.test(c.fts_config)) out.push('The text search configuration is a name, e.g. english, dutch or simple.');
192
+ if (c.fts_vector && /;|--|\/\*/.test(c.fts_vector)) out.push('The tsvector is one column or expression (no ; or comments).');
193
+ }
194
+ if (c.key_column && !COLUMN.test(c.key_column)) out.push(`"${c.key_column}" is not a column name.`);
90
195
  return out;
91
196
  }
92
197
 
@@ -97,7 +202,9 @@ async function configsOf(ctx: PageContext, r: Region): Promise<SearchConfig[]> {
97
202
  return (
98
203
  await runtime.query<SearchConfig>(
99
204
  `select name, label, seq, source_type, source, searchable_columns, title_column, subtitle_column, description_column,
100
- badge_column, icon, link, max_results
205
+ badge_column, icon, link, max_results,
206
+ search_type, fts_vector, fts_config, did_you_mean,
207
+ vector_provider, vector_column, key_column, distance, min_similarity
101
208
  from meta.search_config
102
209
  where app_id = $1 and meta.build_option_on(app_id, build_option) and (cardinality($2::text[]) = 0 or name = any($2))
103
210
  order by seq, name`,
@@ -113,6 +220,10 @@ interface Hit {
113
220
  badge: string | null;
114
221
  icon: string | null;
115
222
  href: Raw | null;
223
+ /** (135) full-text search: title, subtitle and description as highlighted by ts_headline */
224
+ headlines?: { title: Raw; subtitle: Raw | null; description: Raw | null };
225
+ /** (136) vector: the similarity; hybrid: the fused score */
226
+ score?: number;
116
227
  }
117
228
 
118
229
  const text = (v: unknown) => (v === null || v === undefined ? null : typeof v === 'object' ? JSON.stringify(v) : String(v));
@@ -124,21 +235,60 @@ function col(row: Record<string, unknown>, name: string | null) {
124
235
  return key === undefined ? null : text(row[key]);
125
236
  }
126
237
 
127
- /** One configuration's results on a table or a query: up to max_results, and whether there are more. */
128
- async function searchRows(ctx: PageContext, c: SearchConfig, words: string[]): Promise<{ hits: Hit[]; more: boolean }> {
129
- const searched = (c.searchable_columns.length ? c.searchable_columns : [c.title_column, c.subtitle_column, c.description_column]).filter(
130
- (x): x is string => !!x,
131
- );
132
- const conditions = words.map((_, i) => `(${searched.map((s) => `t.${ident(s)}::text ilike $${i + 1} escape '\\'`).join(' or ')})`);
133
- let from: string;
238
+ /** The columns a configuration searches: its searchable columns, else title, subtitle and description. */
239
+ const searchedColumns = (c: SearchConfig) =>
240
+ (c.searchable_columns.length ? c.searchable_columns : [c.title_column, c.subtitle_column, c.description_column]).filter((x): x is string => !!x);
241
+
242
+ /** The FROM of a table or SQL configuration (the source as `t`). */
243
+ async function sourceFrom(ctx: PageContext, c: SearchConfig) {
134
244
  if (c.source_type === 'table') {
135
245
  const table = (await ctx.client!.query('select $1::regclass::text as t', [c.source])).rows[0].t as string;
136
- from = `${table} t`;
137
- } else from = `(${stripSemicolon(applyBinds(c.source, bindValues(ctx)))}) t`;
138
- const sql = `select t.* from ${from} where ${conditions.join(' and ') || 'true'}${c.title_column ? ` order by t.${ident(c.title_column)}` : ''} limit ${c.max_results + 1}`;
139
- const res = await ctx.client!.query(sql, words.map(likePattern));
140
- const rows = res.rows as Record<string, unknown>[];
246
+ return `${table} t`;
247
+ }
248
+ return `(${stripSemicolon(applyBinds(c.source, bindValues(ctx)))}) t`;
249
+ }
250
+
251
+ /**
252
+ * (135) The tsvector of a full-text configuration as SQL over `t`: its column, its expression, or to_tsvector
253
+ * over the searchable columns (`cfg` is the placeholder of the text search configuration).
254
+ */
255
+ export function tsvectorSql(c: Pick<SearchConfig, 'fts_vector' | 'searchable_columns' | 'title_column' | 'subtitle_column' | 'description_column'>, cfg: string) {
256
+ const v = (c.fts_vector ?? '').trim();
257
+ if (v) return COLUMN.test(v) ? `t.${ident(v)}` : `(${v})`;
258
+ const cols = searchedColumns(c as SearchConfig);
259
+ return `to_tsvector(${cfg}, ${cols.map((x) => `coalesce(t.${ident(x)}::text, '')`).join(` || ' ' || `) || `''`})`;
260
+ }
261
+
262
+ /** One configuration's results on a table or a query: up to max_results, and whether there are more. */
263
+ async function searchRows(ctx: PageContext, c: SearchConfig, words: string[], query: string): Promise<{ hits: Hit[]; more: boolean }> {
264
+ const from = await sourceFrom(ctx, c);
265
+ let rows: Record<string, unknown>[];
266
+ const fulltext = c.search_type === 'fulltext';
267
+ if (c.search_type === 'vector' || c.search_type === 'hybrid') {
268
+ rows = await vectorRows(ctx, c, from, query);
269
+ } else if (fulltext) {
270
+ // $1 the text search configuration (a name from pg_ts_config), $2 the user's text, $3 the headline options,
271
+ // $4 the marker characters (taken out of the values first, so only ts_headline's markers become markup)
272
+ const cfg = await textSearchConfig(ctx.client!, c.fts_config, ctx.app.language);
273
+ const vec = tsvectorSql(c, '$1::regconfig');
274
+ const hl = (column: string | null, alias: string, options: string) =>
275
+ column ? `, ts_headline($1::regconfig, translate(s.${ident(column)}::text, $4, ''), "pgkiln$q".query, ${options}) as "pgkiln$${alias}"` : '';
276
+ const sql = `select s.*${hl(c.title_column, 'title', '$3::text || \', HighlightAll=true\'')}${hl(c.subtitle_column, 'subtitle', '$3::text || \', HighlightAll=true\'')}${hl(c.description_column, 'description', '$3::text || \', MaxWords=35, MinWords=15, MaxFragments=2, FragmentDelimiter=" … "\'')}
277
+ from (select t.*, ts_rank_cd(${vec}, "pgkiln$q".query) as "pgkiln$rank"
278
+ from ${from}, websearch_to_tsquery($1::regconfig, $2) "pgkiln$q"(query)
279
+ where ${vec} @@ "pgkiln$q".query
280
+ order by "pgkiln$rank" desc${c.title_column ? `, t.${ident(c.title_column)}` : ''}
281
+ limit ${c.max_results + 1}) s, websearch_to_tsquery($1::regconfig, $2) "pgkiln$q"(query)
282
+ order by s."pgkiln$rank" desc${c.title_column ? `, s.${ident(c.title_column)}` : ''}`;
283
+ rows = (await ctx.client!.query(sql, [cfg, query, `StartSel="${HL_START}", StopSel="${HL_STOP}"`, HL_START + HL_STOP])).rows;
284
+ } else {
285
+ const searched = searchedColumns(c);
286
+ const conditions = words.map((_, i) => `(${searched.map((s) => `t.${ident(s)}::text ilike $${i + 1} escape '\\'`).join(' or ')})`);
287
+ const sql = `select t.* from ${from} where ${conditions.join(' and ') || 'true'}${c.title_column ? ` order by t.${ident(c.title_column)}` : ''} limit ${c.max_results + 1}`;
288
+ rows = (await ctx.client!.query(sql, words.map(likePattern))).rows;
289
+ }
141
290
  const iconCol = /^#(.+)#$/.exec(c.icon ?? '')?.[1] ?? null;
291
+ const h = (row: Record<string, unknown>, alias: string) => (typeof row[`pgkiln$${alias}`] === 'string' ? headlined(row[`pgkiln$${alias}`] as string) : null);
142
292
  return {
143
293
  more: rows.length > c.max_results,
144
294
  hits: rows.slice(0, c.max_results).map((row) => ({
@@ -151,10 +301,98 @@ async function searchRows(ctx: PageContext, c: SearchConfig, words: string[]): P
151
301
  c.link && Number.isInteger(c.link.page)
152
302
  ? linkAttrs(ctx, c.link.page!, fillItems(c.link.items, (name) => col(row, name) ?? undefined))
153
303
  : null,
304
+ ...(fulltext ? { headlines: { title: h(row, 'title') ?? html`${col(row, c.title_column) ?? ''}`, subtitle: h(row, 'subtitle'), description: h(row, 'description') } } : {}),
305
+ ...(typeof row['pgkiln$score'] === 'number' ? { score: Math.round((row['pgkiln$score'] as number) * 10_000) / 10_000 } : {}),
154
306
  })),
155
307
  };
156
308
  }
157
309
 
310
+ /**
311
+ * (136) A vector or hybrid configuration's rows (up to max_results + 1), best first, each with "pgkiln$score"
312
+ * (vector: the similarity; hybrid: the fused score). Runs on ctx.client: the application's role, through RLS.
313
+ */
314
+ async function vectorRows(ctx: PageContext, c: SearchConfig, from: string, query: string): Promise<Record<string, unknown>[]> {
315
+ const client = ctx.client!;
316
+ const schema = await vectorSchema(client);
317
+ if (!schema) throw new Error(PGVECTOR_MISSING);
318
+ const provider = await vectorProviderFor(ctx.app.id, c.vector_provider!);
319
+ const vector = await embedQuery(provider, query, { appId: ctx.app.id, pageNo: ctx.page?.page_no ?? null, user: ctx.user, debug: (l, x) => dbg(ctx, l, 'search', x) }, client);
320
+ const distance: Distance = isDistance(c.distance) ? c.distance : 'cosine';
321
+ const vcol = `t.${ident(c.vector_column!)}`;
322
+ const d = distanceSql(schema, distance, vcol, `$1::text::${ident(schema)}.vector`);
323
+ const minSim = c.min_similarity === null || c.min_similarity === undefined ? null : Number(c.min_similarity);
324
+ const sim = DISTANCES[distance].similarity;
325
+ if (c.search_type === 'vector') {
326
+ // the nearest rows (an HNSW index serves "order by distance limit n"), then those similar enough
327
+ const sql = `select s.*, ${sim('s."pgkiln$distance"')}::float8 as "pgkiln$score"
328
+ from (select t.*, ${d} as "pgkiln$distance" from ${from} where ${vcol} is not null order by ${d} limit ${c.max_results + 1}) s
329
+ where $2::float8 is null or ${sim('s."pgkiln$distance"')} >= $2::float8
330
+ order by s."pgkiln$distance"`;
331
+ return (await client.query(sql, [vectorLiteral(vector), minSim])).rows;
332
+ }
333
+ // hybrid: each ranking's best HYBRID_DEPTH rows, fused by reciprocal rank (1 / (60 + rank)) per key
334
+ const cfg = await textSearchConfig(client, c.fts_config, ctx.app.language);
335
+ const vec = tsvectorSql(c, '$3::regconfig');
336
+ const key = ident(c.key_column!);
337
+ const sql = `with v as (select k, row_number() over (order by dist) as r
338
+ from (select t.${key} as k, ${d} as dist from ${from} where ${vcol} is not null order by ${d} limit ${HYBRID_DEPTH}) x
339
+ where $2::float8 is null or ${sim('dist')} >= $2::float8),
340
+ f as (select k, row_number() over (order by rank desc) as r
341
+ from (select t.${key} as k, ts_rank_cd(${vec}, q.query) as rank from ${from}, websearch_to_tsquery($3::regconfig, $4) q(query)
342
+ where ${vec} @@ q.query order by 2 desc limit ${HYBRID_DEPTH}) y),
343
+ s as (select k, sum(1.0 / (${RRF_K} + r))::float8 as score from (select k, r from v union all select k, r from f) z group by k)
344
+ select t.*, s.score as "pgkiln$score" from ${from} join s on s.k = t.${key}
345
+ order by s.score desc${c.title_column ? `, t.${ident(c.title_column)}` : ''} limit ${c.max_results + 1}`;
346
+ return (await client.query(sql, [vectorLiteral(vector), minSim, cfg, query])).rows;
347
+ }
348
+
349
+ /**
350
+ * (136) The Retrieve tool of an AI agent: a table or SQL configuration's best rows for a text, as the application's
351
+ * role (ctx.client, in the caller's read-only transaction): title, subtitle, description and badge, and the score.
352
+ */
353
+ export async function retrieve(ctx: PageContext, name: string, query: string, max: number) {
354
+ const c = (await runtime.query<SearchConfig>(
355
+ `select name, label, seq, source_type, source, searchable_columns, title_column, subtitle_column, description_column,
356
+ badge_column, icon, link, max_results, search_type, fts_vector, fts_config, did_you_mean,
357
+ vector_provider, vector_column, key_column, distance, min_similarity
358
+ from meta.search_config where app_id = $1 and name = $2 and meta.build_option_on(app_id, build_option)`, [ctx.app.id, name.toUpperCase()])).rows[0];
359
+ if (!c) throw new Error(`There is no search configuration ${name.toUpperCase()}.`);
360
+ if (c.source_type === 'list') throw new Error(`Search configuration ${c.name} searches a list: a Retrieve tool needs a table or SQL source.`);
361
+ const problems = searchConfigProblems(c);
362
+ if (problems.length) throw new Error(`Search configuration ${c.name}: ${problems.join(' ')}`);
363
+ const { hits, more } = await searchRows(ctx, { ...c, max_results: max }, searchWords(query), query.slice(0, MAX_TEXT).trim());
364
+ return {
365
+ rows: hits.map((h) => ({ title: h.title, ...(h.subtitle ? { subtitle: h.subtitle } : {}), ...(h.description ? { text: h.description } : {}), ...(h.badge ? { badge: h.badge } : {}), ...(h.score !== undefined ? { score: h.score } : {}) })),
366
+ ...(more ? { more_rows: true } : {}),
367
+ };
368
+ }
369
+
370
+ /**
371
+ * (135) "Did you mean": with pg_trgm installed, each search word that is not a word of the configuration's
372
+ * searched columns replaced by the most similar one (similarity 0.3 or more), from the first 10,000 rows of the
373
+ * source. Null when there is nothing better to suggest.
374
+ */
375
+ async function suggestion(ctx: PageContext, c: SearchConfig, words: string[], trgm: string): Promise<string | null> {
376
+ const cols = searchedColumns(c);
377
+ if (!cols.length || !words.length) return null;
378
+ const sim = `${ident(trgm)}.similarity`;
379
+ const res = await ctx.client!.query<{ word: string; best: string | null; known: boolean }>(
380
+ `with v as (select distinct w from (select concat_ws(' ', ${cols.map((x) => `t.${ident(x)}::text`).join(', ')}) as doc from ${await sourceFrom(ctx, c)} limit 10000) d,
381
+ regexp_split_to_table(lower(d.doc), '[^[:alnum:]]+') w where length(w) between 2 and 40)
382
+ select u.word, exists (select from v where v.w = lower(u.word)) as known,
383
+ (select v.w from v where ${sim}(v.w, lower(u.word)) >= 0.3 order by ${sim}(v.w, lower(u.word)) desc, v.w limit 1) as best
384
+ from unnest($1::text[]) with ordinality u(word, n) order by u.n`,
385
+ [words],
386
+ );
387
+ let changed = false;
388
+ const out = res.rows.map((r) => {
389
+ if (r.known || !r.best) return r.word;
390
+ changed = true;
391
+ return r.best;
392
+ });
393
+ return changed ? out.join(' ') : null;
394
+ }
395
+
158
396
  /** One configuration's results on a list: the entries (and their children) the user may see. */
159
397
  async function searchList(ctx: PageContext, c: SearchConfig, words: string[]): Promise<{ hits: Hit[]; more: boolean }> {
160
398
  const nodes = (await listTree(ctx, c.source)) ?? [];
@@ -204,19 +442,26 @@ async function searchResults(ctx: PageContext, r: Region, query: string): Promis
204
442
  const minLength = Number.isInteger(conf.min_length) && conf.min_length! > 0 ? conf.min_length! : 1;
205
443
  if (!words.length || query.trim().length < minLength) return html``;
206
444
  const groups: Raw[] = [];
207
- for (const c of await configsOf(ctx, r)) {
445
+ const configs = await configsOf(ctx, r);
446
+ for (const c of configs) {
208
447
  let body: Raw;
209
448
  try {
210
449
  const problems = searchConfigProblems(c);
211
450
  if (problems.length) throw new Error(`Search configuration ${c.name}: ${problems.join(' ')}`);
212
- const { hits, more } = await savepoint(ctx.client!, () => (c.source_type === 'list' ? searchList(ctx, c, words) : searchRows(ctx, c, words)));
451
+ const { hits, more } = await savepoint(ctx.client!, () => (c.source_type === 'list' ? searchList(ctx, c, words) : searchRows(ctx, c, words, query.trim())));
213
452
  if (!hits.length) continue;
214
453
  body = html`<ul class="search-hits">${hits.map((h) => {
454
+ // (135) full-text results: ts_headline's highlights (stemmed words: "running" marks "run"); else the words
455
+ // (136) results by meaning: no word is marked (they need not contain the words)
456
+ const by = c.search_type === 'vector' || c.search_type === 'hybrid' ? [] : words;
457
+ const title = h.headlines?.title ?? marked(h.title, by);
458
+ const subtitle = h.subtitle ? (h.headlines?.subtitle ?? marked(h.subtitle, by)) : null;
459
+ const description = h.description ? (h.headlines?.description ?? marked(h.description, by)) : null;
215
460
  const inner = html`${h.icon ? html`<span class="search-icon">${icon(h.icon)}</span>` : ''}
216
- <span class="search-text"><span class="search-title">${marked(h.title, words)}</span>${
217
- h.subtitle ? html`<span class="search-subtitle">${marked(h.subtitle, words)}</span>` : ''}${
218
- h.description ? html`<span class="search-description">${marked(h.description, words)}</span>` : ''}</span>
219
- ${h.badge ? html`<span class="badge-pill">${marked(h.badge, words)}</span>` : ''}`;
461
+ <span class="search-text"><span class="search-title">${title}</span>${
462
+ subtitle ? html`<span class="search-subtitle">${subtitle}</span>` : ''}${
463
+ description ? html`<span class="search-description">${description}</span>` : ''}</span>
464
+ ${h.badge ? html`<span class="badge-pill">${marked(h.badge, by)}</span>` : ''}`;
220
465
  return html`<li>${h.href ? html`<a class="search-hit" ${h.href}>${inner}</a>` : html`<div class="search-hit">${inner}</div>`}</li>`;
221
466
  })}</ul>${more ? html`<p class="muted search-more">${ctx.locale.t('region.first_rows', { n: String(c.max_results) })}</p>` : ''}`;
222
467
  } catch (e) {
@@ -226,7 +471,29 @@ async function searchResults(ctx: PageContext, r: Region, query: string): Promis
226
471
  groups.push(html`<section class="search-group"><h3>${ctx.locale.tr(c.label)}</h3>${body}</section>`);
227
472
  }
228
473
  // (a configuration that failed shows its error as a group of its own)
229
- return groups.length ? html`${groups}` : html`<p class="muted search-empty">${conf.empty ?? ctx.locale.t('lov.no_rows')}</p>`;
474
+ if (groups.length) return html`${groups}`;
475
+ return html`<p class="muted search-empty">${conf.empty ?? ctx.locale.t('lov.no_rows')}</p>${await didYouMean(ctx, r, configs, words)}`;
476
+ }
477
+
478
+ /** (135) Nothing found: the first suggestion of a configuration that asks for one (pg_trgm installed), as a link. */
479
+ async function didYouMean(ctx: PageContext, r: Region, configs: SearchConfig[], words: string[]): Promise<Raw | ''> {
480
+ const asking = configs.filter((c) => c.did_you_mean && c.source_type !== 'list' && !searchConfigProblems(c).length);
481
+ if (!asking.length) return '';
482
+ const trgm = await trigramSchema(ctx.client!);
483
+ if (!trgm) return '';
484
+ for (const c of asking) {
485
+ // a configuration that fails here suggests nothing (its error was shown, or there was none to show)
486
+ const s = await savepoint(ctx.client!, () => suggestion(ctx, c, words, trgm)).catch(() => null);
487
+ if (!s) continue;
488
+ const conf = (r.config ?? {}) as SearchRegionConfig;
489
+ const label = ctx.locale.t('search.did_you_mean');
490
+ // the region's own field: a link that searches for the suggestion; a page item: the words only
491
+ if (typeof conf.item === 'string' && conf.item) return html`<p class="muted search-suggestion">${label} <b>${s}</b></p>`;
492
+ const params = new URLSearchParams([...ctx.params].filter(([k]) => k !== searchParam(r)));
493
+ params.set(searchParam(r), s);
494
+ return html`<p class="muted search-suggestion">${label} <a href="${ctx.base}/${ctx.page.page_no}?${params}">${s}</a></p>`;
495
+ }
496
+ return '';
230
497
  }
231
498
 
232
499
  export async function renderSearch(ctx: PageContext, r: Region): Promise<Raw> {
package/src/server.ts CHANGED
@@ -5,6 +5,7 @@ import { startWorkflowRunner } from './workflow.ts';
5
5
  import { startProcessJobRunner } from './process-jobs.ts';
6
6
  import { startPushListener } from './push.ts';
7
7
  import { startMailListener } from './mail/mail.ts';
8
+ import { startEmbeddingRunner } from './vector.ts';
8
9
 
9
10
  const app = await buildApp();
10
11
  await app.listen({ port: Number(process.env.PORT ?? 3100), host: process.env.HOST ?? '127.0.0.1' });
@@ -14,6 +15,8 @@ await startWorkflowRunner();
14
15
  await startProcessJobRunner();
15
16
  await startPushListener();
16
17
  await startMailListener();
18
+ // (sprint 136) rows queued for their embeddings (EMBEDDINGS=off on servers that should not embed)
19
+ startEmbeddingRunner();
17
20
  // (sprint 133, SEC-6) say at start-up what could lock people out: a changed instance key or address, the clock
18
21
  try {
19
22
  const { instanceChecks } = await import('./instance-checks.ts');