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