pgkiln 0.69.0 → 0.71.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/db/migrations/137_create_page_types.sql +904 -0
- package/db/migrations/138_app_utilities.sql +153 -0
- package/docs/apex-feature-parity.md +10 -8
- package/docs/guide/03-builder.md +176 -4
- package/docs/guide/04-pages-and-regions.md +45 -3
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/06-processing.md +18 -0
- package/docs/guide/09-reference.md +34 -1
- package/docs/guide/12-development.md +53 -3
- package/docs/guide/15-extensions.md +1 -1
- package/docs/guide/16-files.md +1 -1
- package/examples/hr/README.md +1 -0
- package/examples/hr/hr_72_vector_search.sql +158 -0
- package/examples/hr/hr_73_configuration_options.sql +17 -0
- package/examples/hr/hr_74_app_utilities.sql +22 -0
- package/package.json +1 -1
- package/public/app.css +9 -0
- package/public/builder.css +48 -0
- package/src/ai/embeddings.ts +197 -0
- package/src/ai/service.ts +13 -9
- package/src/app.ts +13 -0
- package/src/builder/advisor.ts +43 -3
- package/src/builder/ai-agents.ts +13 -5
- package/src/builder/app-updates.ts +3 -0
- package/src/builder/components.ts +24 -4
- package/src/builder/create-page.ts +259 -0
- package/src/builder/dependencies.ts +311 -0
- package/src/builder/features.ts +313 -0
- package/src/builder/home.ts +3 -0
- package/src/builder/i18n/de.ts +38 -0
- package/src/builder/i18n/en.ts +38 -0
- package/src/builder/i18n/es.ts +38 -0
- package/src/builder/i18n/fr.ts +38 -0
- package/src/builder/i18n/it.ts +38 -0
- package/src/builder/i18n/ja.ts +38 -0
- package/src/builder/i18n/ko.ts +38 -0
- package/src/builder/i18n/nl.ts +38 -0
- package/src/builder/i18n/pt.ts +38 -0
- package/src/builder/i18n/zh-cn.ts +38 -0
- package/src/builder/i18n/zh-tw.ts +38 -0
- package/src/builder/login-page.ts +17 -14
- package/src/builder/newapp.ts +41 -23
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/routes.ts +13 -2
- 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/ui-defaults.ts +161 -0
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +154 -0
- package/src/builder/utilities.ts +181 -0
- package/src/builder/vector-column.ts +229 -0
- package/src/builder/vector-providers.ts +286 -0
- package/src/builder/wizards.ts +211 -26
- package/src/cli/replace.ts +11 -1
- package/src/history.ts +155 -0
- package/src/runtime/ai-agents.ts +53 -2
- package/src/runtime/builtin-components.ts +23 -2
- package/src/runtime/items.ts +4 -2
- package/src/runtime/search.ts +116 -12
- package/src/server.ts +3 -0
- package/src/vector.ts +288 -0
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;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { TemplateComponent } from './template-components.ts';
|
|
2
2
|
|
|
3
3
|
// Built-in template components (APEX: the Universal Theme's template
|
|
4
|
-
// components: Avatar, Badge, Comments,
|
|
5
|
-
// Metric Card). Every application can use them in a template_component
|
|
4
|
+
// components: Avatar, Badge, Comments, Content Row (137), Media List,
|
|
5
|
+
// Timeline, and 26.1's Metric Card). Every application can use them in a template_component
|
|
6
6
|
// region or as a report column template without importing anything; a
|
|
7
7
|
// component of the application with the same static id takes its place
|
|
8
8
|
// (Shared Components → Template components → Copy into this application).
|
|
@@ -53,6 +53,27 @@ export const BUILTIN_COMPONENTS: readonly TemplateComponent[] = [
|
|
|
53
53
|
css_classes: [],
|
|
54
54
|
attributes: [text('USER', 'User'), text('INITIALS', 'Initials'), text('DATE', 'Date'), text('COMMENT', 'Comment'), text('ACTIONS', 'Extra text', 'Optional, e.g. "edited".')],
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
// (137) APEX: Content Row: an avatar, a title and description, and on the right a secondary text and a badge
|
|
58
|
+
static_id: 'ut_content_row',
|
|
59
|
+
name: 'Content row',
|
|
60
|
+
description: 'Rows with initials or a picture, a title (linked when the region has a link) and a description, and on the right a secondary text and a badge. Show the region as "multiple".',
|
|
61
|
+
template:
|
|
62
|
+
'<li class="tc-content-row">{if ?IMAGE/}<img class="tc-avatar tc-avatar-md" src="#IMAGE#" alt="" loading="lazy">{else/}<span class="tc-avatar tc-avatar-md" aria-hidden="true">#INITIALS#</span>{endif/}' +
|
|
63
|
+
'<div class="tc-stack"><p class="tc-title">{if ?LINK/}<a href="#LINK#">#TITLE#</a>{else/}#TITLE#{endif/}</p>{if ?DESCRIPTION/}<p class="tc-meta">#DESCRIPTION#</p>{endif/}</div>' +
|
|
64
|
+
'<div class="tc-content-row-end">{if ?MISC/}<span class="tc-meta">#MISC#</span>{endif/}{if ?BADGE/}<span class="tc-badge tc-badge-' + STATE + '">#BADGE#</span>{endif/}</div></li>',
|
|
65
|
+
wrapper: '<ul class="tc-content-rows">#APEX$ROWS#</ul>',
|
|
66
|
+
css_classes: [],
|
|
67
|
+
attributes: [
|
|
68
|
+
text('TITLE', 'Title'),
|
|
69
|
+
text('DESCRIPTION', 'Description'),
|
|
70
|
+
text('MISC', 'Secondary text', 'Shown on the right, e.g. a date or an amount.'),
|
|
71
|
+
text('INITIALS', 'Initials', 'Shown without a picture.'),
|
|
72
|
+
text('IMAGE', 'Picture URL', 'Optional: a relative or https URL.'),
|
|
73
|
+
text('BADGE', 'Badge'),
|
|
74
|
+
text('STATE', 'Badge state', 'success, warning, danger, info or neutral.'),
|
|
75
|
+
],
|
|
76
|
+
},
|
|
56
77
|
{
|
|
57
78
|
static_id: 'ut_media_list',
|
|
58
79
|
name: 'Media list',
|
package/src/runtime/items.ts
CHANGED
|
@@ -309,9 +309,11 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
309
309
|
case 'checkbox_group': {
|
|
310
310
|
useLegend = true;
|
|
311
311
|
const selected = new Set(splitValues(value));
|
|
312
|
-
|
|
312
|
+
// (137) {"switches": true}: each value as a switch (the Configuration page's build options)
|
|
313
|
+
const switches = item.config?.switches === true;
|
|
314
|
+
control = html`<div class="radio-group${switches ? ' switch-group' : ''}">${options.map(
|
|
313
315
|
(o, i) =>
|
|
314
|
-
html`<label class="check"><input type="checkbox" id="${i === 0 ? id : `${id}_${i}`}" name="${id}" value="${o.value}"${selected.has(o.value) ? raw(' checked') : ''}${aria}> ${o.display}</label>`,
|
|
316
|
+
html`<label class="check${switches ? ' switch' : ''}"><input type="checkbox" id="${i === 0 ? id : `${id}_${i}`}" name="${id}" value="${o.value}"${switches ? raw(' role="switch"') : ''}${selected.has(o.value) ? raw(' checked') : ''}${aria}> ${o.display}</label>`,
|
|
315
317
|
)}</div>`;
|
|
316
318
|
break;
|
|
317
319
|
}
|
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');
|