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.
- package/db/migrations/136_vector_search.sql +252 -0
- package/docs/apex-feature-parity.md +5 -4
- package/docs/guide/03-builder.md +69 -0
- package/docs/guide/04-pages-and-regions.md +42 -2
- package/docs/guide/09-reference.md +19 -0
- package/docs/guide/12-development.md +31 -2
- package/docs/guide/15-extensions.md +1 -1
- package/examples/hr/README.md +1 -0
- package/examples/hr/hr_72_vector_search.sql +158 -0
- package/package.json +1 -1
- package/public/builder.css +3 -0
- package/src/ai/embeddings.ts +197 -0
- package/src/ai/service.ts +13 -9
- package/src/app.ts +3 -0
- package/src/builder/advisor.ts +41 -1
- package/src/builder/ai-agents.ts +13 -5
- package/src/builder/components.ts +21 -3
- package/src/builder/home.ts +1 -0
- package/src/builder/i18n/de.ts +34 -0
- package/src/builder/i18n/en.ts +34 -0
- package/src/builder/i18n/es.ts +34 -0
- package/src/builder/i18n/fr.ts +34 -0
- package/src/builder/i18n/it.ts +34 -0
- package/src/builder/i18n/ja.ts +34 -0
- package/src/builder/i18n/ko.ts +34 -0
- package/src/builder/i18n/nl.ts +34 -0
- package/src/builder/i18n/pt.ts +34 -0
- package/src/builder/i18n/zh-cn.ts +34 -0
- package/src/builder/i18n/zh-tw.ts +34 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +3 -0
- package/src/builder/table-editor.ts +1 -0
- package/src/builder/vector-column.ts +229 -0
- package/src/builder/vector-providers.ts +286 -0
- package/src/cli/replace.ts +3 -1
- package/src/runtime/ai-agents.ts +53 -2
- package/src/runtime/search.ts +116 -12
- package/src/server.ts +3 -0
- package/src/vector.ts +288 -0
package/src/cli/replace.ts
CHANGED
|
@@ -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". */
|
package/src/runtime/ai-agents.ts
CHANGED
|
@@ -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;
|
package/src/runtime/search.ts
CHANGED
|
@@ -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.
|
|
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 {
|
|
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 (
|
|
53
|
-
search_type?:
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
354
|
-
const
|
|
355
|
-
const
|
|
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,
|
|
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
|
+
}
|