pgkiln 0.69.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 (40) hide show
  1. package/db/migrations/136_vector_search.sql +252 -0
  2. package/docs/apex-feature-parity.md +5 -4
  3. package/docs/guide/03-builder.md +69 -0
  4. package/docs/guide/04-pages-and-regions.md +42 -2
  5. package/docs/guide/09-reference.md +19 -0
  6. package/docs/guide/12-development.md +31 -2
  7. package/docs/guide/15-extensions.md +1 -1
  8. package/examples/hr/README.md +1 -0
  9. package/examples/hr/hr_72_vector_search.sql +158 -0
  10. package/package.json +1 -1
  11. package/public/builder.css +3 -0
  12. package/src/ai/embeddings.ts +197 -0
  13. package/src/ai/service.ts +13 -9
  14. package/src/app.ts +3 -0
  15. package/src/builder/advisor.ts +41 -1
  16. package/src/builder/ai-agents.ts +13 -5
  17. package/src/builder/components.ts +21 -3
  18. package/src/builder/home.ts +1 -0
  19. package/src/builder/i18n/de.ts +34 -0
  20. package/src/builder/i18n/en.ts +34 -0
  21. package/src/builder/i18n/es.ts +34 -0
  22. package/src/builder/i18n/fr.ts +34 -0
  23. package/src/builder/i18n/it.ts +34 -0
  24. package/src/builder/i18n/ja.ts +34 -0
  25. package/src/builder/i18n/ko.ts +34 -0
  26. package/src/builder/i18n/nl.ts +34 -0
  27. package/src/builder/i18n/pt.ts +34 -0
  28. package/src/builder/i18n/zh-cn.ts +34 -0
  29. package/src/builder/i18n/zh-tw.ts +34 -0
  30. package/src/builder/object-actions.ts +1 -0
  31. package/src/builder/shared.ts +1 -1
  32. package/src/builder/sql.ts +3 -0
  33. package/src/builder/table-editor.ts +1 -0
  34. package/src/builder/vector-column.ts +229 -0
  35. package/src/builder/vector-providers.ts +286 -0
  36. package/src/cli/replace.ts +3 -1
  37. package/src/runtime/ai-agents.ts +53 -2
  38. package/src/runtime/search.ts +116 -12
  39. package/src/server.ts +3 -0
  40. package/src/vector.ts +288 -0
@@ -84,7 +84,9 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
84
84
  // (125) who changed the application last in the builder (App Builder list: Updated by)
85
85
  'app_change',
86
86
  // (126) outgoing calls the network access lists refused (Administration → Network access; a log, 7 days)
87
- 'network_refusal'];
87
+ 'network_refusal',
88
+ // (136) rows queued for their embeddings (the vector providers travel in meta.app)
89
+ 'embedding_queue'];
88
90
  /** Children of pages (replaced with their page). */
89
91
  const PAGE_CHILDREN = ['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'];
90
92
  /** References into replaced tables from kept data, repointed below: "table.column". */
@@ -7,6 +7,7 @@ import { checkArgs, toolSchema, type ParamDef, type ToolDef } from './assistant.
7
7
  import { isAuthorized, pageAllowed } from './authz.ts';
8
8
  import { bindValues, dbg, publicError, stripSemicolon, type PageContext } from './context.ts';
9
9
  import { txContext } from './routes.ts';
10
+ import { retrieve } from './search.ts';
10
11
 
11
12
  // AI agents and AI tools (migration 121; APEX 26.1 / 26.2: Shared Components
12
13
  // → AI Agents). Both are lists in meta.app (ai_agents, ai_tools), exported
@@ -31,11 +32,18 @@ import { txContext } from './routes.ts';
31
32
  // values of that page; pgkiln refuses a page the user may not open and
32
33
  // builds the URL itself (item values with their checksum, bound to app,
33
34
  // page and user). The link is shown under the answer; nothing opens it.
35
+ // - retrieve (136): RAG. The model gives a text (QUERY); pgkiln runs the
36
+ // tool's search configuration (vector, hybrid, full-text or standard) as
37
+ // the application's role in a read-only transaction, so row level
38
+ // security decides which rows the model sees, and returns the best rows'
39
+ // title, text and score (max_rows, default 5).
34
40
  // Every tool call is logged with its arguments (meta.ai_tool_call, the
35
41
  // Activity → AI usage page) and in the page's debug messages.
36
42
 
37
- export type AiToolType = 'sql' | 'function' | 'rest' | 'javascript' | 'navigate';
38
- export const AI_TOOL_TYPES: AiToolType[] = ['sql', 'function', 'rest', 'javascript', 'navigate'];
43
+ export type AiToolType = 'sql' | 'function' | 'rest' | 'javascript' | 'navigate' | 'retrieve';
44
+ export const AI_TOOL_TYPES: AiToolType[] = ['sql', 'function', 'rest', 'javascript', 'navigate', 'retrieve'];
45
+ /** (136) Rows a Retrieve tool returns by default. */
46
+ const RETRIEVE_ROWS = 5;
39
47
  export const EFFORTS = ['low', 'medium', 'high'] as const;
40
48
 
41
49
  export interface AiTool {
@@ -47,6 +55,8 @@ export interface AiTool {
47
55
  source?: string;
48
56
  action?: string;
49
57
  pages?: number[];
58
+ /** (136) retrieve: the search configuration (static ID) */
59
+ search_config?: string;
50
60
  parameters?: Record<string, ParamDef>;
51
61
  max_rows?: number;
52
62
  timeout_s?: number;
@@ -116,6 +126,9 @@ export function aiToolProblems(t: unknown): string[] {
116
126
  if (!Array.isArray(t.pages) || !t.pages.length || t.pages.length > 50 || t.pages.some((p: unknown) => !intIn(p, 1, 2_147_483_647)))
117
127
  out.push(`${what}: "pages" lists the page numbers the agent may offer (1 to 50 pages).`);
118
128
  if (t.parameters !== undefined) out.push(`${what}: a navigate tool has no parameters (the model picks a page, a link text and item values).`);
129
+ } else if (t.type === 'retrieve') {
130
+ if (typeof t.search_config !== 'string' || !/^[A-Z][A-Z0-9_]{0,59}$/.test(t.search_config)) out.push(`${what}: "search_config" names a search configuration (e.g. POLICIES).`);
131
+ if (t.parameters !== undefined) out.push(`${what}: a retrieve tool has no parameters (the model gives the text to look for).`);
119
132
  } else out.push(...paramProblems(what, t.parameters));
120
133
  if (t.max_rows !== undefined && !intIn(t.max_rows, 1, 500)) out.push(`${what}: maximum rows 1 to 500.`);
121
134
  if (t.timeout_s !== undefined && !intIn(t.timeout_s, 1, 60)) out.push(`${what}: time limit 1 to 60 seconds.`);
@@ -201,6 +214,14 @@ export interface PreparedTool {
201
214
 
202
215
  const asToolDef = (t: AiTool): ToolDef => ({ name: t.name, description: t.description, parameters: t.parameters, max_rows: t.max_rows });
203
216
 
217
+ /** (136) The one parameter of a Retrieve tool. */
218
+ const RETRIEVE_SCHEMA = {
219
+ type: 'object',
220
+ properties: { QUERY: { type: 'string', description: 'What to look for, in your own words (a question or a few words); the search finds rows by meaning.' } },
221
+ required: ['QUERY'],
222
+ additionalProperties: false,
223
+ };
224
+
204
225
  /**
205
226
  * The agent's tools this user may use (their authorization schemes), with their schemas. Navigate tools keep
206
227
  * only the pages the user may open (checked here, in the caller's transaction). Needs ctx.client.
@@ -209,6 +230,10 @@ export async function prepareTools(ctx: PageContext, tools: AiTool[]): Promise<P
209
230
  const out: PreparedTool[] = [];
210
231
  for (const tool of tools) {
211
232
  if (!(await isAuthorized(ctx, tool.authz))) continue;
233
+ if (tool.type === 'retrieve') {
234
+ out.push({ tool, chat: { name: tool.name, description: `${tool.description} Returns the best matching rows (title, text, score) the user may read.`, parameters: RETRIEVE_SCHEMA } });
235
+ continue;
236
+ }
212
237
  if (tool.type !== 'navigate') {
213
238
  const extra = tool.type === 'javascript' ? ' It runs in the user\'s browser after your answer; it returns no data.' : '';
214
239
  out.push({ tool, chat: { name: tool.name, description: tool.description + extra, parameters: toolSchema(asToolDef(tool)) } });
@@ -309,6 +334,7 @@ export async function runAgentTool(ctx: PageContext, p: PreparedTool, input: Rec
309
334
  const { tool } = p;
310
335
  if (tool.type === 'navigate') return navigate(ctx, p, input, extras);
311
336
  if (tool.type === 'rest') return rest({ ...asToolDef(tool), type: 'rest', source: tool.source }, input);
337
+ if (tool.type === 'retrieve') return retrieveTool(ctx, tool, input);
312
338
  const checked = checkArgs(asToolDef(tool), input);
313
339
  if ('error' in checked) return { content: checked.error, isError: true };
314
340
  if (tool.type === 'javascript') {
@@ -336,6 +362,31 @@ export async function runAgentTool(ctx: PageContext, p: PreparedTool, input: Rec
336
362
  }
337
363
  }
338
364
 
365
+ /**
366
+ * (136) A Retrieve tool: the search configuration as the application's role in a read-only transaction with the
367
+ * tool's time limit (row level security applies), its best rows as JSON for the model.
368
+ */
369
+ async function retrieveTool(ctx: PageContext, tool: AiTool, input: Record<string, unknown>): Promise<AgentOutcome> {
370
+ for (const k of Object.keys(input)) if (k !== 'QUERY') return { content: `Unknown argument "${k}".`, isError: true };
371
+ const query = typeof input.QUERY === 'string' ? input.QUERY.replace(/\s+/g, ' ').trim().slice(0, 1000) : '';
372
+ if (!query) return { content: 'QUERY is the text to look for.', isError: true };
373
+ const max = tool.max_rows ?? RETRIEVE_ROWS;
374
+ const before = ctx.client;
375
+ try {
376
+ const found = await appTx(txContext(ctx), async (c) => {
377
+ await c.query(`select set_config('statement_timeout', $1, true)`, [`${tool.timeout_s ?? DEFAULT_TIMEOUT_S}s`]);
378
+ await c.query('set transaction read only');
379
+ ctx.client = c;
380
+ return retrieve(ctx, tool.search_config!, query, max);
381
+ });
382
+ return { content: JSON.stringify(found) };
383
+ } catch (e) {
384
+ return { content: await publicError(ctx, e, `AI tool ${tool.name}`), isError: true };
385
+ } finally {
386
+ ctx.client = before;
387
+ }
388
+ }
389
+
339
390
  // ---------------------------------------------------------------- the log
340
391
 
341
392
  const MAX_ARG_CHARS = 4000;
@@ -18,7 +18,14 @@
18
18
  // (between private-use markers that only then become <mark>, everything
19
19
  // else escaped); the text search configuration is checked against
20
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. The
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
22
29
  // queries run as the application's role in the page's transaction, each in a
23
30
  // savepoint, so one configuration that fails shows its error and the others
24
31
  // still answer. Without JavaScript the field is a GET form; app.js searches
@@ -29,7 +36,9 @@ import { runtime, savepoint } from '../db.ts';
29
36
  import { html, raw, type Raw } from '../html.ts';
30
37
  import { icon } from '../icons.ts';
31
38
  import type { Region } from '../metadata.ts';
32
- 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';
33
42
  import { fillItems, linkAttrs } from './links.ts';
34
43
  import { listTree, type ListNode } from './lists.ts';
35
44
 
@@ -49,14 +58,24 @@ export interface SearchConfig {
49
58
  icon: string | null;
50
59
  link: { page?: number; items?: Record<string, string> } | null;
51
60
  max_results: number;
52
- /** (135) standard: ILIKE per word · fulltext: PostgreSQL full-text search (later: vector, hybrid) */
53
- search_type?: 'standard' | 'fulltext';
61
+ /** (135) standard: ILIKE per word · fulltext: PostgreSQL full-text search · (136) vector, hybrid */
62
+ search_type?: (typeof SEARCH_TYPES)[number];
54
63
  /** (135) fulltext: a tsvector column or expression; empty: to_tsvector over the searchable columns */
55
64
  fts_vector?: string | null;
56
65
  /** (135) fulltext: the text search configuration; empty: the application language's */
57
66
  fts_config?: string | null;
58
67
  /** (135) with pg_trgm: suggest the closest words when nothing is found */
59
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;
60
79
  }
61
80
 
62
81
  export interface SearchRegionConfig {
@@ -76,8 +95,11 @@ const TABLE = /^([A-Za-z_][A-Za-z0-9_$]{0,62}\.)?[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
76
95
  /** A client, a pool or pgkiln's Db: something that runs a query with parameters. */
77
96
  type Queryable = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> };
78
97
 
79
- /** (135) The search types (later: vector, hybrid). */
80
- export const SEARCH_TYPES = ['standard', 'fulltext'] as const;
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;
81
103
  const TS_CONFIG = /^[a-z_][a-z0-9_]{0,62}$/;
82
104
 
83
105
  /** (135) The text search configuration of a language: pgkiln's languages that PostgreSQL has a stemmer for. */
@@ -156,6 +178,20 @@ export function searchConfigProblems(c: Partial<SearchConfig>): string[] {
156
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.');
157
179
  if (c.fts_vector && /;|--|\/\*/.test(c.fts_vector)) out.push('The tsvector is one column or expression (no ; or comments).');
158
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.`);
159
195
  return out;
160
196
  }
161
197
 
@@ -167,7 +203,8 @@ async function configsOf(ctx: PageContext, r: Region): Promise<SearchConfig[]> {
167
203
  await runtime.query<SearchConfig>(
168
204
  `select name, label, seq, source_type, source, searchable_columns, title_column, subtitle_column, description_column,
169
205
  badge_column, icon, link, max_results,
170
- search_type, fts_vector, fts_config, did_you_mean
206
+ search_type, fts_vector, fts_config, did_you_mean,
207
+ vector_provider, vector_column, key_column, distance, min_similarity
171
208
  from meta.search_config
172
209
  where app_id = $1 and meta.build_option_on(app_id, build_option) and (cardinality($2::text[]) = 0 or name = any($2))
173
210
  order by seq, name`,
@@ -185,6 +222,8 @@ interface Hit {
185
222
  href: Raw | null;
186
223
  /** (135) full-text search: title, subtitle and description as highlighted by ts_headline */
187
224
  headlines?: { title: Raw; subtitle: Raw | null; description: Raw | null };
225
+ /** (136) vector: the similarity; hybrid: the fused score */
226
+ score?: number;
188
227
  }
189
228
 
190
229
  const text = (v: unknown) => (v === null || v === undefined ? null : typeof v === 'object' ? JSON.stringify(v) : String(v));
@@ -225,7 +264,9 @@ async function searchRows(ctx: PageContext, c: SearchConfig, words: string[], qu
225
264
  const from = await sourceFrom(ctx, c);
226
265
  let rows: Record<string, unknown>[];
227
266
  const fulltext = c.search_type === 'fulltext';
228
- if (fulltext) {
267
+ if (c.search_type === 'vector' || c.search_type === 'hybrid') {
268
+ rows = await vectorRows(ctx, c, from, query);
269
+ } else if (fulltext) {
229
270
  // $1 the text search configuration (a name from pg_ts_config), $2 the user's text, $3 the headline options,
230
271
  // $4 the marker characters (taken out of the values first, so only ts_headline's markers become markup)
231
272
  const cfg = await textSearchConfig(ctx.client!, c.fts_config, ctx.app.language);
@@ -261,10 +302,71 @@ async function searchRows(ctx: PageContext, c: SearchConfig, words: string[], qu
261
302
  ? linkAttrs(ctx, c.link.page!, fillItems(c.link.items, (name) => col(row, name) ?? undefined))
262
303
  : null,
263
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 } : {}),
264
306
  })),
265
307
  };
266
308
  }
267
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
+
268
370
  /**
269
371
  * (135) "Did you mean": with pg_trgm installed, each search word that is not a word of the configuration's
270
372
  * searched columns replaced by the most similar one (similarity 0.3 or more), from the first 10,000 rows of the
@@ -350,14 +452,16 @@ async function searchResults(ctx: PageContext, r: Region, query: string): Promis
350
452
  if (!hits.length) continue;
351
453
  body = html`<ul class="search-hits">${hits.map((h) => {
352
454
  // (135) full-text results: ts_headline's highlights (stemmed words: "running" marks "run"); else the words
353
- const title = h.headlines?.title ?? marked(h.title, words);
354
- const subtitle = h.subtitle ? (h.headlines?.subtitle ?? marked(h.subtitle, words)) : null;
355
- const description = h.description ? (h.headlines?.description ?? marked(h.description, words)) : null;
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;
356
460
  const inner = html`${h.icon ? html`<span class="search-icon">${icon(h.icon)}</span>` : ''}
357
461
  <span class="search-text"><span class="search-title">${title}</span>${
358
462
  subtitle ? html`<span class="search-subtitle">${subtitle}</span>` : ''}${
359
463
  description ? html`<span class="search-description">${description}</span>` : ''}</span>
360
- ${h.badge ? html`<span class="badge-pill">${marked(h.badge, words)}</span>` : ''}`;
464
+ ${h.badge ? html`<span class="badge-pill">${marked(h.badge, by)}</span>` : ''}`;
361
465
  return html`<li>${h.href ? html`<a class="search-hit" ${h.href}>${inner}</a>` : html`<div class="search-hit">${inner}</div>`}</li>`;
362
466
  })}</ul>${more ? html`<p class="muted search-more">${ctx.locale.t('region.first_rows', { n: String(c.max_results) })}</p>` : ''}`;
363
467
  } catch (e) {
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');
package/src/vector.ts ADDED
@@ -0,0 +1,288 @@
1
+ import pg from 'pg';
2
+ import { embedTexts, loadVectorProviders, vectorLiteral, vectorProviderProblems, DEFAULT_BATCH, DEFAULT_PER_MINUTE, type VectorProvider } from './ai/embeddings.ts';
3
+ import { isAiError } from './ai/types.ts';
4
+ import { owner } from './db.ts';
5
+ import { devDbForApp } from './workspace-db.ts';
6
+
7
+ // (sprint 136, AP-1) pgvector, and keeping vector columns current.
8
+ //
9
+ // pgvector is an extension that may be missing: stock postgres images don't have it. pgvectorStatus() says what
10
+ // there is (as the SQL Workshop does for pg_cron), vectorType() the schema-qualified type name to use; nothing in
11
+ // meta needs the extension.
12
+ //
13
+ // The embedding queue (migration 136): a vector column kept by a queue has an AFTER INSERT OR UPDATE OF … trigger
14
+ // (meta.embedding_enqueue) that puts the row's key in meta.embedding_queue; Backfill queues the rows without an
15
+ // embedding. embeddingTick() takes queued rows of one table and provider (FOR UPDATE SKIP LOCKED, so several
16
+ // servers share the queue), reads their source columns, embeds them in batches of the provider's batch_size
17
+ // within its requests per minute (and the application's daily AI limits), and writes the vectors. It reads and
18
+ // writes the table with the developer connection of the application's workspace (the owner, or an isolated
19
+ // workspace's developer login), as the SQL Workshop that created the column does. A failed batch is retried later
20
+ // (1, 2, 4 … minutes; at most 6 attempts), its message kept for the builder.
21
+
22
+ type Queryable = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> };
23
+
24
+ export interface PgvectorStatus {
25
+ /** the extension is created in this database */
26
+ installed: boolean;
27
+ version: string | null;
28
+ schema: string | null;
29
+ /** the server has the package (pg_available_extensions) */
30
+ available: boolean;
31
+ availableVersion: string | null;
32
+ /** the connection may create it (a superuser; pgvector is not a trusted extension) */
33
+ canCreate: boolean;
34
+ }
35
+
36
+ export async function pgvectorStatus(db: Queryable): Promise<PgvectorStatus> {
37
+ const r = (await db.query(
38
+ `select e.extversion as version, n.nspname as schema, a.default_version as available_version,
39
+ (select rolsuper from pg_catalog.pg_roles where rolname = current_user) as superuser
40
+ from (select 1) x
41
+ left join pg_catalog.pg_extension e on e.extname = 'vector'
42
+ left join pg_catalog.pg_namespace n on n.oid = e.extnamespace
43
+ left join pg_catalog.pg_available_extensions a on a.name = 'vector'`,
44
+ )).rows[0];
45
+ return {
46
+ installed: !!r.version, version: r.version ?? null, schema: r.schema ?? null,
47
+ available: !!r.available_version, availableVersion: r.available_version ?? null, canCreate: !!r.superuser,
48
+ };
49
+ }
50
+
51
+ /** The schema of pgvector when it is installed (its type and operators are used schema-qualified), else null. */
52
+ export async function vectorSchema(db: Queryable): Promise<string | null> {
53
+ 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 = 'vector'`)).rows[0]?.s ?? null;
54
+ }
55
+
56
+ /** What to say when pgvector is missing (builder and runtime messages). */
57
+ export const PGVECTOR_MISSING = 'The pgvector extension is not installed in this database: an administrator installs the package on the database server (e.g. apt install postgresql-17-pgvector, or the pgvector/pgvector Docker image) and creates it (Workspace utilities → Vector providers, or create extension vector as a superuser).';
58
+
59
+ /** The distances of pgvector: operator, HNSW operator class, and the similarity (higher is closer) as SQL of `d`. */
60
+ export const DISTANCES = {
61
+ cosine: { op: '<=>', opclass: 'vector_cosine_ops', similarity: (d: string) => `(1 - ${d})` },
62
+ l2: { op: '<->', opclass: 'vector_l2_ops', similarity: (d: string) => `(1 / (1 + ${d}))` },
63
+ inner_product: { op: '<#>', opclass: 'vector_ip_ops', similarity: (d: string) => `(-${d})` },
64
+ } as const;
65
+ export type Distance = keyof typeof DISTANCES;
66
+ export const isDistance = (d: unknown): d is Distance => typeof d === 'string' && Object.hasOwn(DISTANCES, d);
67
+
68
+ /** `a <=> b` with pgvector's operator schema-qualified (an application role's search_path may not have it). */
69
+ export const distanceSql = (schema: string, d: Distance, a: string, b: string) => `${a} OPERATOR(${pg.escapeIdentifier(schema)}.${DISTANCES[d].op}) ${b}`;
70
+
71
+ /** The dimensions of a vector column (its typmod), or null when it is no vector(n) column. */
72
+ export async function vectorColumnDims(db: Queryable, table: string, column: string): Promise<number | null | undefined> {
73
+ const r = (await db.query(
74
+ `select t.typname = 'vector' as is_vector, a.atttypmod as dims
75
+ from pg_catalog.pg_attribute a join pg_catalog.pg_type t on t.oid = a.atttypid
76
+ where a.attrelid = $1::regclass and a.attname = $2 and a.attnum > 0 and not a.attisdropped`, [table, column])).rows[0];
77
+ if (!r) return undefined;
78
+ return r.is_vector && r.dims > 0 ? r.dims : null;
79
+ }
80
+
81
+ // ---------------------------------------------------------------- the queue
82
+
83
+ const COLUMN = /^[a-z_][a-z0-9_$]{0,62}$/;
84
+ const MAX_ATTEMPTS = 6;
85
+ /** Requests per provider in the last minute (this process). */
86
+ const sent = new Map<string, number[]>();
87
+
88
+ function mayRequest(key: string, perMinute: number) {
89
+ const now = Date.now();
90
+ const times = (sent.get(key) ?? []).filter((t) => now - t < 60_000);
91
+ sent.set(key, times);
92
+ if (times.length >= perMinute) return false;
93
+ times.push(now);
94
+ return true;
95
+ }
96
+
97
+ interface Group { app_id: number; provider: string; tbl: number; vector_column: string; key_column: string; source_columns: string[] }
98
+
99
+ /** Embed one batch of a group; returns the number of rows written (0: nothing to do or wait). */
100
+ async function runBatch(g: Group, p: VectorProvider): Promise<number> {
101
+ const batch = p.type === 'ai_service' ? (p.batch_size ?? DEFAULT_BATCH) : 100;
102
+ if (p.type === 'ai_service' && !mayRequest(`${g.app_id}:${p.name}`, p.per_minute ?? DEFAULT_PER_MINUTE)) return 0;
103
+ return owner.tx(async (q) => {
104
+ const rows = (await q.query<{ id: string; key_value: string; queued_at: string }>(
105
+ `select id, key_value, queued_at::text from meta.embedding_queue
106
+ where app_id = $1 and provider = $2 and tbl = $3 and vector_column = $4 and key_column = $5 and source_columns = $6 and next_at <= now()
107
+ order by id limit $7 for update skip locked`,
108
+ [g.app_id, g.provider, g.tbl, g.vector_column, g.key_column, g.source_columns, batch])).rows;
109
+ if (!rows.length) return 0;
110
+ const ids = rows.map((r) => r.id);
111
+ const fail = async (message: string) => {
112
+ await q.query(
113
+ `update meta.embedding_queue set attempts = attempts + 1, last_error = left($2, 500),
114
+ next_at = now() + make_interval(mins => power(2, least(attempts, 6))::int)
115
+ where id = any($1::bigint[])`, [ids, message]);
116
+ return 0;
117
+ };
118
+ const db = await devDbForApp(g.app_id);
119
+ const table = (await db.query<{ t: string | null }>('select $1::oid::regclass::text as t from pg_catalog.pg_class where oid = $1', [g.tbl])).rows[0]?.t;
120
+ if (!table) {
121
+ // the table is gone
122
+ await q.query('delete from meta.embedding_queue where id = any($1::bigint[])', [ids]);
123
+ return 0;
124
+ }
125
+ if (![g.vector_column, g.key_column, ...g.source_columns].every((c) => COLUMN.test(c))) return fail('The trigger names a column that is not a plain name.');
126
+ const id = pg.escapeIdentifier;
127
+ const text = `meta.embedding_text(to_jsonb(t), $2::text[])`;
128
+ try {
129
+ let written = 0;
130
+ if (p.type === 'function') {
131
+ // a function provider: embedded in the database, as the developer connection (the trigger's own function)
132
+ await db.query(
133
+ `update ${table} t set ${id(g.vector_column)} = translate(meta.embed(${text}, $3, $4)::text, '{}', '[]')::text::${await vectorTypeOf(db)}
134
+ where t.${id(g.key_column)}::text = any($1::text[])`, [rows.map((r) => r.key_value), g.source_columns, p.name, g.app_id]);
135
+ written = rows.length;
136
+ } else {
137
+ const found = (await db.query<{ k: string; t: string | null }>(
138
+ `select t.${id(g.key_column)}::text as k, ${text} as t from ${table} t where t.${id(g.key_column)}::text = any($1::text[])`,
139
+ [rows.map((r) => r.key_value), g.source_columns])).rows;
140
+ const withText = found.filter((r) => r.t);
141
+ const vectors = withText.length ? await embedTexts(p, withText.map((r) => r.t!), { appId: g.app_id, user: null, pageNo: null }) : [];
142
+ const type = await vectorTypeOf(db);
143
+ // rows without text get no embedding; rows deleted meanwhile are simply gone
144
+ await db.query(
145
+ `update ${table} t set ${id(g.vector_column)} = x.v::${type}
146
+ from unnest($1::text[], $2::text[]) x(k, v) where t.${id(g.key_column)}::text = x.k`,
147
+ [[...withText.map((r) => r.k), ...found.filter((r) => !r.t).map((r) => r.k)], [...vectors.map(vectorLiteral), ...found.filter((r) => !r.t).map(() => null)]]);
148
+ written = found.length;
149
+ }
150
+ // done, unless the row was queued again meanwhile (the trigger moved queued_at)
151
+ await q.query(
152
+ `delete from meta.embedding_queue e using unnest($1::bigint[], $2::text[]) x(id, at) where e.id = x.id and e.queued_at::text = x.at`,
153
+ [ids, rows.map((r) => r.queued_at)]);
154
+ return Math.max(written, 1);
155
+ } catch (e) {
156
+ const message = isAiError(e) ? e.message : (e as Error).message;
157
+ return fail(message);
158
+ }
159
+ });
160
+ }
161
+
162
+ /** pgvector's type, schema-qualified, for casts. */
163
+ async function vectorTypeOf(db: Queryable) {
164
+ const s = await vectorSchema(db);
165
+ if (!s) throw new Error(PGVECTOR_MISSING);
166
+ return `${pg.escapeIdentifier(s)}.vector`;
167
+ }
168
+
169
+ let lastPurge = 0;
170
+
171
+ /** One round of the queue: a batch of each group with work (call again until it returns 0). */
172
+ export async function embeddingTick(): Promise<number> {
173
+ if (Date.now() - lastPurge > 3_600_000) {
174
+ lastPurge = Date.now();
175
+ await owner.query(`delete from meta.embedding_queue where attempts >= $1 and next_at < now() - interval '7 days'`, [MAX_ATTEMPTS]);
176
+ }
177
+ const groups = (await owner.query<Group>(
178
+ `select distinct app_id, provider, tbl, vector_column, key_column, source_columns from meta.embedding_queue
179
+ where next_at <= now() and attempts < $1 limit 20`, [MAX_ATTEMPTS])).rows;
180
+ let done = 0;
181
+ const providers = new Map<number, VectorProvider[]>();
182
+ for (const g of groups) {
183
+ if (!providers.has(g.app_id)) providers.set(g.app_id, await loadVectorProviders(g.app_id));
184
+ const p = providers.get(g.app_id)!.find((x) => x.name === g.provider);
185
+ if (!p || vectorProviderProblems(p).length) {
186
+ await owner.query(
187
+ `update meta.embedding_queue set attempts = $5, last_error = $6, next_at = now() + interval '10 minutes'
188
+ where app_id = $1 and provider = $2 and tbl = $3 and vector_column = $4`,
189
+ [g.app_id, g.provider, g.tbl, g.vector_column, MAX_ATTEMPTS, `There is no valid vector provider ${g.provider} in application ${g.app_id}.`]);
190
+ continue;
191
+ }
192
+ try {
193
+ done += await runBatch(g, p);
194
+ } catch (e) {
195
+ console.error('embeddings:', (e as Error).message);
196
+ }
197
+ }
198
+ return done;
199
+ }
200
+
201
+ /** Work the queue until it is empty or waiting (tests, Backfill); at most `rounds` rounds. */
202
+ export async function drainEmbeddings(rounds = 50) {
203
+ let total = 0;
204
+ for (let i = 0; i < rounds; i++) {
205
+ const n = await embeddingTick();
206
+ if (!n) break;
207
+ total += n;
208
+ }
209
+ return total;
210
+ }
211
+
212
+ let timer: NodeJS.Timeout | null = null;
213
+ let busy = false;
214
+
215
+ /** Kick the queue now (after a Backfill), without waiting for it (not with EMBEDDINGS=off). */
216
+ export function kickEmbeddings() {
217
+ if (busy || process.env.EMBEDDINGS === 'off') return;
218
+ busy = true;
219
+ drainEmbeddings(20).catch((e) => console.error('embeddings:', (e as Error).message)).finally(() => (busy = false));
220
+ }
221
+
222
+ /** Started by server.ts; EMBEDDINGS=off on servers that should not embed, EMBEDDING_INTERVAL_S (default 5). */
223
+ export function startEmbeddingRunner() {
224
+ if (process.env.EMBEDDINGS === 'off' || timer) return;
225
+ const every = Math.max(1, Number(process.env.EMBEDDING_INTERVAL_S ?? 5)) * 1000;
226
+ timer = setInterval(() => kickEmbeddings(), every);
227
+ timer.unref();
228
+ }
229
+
230
+ /** The queue of a vector column: waiting rows, failing rows and the last message. */
231
+ export async function queueState(tbl: number, column: string) {
232
+ return (await owner.one<{ queued: number; failing: number; message: string | null }>(
233
+ `select count(*)::int as queued, count(*) filter (where attempts > 0)::int as failing,
234
+ (select last_error from meta.embedding_queue where tbl = $1 and vector_column = $2 and last_error is not null order by id desc limit 1) as message
235
+ from meta.embedding_queue where tbl = $1 and vector_column = $2`, [tbl, column]))!;
236
+ }
237
+
238
+ // ---------------------------------------------------------------- the triggers that keep vector columns
239
+
240
+ export interface VectorTrigger {
241
+ table: string;
242
+ tbl: number;
243
+ trigger: string;
244
+ /** set: a function provider's embedding at once (BEFORE); queue: queued for the server (AFTER) */
245
+ mode: 'set' | 'queue';
246
+ appId: number;
247
+ provider: string;
248
+ vectorColumn: string;
249
+ keyColumn: string;
250
+ sourceColumns: string[];
251
+ }
252
+
253
+ /** The meta.embedding_set / meta.embedding_enqueue triggers (of one table, or of every table), with their arguments. */
254
+ export async function vectorTriggers(db: Queryable, table?: string): Promise<VectorTrigger[]> {
255
+ const rows = (await db.query(
256
+ `select t.tgrelid::regclass::text as tbl_name, t.tgrelid::int as tbl, t.tgname as trigger, p.proname as fn, pg_catalog.pg_get_triggerdef(t.oid) as def
257
+ from pg_catalog.pg_trigger t join pg_catalog.pg_proc p on p.oid = t.tgfoid join pg_catalog.pg_namespace n on n.oid = p.pronamespace
258
+ where n.nspname = 'meta' and p.proname in ('embedding_set', 'embedding_enqueue') and not t.tgisinternal
259
+ and ($1::text is null or t.tgrelid = $1::regclass)
260
+ order by 1, 3`, [table ?? null])).rows;
261
+ const out: VectorTrigger[] = [];
262
+ for (const r of rows) {
263
+ const args = [...String(/EXECUTE FUNCTION [^(]+\((.*)\)\s*$/i.exec(r.def)?.[1] ?? '').matchAll(/'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'"));
264
+ if (args.length < 5 || !/^\d+$/.test(args[0])) continue;
265
+ out.push({
266
+ table: r.tbl_name, tbl: r.tbl, trigger: r.trigger, mode: r.fn === 'embedding_set' ? 'set' : 'queue', appId: Number(args[0]),
267
+ provider: args[1].toUpperCase(), vectorColumn: args[2], keyColumn: args[3], sourceColumns: args.slice(4),
268
+ });
269
+ }
270
+ return out;
271
+ }
272
+
273
+ /** Queue rows of a vector column for the server: those without an embedding, or all of them. Returns how many. */
274
+ export async function backfill(db: Queryable, t: VectorTrigger, all: boolean, max = 100_000): Promise<number> {
275
+ const id = pg.escapeIdentifier;
276
+ const keys = (await db.query(
277
+ `select t.${id(t.keyColumn)}::text as k from ${t.table} t ${all ? '' : `where t.${id(t.vectorColumn)} is null`} order by 1 limit ${max}`)).rows.map((r: { k: string }) => r.k);
278
+ for (let i = 0; i < keys.length; i += 5000) {
279
+ await owner.query(
280
+ `insert into meta.embedding_queue (app_id, provider, tbl, vector_column, key_column, source_columns, key_value)
281
+ select $1, $2, $3, $4, $5, $6, k from unnest($7::text[]) k
282
+ on conflict (tbl, vector_column, key_value) do update
283
+ set app_id = excluded.app_id, provider = excluded.provider, key_column = excluded.key_column, source_columns = excluded.source_columns,
284
+ queued_at = now(), next_at = now(), attempts = 0, last_error = null`,
285
+ [t.appId, t.provider, t.tbl, t.vectorColumn, t.keyColumn, t.sourceColumns, keys.slice(i, i + 5000)]);
286
+ }
287
+ return keys.length;
288
+ }