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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { openAiError } from './openai.ts';
|
|
4
|
+
import { baseUrlFor, checkDailyLimit, logUsage, serviceForApp, serviceKey, type CallInfo } from './service.ts';
|
|
5
|
+
import { AiError } from './types.ts';
|
|
6
|
+
|
|
7
|
+
// (sprint 136, AP-1; APEX: Vector Providers, APEX_AI.GET_VECTOR_EMBEDDINGS) Vector providers turn text into an
|
|
8
|
+
// embedding. They are a JSON list in meta.app (vector_providers, migration 136), exported with the application:
|
|
9
|
+
//
|
|
10
|
+
// - ai_service: an AI service of the installation (OpenAI, Gemini, or any OpenAI-compatible endpoint such as
|
|
11
|
+
// Ollama) and an embedding model (text-embedding-3-small, gemini-embedding-001, nomic-embed-text …), called with
|
|
12
|
+
// the official openai SDK's embeddings API. The key stays in meta.ai_service (encrypted, write-only, never
|
|
13
|
+
// exported); the application must be allowed to use the service and its daily limits apply; every call is
|
|
14
|
+
// logged in meta.ai_usage (source "embedding": tokens, model, duration, never the text).
|
|
15
|
+
// - function: a SQL or PL/pgSQL function (text → vector, real[] or float8[]), called by meta.embed() as the
|
|
16
|
+
// role that runs the query (security invoker), so its grants apply.
|
|
17
|
+
//
|
|
18
|
+
// The dimensions are part of the definition: every embedding is checked against them, and the builder checks
|
|
19
|
+
// them against the vector column (vector(n)) a search configuration or a vector column helper uses.
|
|
20
|
+
|
|
21
|
+
export type VectorProviderType = 'ai_service' | 'function';
|
|
22
|
+
export const VECTOR_PROVIDER_TYPES: VectorProviderType[] = ['ai_service', 'function'];
|
|
23
|
+
|
|
24
|
+
export interface VectorProvider {
|
|
25
|
+
name: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
type: VectorProviderType;
|
|
28
|
+
/** ai_service: the AI service's name */
|
|
29
|
+
service?: string;
|
|
30
|
+
/** ai_service: the embedding model, exactly as the provider names it */
|
|
31
|
+
model?: string;
|
|
32
|
+
dimensions: number;
|
|
33
|
+
/** function: schema.name */
|
|
34
|
+
function?: string;
|
|
35
|
+
/** ai_service: texts per request (background embedding; default 64) */
|
|
36
|
+
batch_size?: number;
|
|
37
|
+
/** ai_service: requests per minute (background embedding; default 60) */
|
|
38
|
+
per_minute?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const PROVIDER_NAME = /^[A-Z][A-Z0-9_]{0,59}$/;
|
|
42
|
+
const SERVICE_NAME = /^[A-Za-z][A-Za-z0-9_]{0,59}$/;
|
|
43
|
+
const MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/;
|
|
44
|
+
export const FUNCTION_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}\.[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
45
|
+
/** pgvector's vector type takes up to 16,000 dimensions (an HNSW index up to 2,000). */
|
|
46
|
+
export const MAX_DIMENSIONS = 16_000;
|
|
47
|
+
export const HNSW_MAX_DIMENSIONS = 2_000;
|
|
48
|
+
export const DEFAULT_BATCH = 64;
|
|
49
|
+
export const DEFAULT_PER_MINUTE = 60;
|
|
50
|
+
/** Characters of one text sent to a provider (longer texts are cut). */
|
|
51
|
+
export const MAX_TEXT_CHARS = 20_000;
|
|
52
|
+
|
|
53
|
+
const isObj = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
54
|
+
const intIn = (v: unknown, min: number, max: number) => Number.isInteger(v) && (v as number) >= min && (v as number) <= max;
|
|
55
|
+
|
|
56
|
+
/** Problems with one vector provider (builder and runtime). */
|
|
57
|
+
export function vectorProviderProblems(p: unknown): string[] {
|
|
58
|
+
if (!isObj(p)) return ['A vector provider is an object.'];
|
|
59
|
+
const what = `Vector provider ${typeof p.name === 'string' ? p.name : '?'}`;
|
|
60
|
+
const out: string[] = [];
|
|
61
|
+
if (typeof p.name !== 'string' || !PROVIDER_NAME.test(p.name)) out.push(`${what}: the name (static ID) is upper case letters, digits and _ (e.g. DOC_EMBEDDINGS).`);
|
|
62
|
+
if (!VECTOR_PROVIDER_TYPES.includes(p.type)) out.push(`${what}: the type is ai_service or function.`);
|
|
63
|
+
if (p.description !== undefined && p.description !== null && (typeof p.description !== 'string' || p.description.length > 500)) out.push(`${what}: the description is text (at most 500 characters).`);
|
|
64
|
+
if (!intIn(p.dimensions, 1, MAX_DIMENSIONS)) out.push(`${what}: the dimensions are a whole number from 1 to ${MAX_DIMENSIONS}.`);
|
|
65
|
+
if (p.type === 'ai_service') {
|
|
66
|
+
if (typeof p.service !== 'string' || !SERVICE_NAME.test(p.service)) out.push(`${what}: choose an AI service.`);
|
|
67
|
+
if (typeof p.model !== 'string' || !MODEL.test(p.model)) out.push(`${what}: the embedding model is its name, e.g. text-embedding-3-small or nomic-embed-text.`);
|
|
68
|
+
if (p.function !== undefined) out.push(`${what}: an AI service provider has no function.`);
|
|
69
|
+
}
|
|
70
|
+
if (p.type === 'function') {
|
|
71
|
+
if (typeof p.function !== 'string' || !FUNCTION_NAME.test(p.function)) out.push(`${what}: the function is schema.name, e.g. sales.embed.`);
|
|
72
|
+
if (p.service !== undefined || p.model !== undefined) out.push(`${what}: a function provider has no AI service or model.`);
|
|
73
|
+
}
|
|
74
|
+
if (p.batch_size !== undefined && !intIn(p.batch_size, 1, 2048)) out.push(`${what}: texts per request 1 to 2048.`);
|
|
75
|
+
if (p.per_minute !== undefined && !intIn(p.per_minute, 1, 10_000)) out.push(`${what}: requests per minute 1 to 10000.`);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Problems with an application's list: each entry, and names used twice. */
|
|
80
|
+
export function vectorProvidersProblems(list: unknown): string[] {
|
|
81
|
+
if (!Array.isArray(list)) return ['Vector providers are a list.'];
|
|
82
|
+
const out: string[] = [];
|
|
83
|
+
const names = new Set<string>();
|
|
84
|
+
for (const p of list) {
|
|
85
|
+
out.push(...vectorProviderProblems(p));
|
|
86
|
+
if (isObj(p) && typeof p.name === 'string') {
|
|
87
|
+
if (names.has(p.name)) out.push(`Vector provider ${p.name} is defined twice.`);
|
|
88
|
+
names.add(p.name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The application's vector providers (the owner reads them: the builder may have changed them a moment ago). */
|
|
95
|
+
export async function loadVectorProviders(appId: number): Promise<VectorProvider[]> {
|
|
96
|
+
const r = await owner.one<{ vector_providers: unknown }>('select vector_providers from meta.app where id = $1', [appId]);
|
|
97
|
+
return Array.isArray(r?.vector_providers) ? (r!.vector_providers as VectorProvider[]) : [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One valid provider of an application by its static ID, or an AiError. */
|
|
101
|
+
export async function vectorProviderFor(appId: number, name: string): Promise<VectorProvider> {
|
|
102
|
+
const p = (await loadVectorProviders(appId)).find((x) => x.name === String(name ?? '').toUpperCase());
|
|
103
|
+
if (!p) throw new AiError('config', `There is no vector provider ${String(name ?? '').toUpperCase()} (Shared Components → Vector providers).`);
|
|
104
|
+
const problems = vectorProviderProblems(p);
|
|
105
|
+
if (problems.length) throw new AiError('config', problems.join(' '));
|
|
106
|
+
return p;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A vector as pgvector's text input: [0.1,0.2,…] (numbers only). */
|
|
110
|
+
export function vectorLiteral(v: number[]): string {
|
|
111
|
+
if (!Array.isArray(v) || !v.length || v.some((x) => typeof x !== 'number' || !Number.isFinite(x))) throw new AiError('output', 'The embedding is not a list of numbers.');
|
|
112
|
+
return `[${v.join(',')}]`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A client, a pool or pgkiln's Db: something that runs a query with parameters. */
|
|
116
|
+
type Queryable = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> };
|
|
117
|
+
|
|
118
|
+
export interface EmbedInfo extends Pick<CallInfo, 'appId' | 'pageNo' | 'user' | 'debug'> {
|
|
119
|
+
appId: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Embed texts with a provider: one vector per text, each with the provider's dimensions. A function provider
|
|
124
|
+
* runs through meta.embed() on `client` (the caller's role and transaction); an AI service is called here.
|
|
125
|
+
*/
|
|
126
|
+
export async function embedTexts(p: VectorProvider, texts: string[], info: EmbedInfo, client?: Queryable): Promise<number[][]> {
|
|
127
|
+
if (!texts.length) return [];
|
|
128
|
+
const input = texts.map((t) => String(t ?? '').slice(0, MAX_TEXT_CHARS));
|
|
129
|
+
if (p.type === 'function') {
|
|
130
|
+
if (!client) throw new AiError('config', `Vector provider ${p.name}: a function provider needs a database connection.`);
|
|
131
|
+
const rows = (await client.query('select meta.embed(t, $2, $3) as v from unnest($1::text[]) with ordinality u(t, n) order by n', [input, p.name, info.appId])).rows;
|
|
132
|
+
return rows.map((r: { v: number[] | null }) => {
|
|
133
|
+
if (!Array.isArray(r.v)) throw new AiError('output', `Vector provider ${p.name}: the function returned no embedding.`);
|
|
134
|
+
return r.v.map(Number);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const { service: s, limits } = await serviceForApp(p.service!, info.appId);
|
|
138
|
+
if (s.provider === 'anthropic') throw new AiError('config', `AI service ${s.name} is a Claude service: Claude has no embeddings API (use an OpenAI, Gemini or OpenAI-compatible service).`);
|
|
139
|
+
const call: CallInfo = { ...info, source: 'embedding' };
|
|
140
|
+
await checkDailyLimit(s, limits, call, p.model!);
|
|
141
|
+
const client2 = new OpenAI({ apiKey: serviceKey(s), baseURL: baseUrlFor(s) || undefined, timeout: s.timeout_s * 1000, maxRetries: 1 });
|
|
142
|
+
const t0 = performance.now();
|
|
143
|
+
info.debug?.(6, `vector provider ${p.name}: ${input.length} text(s) to AI service ${s.name} (${s.provider}, ${p.model})`);
|
|
144
|
+
let tokens = 0;
|
|
145
|
+
try {
|
|
146
|
+
let res: OpenAI.CreateEmbeddingResponse;
|
|
147
|
+
try {
|
|
148
|
+
res = await client2.embeddings.create({
|
|
149
|
+
model: p.model!,
|
|
150
|
+
input,
|
|
151
|
+
encoding_format: 'float',
|
|
152
|
+
// OpenAI's text-embedding-3 models shorten their vectors to the dimensions asked for
|
|
153
|
+
...(s.provider === 'openai' && /^text-embedding-3/.test(p.model!) ? { dimensions: p.dimensions } : {}),
|
|
154
|
+
});
|
|
155
|
+
} catch (e) {
|
|
156
|
+
throw openAiError(e);
|
|
157
|
+
}
|
|
158
|
+
tokens = res.usage?.prompt_tokens ?? 0;
|
|
159
|
+
const data = [...(res.data ?? [])].sort((a, b) => a.index - b.index);
|
|
160
|
+
if (data.length !== input.length) throw new AiError('output', `The AI service sent ${data.length} embeddings for ${input.length} texts.`);
|
|
161
|
+
const out = data.map((d) => (Array.isArray(d.embedding) ? d.embedding.map(Number) : []));
|
|
162
|
+
for (const v of out) {
|
|
163
|
+
if (v.length !== p.dimensions)
|
|
164
|
+
throw new AiError('output', `Vector provider ${p.name}: the model returned ${v.length} dimensions, the provider says ${p.dimensions} (change the provider's dimensions).`);
|
|
165
|
+
if (v.some((x) => !Number.isFinite(x))) throw new AiError('output', 'The embedding is not a list of numbers.');
|
|
166
|
+
}
|
|
167
|
+
await logUsage(call, { service: s, model: res.model || p.model!, inputTokens: tokens, outputTokens: 0, ms: performance.now() - t0, status: 'ok', message: null });
|
|
168
|
+
return out;
|
|
169
|
+
} catch (e) {
|
|
170
|
+
const err = e instanceof AiError ? e : new AiError('server', 'The embedding request failed.');
|
|
171
|
+
await logUsage(call, { service: s, model: p.model!, inputTokens: tokens, outputTokens: 0, ms: performance.now() - t0, status: 'error', message: err.kind + (err.status ? ` ${err.status}` : '') });
|
|
172
|
+
info.debug?.(1, `vector provider ${p.name}: ${err.kind}: ${err.message}`);
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------- the search text's embedding
|
|
178
|
+
|
|
179
|
+
/** Search texts embedded recently (per server process, AI service providers only): a search as you type asks again. */
|
|
180
|
+
const recent = new Map<string, { at: number; v: number[] }>();
|
|
181
|
+
const RECENT_MAX = 500;
|
|
182
|
+
const RECENT_MS = 10 * 60_000;
|
|
183
|
+
|
|
184
|
+
/** The embedding of one search text (AI service providers: kept a few minutes in this process's memory). */
|
|
185
|
+
export async function embedQuery(p: VectorProvider, text: string, info: EmbedInfo, client?: Queryable): Promise<number[]> {
|
|
186
|
+
if (p.type === 'function') return (await embedTexts(p, [text], info, client))[0];
|
|
187
|
+
const key = JSON.stringify([info.appId, p.name, p.service, p.model, p.dimensions, text]);
|
|
188
|
+
const hit = recent.get(key);
|
|
189
|
+
if (hit && Date.now() - hit.at < RECENT_MS) return hit.v;
|
|
190
|
+
const [v] = await embedTexts(p, [text], info, client);
|
|
191
|
+
if (recent.size >= RECENT_MAX) recent.delete(recent.keys().next().value!);
|
|
192
|
+
recent.set(key, { at: Date.now(), v });
|
|
193
|
+
return v;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Forget the remembered search texts (tests; a provider that changed). */
|
|
197
|
+
export const forgetQueries = () => recent.clear();
|
package/src/ai/service.ts
CHANGED
|
@@ -99,7 +99,7 @@ export interface CallInfo {
|
|
|
99
99
|
appId: number | null;
|
|
100
100
|
pageNo?: number | null;
|
|
101
101
|
user?: string | null;
|
|
102
|
-
source: 'process' | 'dynamic_action' | 'sql' | 'builder' | 'assistant' | 'nl2ir';
|
|
102
|
+
source: 'process' | 'dynamic_action' | 'sql' | 'builder' | 'assistant' | 'nl2ir' | 'embedding';
|
|
103
103
|
/** debug messages of the request (level, text) */
|
|
104
104
|
debug?: (level: number, text: string) => void;
|
|
105
105
|
}
|
|
@@ -147,6 +147,17 @@ export async function serviceForApp(name: string, appId: number | null) {
|
|
|
147
147
|
return { service: s, limits };
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/** The application's daily request and token limits on a service (logged and refused when reached). */
|
|
151
|
+
export async function checkDailyLimit(s: AiService, limits: { max_requests: number | null; max_tokens: string | null } | null, info: CallInfo, model = s.model) {
|
|
152
|
+
if (!limits || info.appId === null || (limits.max_requests === null && limits.max_tokens === null)) return;
|
|
153
|
+
const today = await usageToday(info.appId, s.id);
|
|
154
|
+
const over = (limits.max_requests !== null && today.requests >= limits.max_requests) || (limits.max_tokens !== null && today.tokens >= Number(limits.max_tokens));
|
|
155
|
+
if (over) {
|
|
156
|
+
await logUsage(info, { service: s, model, inputTokens: 0, outputTokens: 0, ms: 0, status: 'limited', message: 'daily limit' });
|
|
157
|
+
throw new AiError('limit', `This application has reached its daily limit for AI service ${s.name}: try again tomorrow.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
150
161
|
/**
|
|
151
162
|
* One AI request through a service: checks, daily limits, the call, the
|
|
152
163
|
* usage log. A structured request returns the parsed JSON in `json`.
|
|
@@ -157,14 +168,7 @@ export async function generate(name: string, req: GenerateRequest, info: CallInf
|
|
|
157
168
|
if (req.prompt.length > MAX_PROMPT_CHARS || (req.system?.length ?? 0) > MAX_SYSTEM_CHARS)
|
|
158
169
|
throw new AiError('config', `The prompt is too long (at most ${MAX_PROMPT_CHARS} characters, the system prompt ${MAX_SYSTEM_CHARS}).`);
|
|
159
170
|
if (!req.prompt.trim()) throw new AiError('config', 'The prompt is empty.');
|
|
160
|
-
|
|
161
|
-
const today = await usageToday(info.appId, s.id);
|
|
162
|
-
const over = (limits.max_requests !== null && today.requests >= limits.max_requests) || (limits.max_tokens !== null && today.tokens >= Number(limits.max_tokens));
|
|
163
|
-
if (over) {
|
|
164
|
-
await logUsage(info, { service: s, model: s.model, inputTokens: 0, outputTokens: 0, ms: 0, status: 'limited', message: 'daily limit' });
|
|
165
|
-
throw new AiError('limit', `This application has reached its daily limit for AI service ${s.name}: try again tomorrow.`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
171
|
+
await checkDailyLimit(s, limits, info);
|
|
168
172
|
const provider = providerFor(s);
|
|
169
173
|
const t0 = performance.now();
|
|
170
174
|
info.debug?.(6, `AI service ${s.name} (${s.provider}, ${s.model})${req.schema ? ', structured output' : ''}`);
|
package/src/app.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { diagnosticsRoutes } from './builder/diagnostics.ts';
|
|
|
26
26
|
import { ldapRoutes } from './builder/ldap.ts';
|
|
27
27
|
import { aiRoutes } from './builder/ai.ts';
|
|
28
28
|
import { aiAgentRoutes } from './builder/ai-agents.ts';
|
|
29
|
+
import { vectorProviderRoutes } from './builder/vector-providers.ts';
|
|
29
30
|
import { mailRoutes } from './builder/mail.ts';
|
|
30
31
|
import { backupRoutes } from './builder/backups.ts';
|
|
31
32
|
import { availabilityRoutes } from './builder/availability.ts';
|
|
@@ -236,6 +237,8 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
236
237
|
await app.register(ldapRoutes);
|
|
237
238
|
await app.register(aiRoutes);
|
|
238
239
|
await app.register(aiAgentRoutes);
|
|
240
|
+
// (sprint 136, AP-1) vector providers: Shared Components and Workspace utilities
|
|
241
|
+
await app.register(vectorProviderRoutes);
|
|
239
242
|
await app.register(mailRoutes);
|
|
240
243
|
await app.register(backupRoutes);
|
|
241
244
|
await app.register(availabilityRoutes);
|
package/src/builder/advisor.ts
CHANGED
|
@@ -11,8 +11,9 @@ import { handlerProblems } from '../runtime/rest.ts';
|
|
|
11
11
|
import { plpgsqlBlock } from '../runtime/rest-handlers.ts';
|
|
12
12
|
import { processProblems } from '../runtime/processes.ts';
|
|
13
13
|
import { urlConfigProblem, urlTarget } from '../runtime/url-region.ts';
|
|
14
|
-
import { configColumns, searchConfigProblems } from '../runtime/search.ts';
|
|
14
|
+
import { configColumns, searchConfigProblems, textSearchConfig, tsvectorSql } from '../runtime/search.ts';
|
|
15
15
|
import { COMPONENTS } from './components.ts';
|
|
16
|
+
import { DISTANCES, PGVECTOR_MISSING, vectorColumnDims, vectorSchema, type Distance } from '../vector.ts';
|
|
16
17
|
import { appEntries, type Entry } from './search.ts';
|
|
17
18
|
import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
|
|
18
19
|
|
|
@@ -240,10 +241,85 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
240
241
|
findings.push({ severity: 'error', entry, field: 'Source', message: problems.join(' ') });
|
|
241
242
|
continue;
|
|
242
243
|
}
|
|
243
|
-
|
|
244
|
+
let cols = configColumns(row).map((x) => `t.${pg.escapeIdentifier(x)}`).join(', ') || '*';
|
|
245
|
+
// (135) a full-text configuration: its tsvector matched with its text search configuration
|
|
246
|
+
const fulltext = row.search_type === 'fulltext' || row.search_type === 'hybrid';
|
|
247
|
+
// (136) a vector or hybrid configuration also reads its vector (and key) column
|
|
248
|
+
const vectorType = row.search_type === 'vector' || row.search_type === 'hybrid';
|
|
249
|
+
if (vectorType) cols = [cols === '*' ? 't.*' : cols, ...[row.vector_column, row.key_column].filter(Boolean).map((x: string) => `t.${pg.escapeIdentifier(x)}`)].join(', ');
|
|
250
|
+
if (fulltext) {
|
|
251
|
+
const cfg = await textSearchConfig(c, row.fts_config, app.language);
|
|
252
|
+
if (row.fts_config && cfg !== row.fts_config)
|
|
253
|
+
findings.push({ severity: 'error', entry, field: 'Text search configuration', message: `Text search configuration ${row.fts_config} doesn't exist (pg_ts_config): ${cfg} is used instead.` });
|
|
254
|
+
const lit = `${pg.escapeLiteral(cfg)}::regconfig`;
|
|
255
|
+
// (ts_rank_cd takes only a tsvector: text @@ tsquery would pass)
|
|
256
|
+
cols = `${cols === '*' ? 't.*' : cols}, ts_rank_cd(${tsvectorSql(row, lit)}, websearch_to_tsquery(${lit}, 'x'))`;
|
|
257
|
+
}
|
|
244
258
|
checked++;
|
|
245
259
|
const problem = await checkSql(c, `select ${cols} from ${row.source_type === 'table' ? row.source.trim() : `(${row.source})`} t`, 'select');
|
|
246
|
-
if (problem) findings.push({ ...problem, entry, field: 'Source' });
|
|
260
|
+
if (problem) findings.push({ ...problem, entry, field: fulltext ? 'Source / tsvector' : 'Source' });
|
|
261
|
+
// (135) a full-text search on a table without a GIN index on its tsvector reads every row
|
|
262
|
+
if (fulltext && !problem && row.source_type === 'table') {
|
|
263
|
+
const vector = String(row.fts_vector ?? '').trim();
|
|
264
|
+
const column = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/.test(vector) ? vector : null;
|
|
265
|
+
await c.query('savepoint advisor_fts');
|
|
266
|
+
const ix = (await c.query<{ checkable: boolean; gin: boolean }>(
|
|
267
|
+
`select cl.relkind in ('r', 'p', 'm') as checkable,
|
|
268
|
+
exists (select from pg_catalog.pg_index i join pg_catalog.pg_class ic on ic.oid = i.indexrelid join pg_catalog.pg_am am on am.oid = ic.relam
|
|
269
|
+
where i.indrelid = cl.oid and am.amname = 'gin'
|
|
270
|
+
and case when $2::text is not null
|
|
271
|
+
then (select a.attnum from pg_catalog.pg_attribute a where a.attrelid = cl.oid and a.attname = $2 and not a.attisdropped) = any (i.indkey::int2[])
|
|
272
|
+
else i.indexprs is not null and pg_catalog.pg_get_indexdef(i.indexrelid) ~* 'to_tsvector' end) as gin
|
|
273
|
+
from pg_catalog.pg_class cl where cl.oid = $1::regclass`, [row.source.trim(), column]).catch(async () => {
|
|
274
|
+
await c.query('rollback to savepoint advisor_fts');
|
|
275
|
+
return { rows: [] };
|
|
276
|
+
})).rows[0];
|
|
277
|
+
if (ix?.checkable && (!ix.gin || !vector))
|
|
278
|
+
findings.push({
|
|
279
|
+
severity: 'warning', entry, field: 'tsvector column or expression',
|
|
280
|
+
message: column
|
|
281
|
+
? `${row.source.trim()}.${column} has no GIN index: every search reads the whole table (create index … on ${row.source.trim()} using gin (${pg.escapeIdentifier(column)})).`
|
|
282
|
+
: vector
|
|
283
|
+
? `${row.source.trim()} has no GIN index on a to_tsvector expression: every search reads the whole table.`
|
|
284
|
+
: `The tsvector is built from the searchable columns on every search, so no index can help: add a tsvector column with a GIN index (SQL Workshop › Table Properties › Add full-text search) and name it here.`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// (136) vector and hybrid configurations: pgvector, the provider, the column's dimensions and its index
|
|
289
|
+
for (const { kind, row } of all.filter((x) => x.kind === 'search_config' && (x.row.search_type === 'vector' || x.row.search_type === 'hybrid') && !searchConfigProblems(x.row).length)) {
|
|
290
|
+
const entry = byKey.get(`${kind}-${row.id}`) ?? null;
|
|
291
|
+
const schema = await vectorSchema(c);
|
|
292
|
+
if (!schema) {
|
|
293
|
+
findings.push({ severity: 'error', entry, field: 'Search type', message: PGVECTOR_MISSING });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const provider = (Array.isArray(app.vector_providers) ? app.vector_providers : []).find((p: { name: string }) => p.name === row.vector_provider);
|
|
297
|
+
if (!provider) findings.push({ severity: 'error', entry, field: 'Vector provider', message: `There is no vector provider ${row.vector_provider} (Shared Components → Vector providers).` });
|
|
298
|
+
if (row.source_type !== 'table') continue;
|
|
299
|
+
await c.query('savepoint advisor_vec');
|
|
300
|
+
const info = await (async () => {
|
|
301
|
+
const dims = await vectorColumnDims(c, row.source.trim(), row.vector_column);
|
|
302
|
+
const ix = (await c.query<{ ok: boolean; ops: string | null }>(
|
|
303
|
+
`select exists (select from pg_catalog.pg_index i join pg_catalog.pg_class ic on ic.oid = i.indexrelid join pg_catalog.pg_am am on am.oid = ic.relam
|
|
304
|
+
where i.indrelid = $1::regclass and am.amname in ('hnsw', 'ivfflat')
|
|
305
|
+
and (select a.attnum from pg_catalog.pg_attribute a where a.attrelid = $1::regclass and a.attname = $2) = any (i.indkey::int2[])) as ok,
|
|
306
|
+
(select string_agg(o.opcname, ',') from pg_catalog.pg_index i join pg_catalog.pg_opclass o on o.oid = any (i.indclass::oid[])
|
|
307
|
+
where i.indrelid = $1::regclass and (select a.attnum from pg_catalog.pg_attribute a where a.attrelid = $1::regclass and a.attname = $2) = any (i.indkey::int2[])) as ops`,
|
|
308
|
+
[row.source.trim(), row.vector_column])).rows[0];
|
|
309
|
+
return { dims, ix };
|
|
310
|
+
})().catch(async () => {
|
|
311
|
+
await c.query('rollback to savepoint advisor_vec');
|
|
312
|
+
return null;
|
|
313
|
+
});
|
|
314
|
+
if (!info) continue;
|
|
315
|
+
if (info.dims === undefined) findings.push({ severity: 'error', entry, field: 'Vector column', message: `${row.source.trim()} has no column ${row.vector_column}.` });
|
|
316
|
+
else if (info.dims === null) findings.push({ severity: 'error', entry, field: 'Vector column', message: `${row.source.trim()}.${row.vector_column} is not a vector(n) column.` });
|
|
317
|
+
else if (provider && info.dims !== provider.dimensions)
|
|
318
|
+
findings.push({ severity: 'error', entry, field: 'Vector column', message: `${row.source.trim()}.${row.vector_column} is vector(${info.dims}), vector provider ${provider.name} gives ${provider.dimensions} dimensions.` });
|
|
319
|
+
if (info.dims && !info.ix?.ok)
|
|
320
|
+
findings.push({ severity: 'warning', entry, field: 'Vector column', message: `${row.source.trim()}.${row.vector_column} has no HNSW index: every search reads the whole table (SQL Workshop › Table Properties › Add vector column adds one).` });
|
|
321
|
+
else if (info.dims && info.ix?.ops && !info.ix.ops.split(',').includes(DISTANCES[(row.distance ?? 'cosine') as Distance].opclass))
|
|
322
|
+
findings.push({ severity: 'warning', entry, field: 'Distance', message: `The index on ${row.vector_column} is for ${info.ix.ops}, not ${DISTANCES[(row.distance ?? 'cosine') as Distance].opclass}: the ${row.distance ?? 'cosine'} search can't use it.` });
|
|
247
323
|
}
|
|
248
324
|
// the search regions name existing configurations
|
|
249
325
|
for (const { kind, row } of all.filter((x) => x.kind === 'region' && x.row.type === 'search')) {
|
package/src/builder/ai-agents.ts
CHANGED
|
@@ -24,6 +24,7 @@ const TOOL_TYPES: [string, string][] = [
|
|
|
24
24
|
['rest', 'REST data source'],
|
|
25
25
|
['javascript', 'JavaScript action in the browser'],
|
|
26
26
|
['navigate', 'Navigate: a link to a page'],
|
|
27
|
+
['retrieve', 'Retrieve (RAG): rows of a search configuration'],
|
|
27
28
|
];
|
|
28
29
|
|
|
29
30
|
const when = (d: Date | string) => new Date(d).toISOString().slice(0, 19).replace('T', ' ');
|
|
@@ -87,6 +88,7 @@ async function agentForm(a: any, s: Session, x: Partial<AiAgent>, original: stri
|
|
|
87
88
|
|
|
88
89
|
async function toolForm(a: any, s: Session, x: Partial<AiTool> & { parameters_text?: string }, original: string, problems: string[]): Promise<Raw> {
|
|
89
90
|
const sources = (await owner.query<{ name: string }>('select name from meta.rest_source where app_id = $1 order by name', [a.id])).rows.map((r) => r.name);
|
|
91
|
+
const configs = (await owner.query<{ name: string; search_type: string }>(`select name, search_type from meta.search_config where app_id = $1 and source_type <> 'list' order by name`, [a.id])).rows;
|
|
90
92
|
const params = x.parameters_text ?? (x.parameters ? JSON.stringify(x.parameters, null, 2) : '');
|
|
91
93
|
const usedBy = original ? ((a.ai_agents ?? []) as AiAgent[]).filter((g) => (g.tools ?? []).includes(original)).map((g) => g.name) : [];
|
|
92
94
|
return html`${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
|
|
@@ -100,7 +102,10 @@ async function toolForm(a: any, s: Session, x: Partial<AiTool> & { parameters_te
|
|
|
100
102
|
${select('source', 'REST data source (REST)', x.source ?? '', [['', '-'], ...sources.map((n): [string, string] => [n, n])], 'The parameters become the source\'s parameters.')}
|
|
101
103
|
${input('action', 'Action (JavaScript)', x.action ?? '', { placeholder: 'e.g. highlightOrder', help: 'After the answer the browser calls the function registered with pgkiln.actions.register(name) with {value: the arguments}, and the dynamic actions of the custom event of this name.' })}
|
|
102
104
|
${input('pages', 'Pages (Navigate)', (x.pages ?? []).join(', '), { placeholder: 'e.g. 3, 7', help: 'The page numbers the agent may link to. pgkiln offers only those the user may open and builds the URL (item values with their checksum).' })}
|
|
103
|
-
${
|
|
105
|
+
${select('search_config', 'Search configuration (Retrieve)', x.search_config ?? '', [['', '-'], ...configs.map((c): [string, string] => [c.name, `${c.name} (${c.search_type})`]),
|
|
106
|
+
...(x.search_config && !configs.some((c) => c.name === x.search_config) ? [[x.search_config, `${x.search_config} (does not exist)`] as [string, string]] : [])],
|
|
107
|
+
'The model gives a text; pgkiln runs this search configuration (best: vector or hybrid) as the application\'s role, so row level security applies, and returns the best rows\' title, text and score.')}
|
|
108
|
+
${area('parameters', 'Parameters (JSON; not for Navigate or Retrieve)', params, 6, '{"DNAME": {"type": "string", "description": "A department name"}, "LIMIT": {"type": "integer", "optional": true}}. Types: string, integer, number, boolean, date; "enum" for a list of texts.', true)}
|
|
104
109
|
${input('max_rows', 'Maximum rows', x.max_rows ?? '', { type: 'number', placeholder: '50', help: 'Rows the model gets back (1 – 500).' })}
|
|
105
110
|
${input('timeout_s', 'Time limit (seconds)', x.timeout_s ?? '', { type: 'number', placeholder: '15', help: 'SQL and function tools (1 – 60).' })}
|
|
106
111
|
${authzSelect(await schemes(a.id), x.authz, 'Only users who pass it are offered the tool.')}
|
|
@@ -151,8 +156,9 @@ function toolFrom(b: Body, errors: string[]): AiTool & { parameters_text?: strin
|
|
|
151
156
|
if (type === 'rest') x.source = one(b.source).trim().toUpperCase();
|
|
152
157
|
if (type === 'javascript') x.action = one(b.action).trim();
|
|
153
158
|
if (type === 'navigate') x.pages = one(b.pages).split(/[\s,;]+/).filter(Boolean).map(Number);
|
|
159
|
+
if (type === 'retrieve') x.search_config = one(b.search_config).trim().toUpperCase();
|
|
154
160
|
const params = one(b.parameters).trim();
|
|
155
|
-
if (params && type !== 'navigate') {
|
|
161
|
+
if (params && type !== 'navigate' && type !== 'retrieve') {
|
|
156
162
|
try {
|
|
157
163
|
x.parameters = JSON.parse(params);
|
|
158
164
|
} catch {
|
|
@@ -160,11 +166,11 @@ function toolFrom(b: Body, errors: string[]): AiTool & { parameters_text?: strin
|
|
|
160
166
|
x.parameters_text = params;
|
|
161
167
|
}
|
|
162
168
|
}
|
|
163
|
-
if (type === 'sql' || type === 'function' || type === 'rest') {
|
|
169
|
+
if (type === 'sql' || type === 'function' || type === 'rest' || type === 'retrieve') {
|
|
164
170
|
const rows = intOrUndef(one(b.max_rows), 'Maximum rows', errors);
|
|
165
171
|
if (rows !== undefined) x.max_rows = rows;
|
|
166
172
|
}
|
|
167
|
-
if (type === 'sql' || type === 'function') {
|
|
173
|
+
if (type === 'sql' || type === 'function' || type === 'retrieve') {
|
|
168
174
|
const t = intOrUndef(one(b.timeout_s), 'Time limit', errors);
|
|
169
175
|
if (t !== undefined) x.timeout_s = t;
|
|
170
176
|
}
|
|
@@ -225,7 +231,7 @@ export async function aiAgentRoutes(app: FastifyInstance) {
|
|
|
225
231
|
const problems = definitionProblems(agents, tools);
|
|
226
232
|
const main = html`${appHeader(a, 'shared', s)}
|
|
227
233
|
<div class="title-row"><h1>AI Agents</h1></div>
|
|
228
|
-
<p class="muted u-mt0">An agent answers in an AI assistant region and acts only through the tools you give it: SQL queries (read-only, as the application's database role, so row level security applies), PL/pgSQL functions, REST data sources, JavaScript actions in the browser, and links to pages (pgkiln builds the URL and its checksum, only for pages the user may open). What users type and the tools' results go to the agent's AI service. <a href="${BASE}/apps/${a.id}/ai">AI usage</a> lists every tool call.</p>
|
|
234
|
+
<p class="muted u-mt0">An agent answers in an AI assistant region and acts only through the tools you give it: SQL queries (read-only, as the application's database role, so row level security applies), PL/pgSQL functions, REST data sources, JavaScript actions in the browser, and links to pages (pgkiln builds the URL and its checksum, only for pages the user may open). A Retrieve tool gives the agent the rows of a search configuration that match a text by meaning (RAG), again as the application's role. What users type and the tools' results go to the agent's AI service. <a href="${BASE}/apps/${a.id}/ai">AI usage</a> lists every tool call.</p>
|
|
229
235
|
${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
|
|
230
236
|
<div class="columns">
|
|
231
237
|
${region('Agents', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Agent</th><th>AI service</th><th>Tools</th><th>Authorization</th></tr></thead>
|
|
@@ -322,6 +328,8 @@ export async function aiAgentRoutes(app: FastifyInstance) {
|
|
|
322
328
|
const out = aiToolProblems(tool);
|
|
323
329
|
if (tool.type === 'rest' && tool.source && !(await owner.one('select 1 from meta.rest_source where app_id = $1 and name = $2', [a.id, tool.source])))
|
|
324
330
|
out.push(`There is no REST data source ${tool.source}.`);
|
|
331
|
+
if (tool.type === 'retrieve' && tool.search_config && !(await owner.one(`select 1 from meta.search_config where app_id = $1 and name = $2 and source_type <> 'list'`, [a.id, tool.search_config])))
|
|
332
|
+
out.push(`There is no search configuration ${tool.search_config} with a table or SQL source.`);
|
|
325
333
|
if (tool.type === 'navigate') {
|
|
326
334
|
const known = new Set((await owner.query<{ page_no: number }>('select page_no from meta.page where app_id = $1', [a.id])).rows.map((p) => p.page_no));
|
|
327
335
|
for (const p of tool.pages ?? []) if (!known.has(p)) out.push(`There is no page ${p}.`);
|
|
@@ -722,8 +722,21 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
722
722
|
plural: 'Search configurations',
|
|
723
723
|
icon: 'search',
|
|
724
724
|
summary: (c) => `${c.name} (${c.source_type})`,
|
|
725
|
-
defaults: { source_type: 'table', seq: 10, max_results: 10, searchable_columns: [] },
|
|
725
|
+
defaults: { source_type: 'table', search_type: 'standard', seq: 10, max_results: 10, searchable_columns: [], did_you_mean: false, distance: 'cosine' },
|
|
726
726
|
validate: (v) => searchConfigProblems(v as never).join(' ') || null,
|
|
727
|
+
// (135) empty means: built from the searchable columns / the application language's configuration
|
|
728
|
+
beforeSave: async (v) => {
|
|
729
|
+
for (const k of ['fts_vector', 'fts_config'] as const) if (typeof v[k] === 'string' && !(v[k] as string).trim()) v[k] = null;
|
|
730
|
+
if (typeof v.fts_config === 'string') v.fts_config = v.fts_config.trim().toLowerCase();
|
|
731
|
+
// (136) vector and hybrid search
|
|
732
|
+
for (const k of ['vector_column', 'key_column'] as const) if (typeof v[k] === 'string') v[k] = (v[k] as string).trim() || null;
|
|
733
|
+
if (typeof v.vector_provider === 'string') v.vector_provider = v.vector_provider.trim().toUpperCase() || null;
|
|
734
|
+
if (typeof v.min_similarity === 'string') {
|
|
735
|
+
const t = v.min_similarity.trim().replace(',', '.');
|
|
736
|
+
if (t && !Number.isFinite(Number(t))) throw new Error('Minimum similarity must be a number from -1 to 1.');
|
|
737
|
+
v.min_similarity = t ? Number(t) : null;
|
|
738
|
+
}
|
|
739
|
+
},
|
|
727
740
|
fields: [
|
|
728
741
|
{ name: 'name', label: 'Name (static ID)', kind: 'upper', group: 'Identification', help: 'e.g. CUSTOMERS; a search region names it in {"configs": ["CUSTOMERS"]}.' },
|
|
729
742
|
{ name: 'label', label: 'Label', kind: 'text', group: 'Identification', help: 'The heading of its results, e.g. Customers.' },
|
|
@@ -733,6 +746,24 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
733
746
|
{ name: 'source', label: 'Source', kind: 'code', wide: true, group: 'Source', help: 'table: sales.customers · sql: select id, name, city from sales.customers where active · list: the list\'s name.' },
|
|
734
747
|
{ name: 'searchable_columns', label: 'Searchable columns', kind: 'list', group: 'Source',
|
|
735
748
|
help: 'Comma-separated columns the search text is looked for in (each word must be found in one of them). Empty: the title, subtitle and description columns.' },
|
|
749
|
+
{ name: 'search_type', label: 'Search type', kind: 'select', options: ['standard', 'fulltext', 'vector', 'hybrid'], group: 'Search',
|
|
750
|
+
help: 'standard: each word anywhere in one of the searchable columns (case-insensitive) · fulltext: PostgreSQL full-text search: stemmed words ("running" finds "run"), "quoted phrases", or, -word; the best match first, the matched words highlighted (table, sql) · vector: by meaning: the rows whose embedding is nearest to the search text\'s (pgvector) · hybrid: full-text and vector together (reciprocal rank fusion).' },
|
|
751
|
+
{ name: 'fts_vector', label: 'tsvector column or expression', kind: 'code', wide: true, group: 'Search',
|
|
752
|
+
help: 'fulltext: a tsvector column of the source (e.g. search_vector, with a GIN index: SQL Workshop › Table Properties › Add full-text search), or an expression, e.g. to_tsvector(\'english\', title || \' \' || body). Empty: to_tsvector over the searchable columns (no index can help).' },
|
|
753
|
+
{ name: 'fts_config', label: 'Text search configuration', kind: 'text', group: 'Search',
|
|
754
|
+
help: 'fulltext: english, dutch, german, french, simple … (pg_ts_config); the same one the tsvector was made with. Empty: the application\'s primary language\'s, else simple.' },
|
|
755
|
+
{ name: 'did_you_mean', label: 'Did you mean', kind: 'bool', group: 'Search',
|
|
756
|
+
help: 'With the pg_trgm extension installed: when nothing is found, suggest the closest words from the searched columns (reads up to 10,000 rows). Without pg_trgm nothing is suggested.' },
|
|
757
|
+
{ name: 'vector_provider', label: 'Vector provider', kind: 'upper', group: 'Vector search',
|
|
758
|
+
help: 'vector, hybrid: the static ID of a vector provider (Shared Components → Vector providers) that embeds the search text, the same one that embedded the rows.' },
|
|
759
|
+
{ name: 'vector_column', label: 'Vector column', kind: 'text', group: 'Vector search',
|
|
760
|
+
help: 'vector, hybrid: the source\'s vector(n) column, e.g. embedding (SQL Workshop › Table Properties › Add vector column adds it, with an HNSW index, and keeps it current); n must be the provider\'s dimensions.' },
|
|
761
|
+
{ name: 'key_column', label: 'Key column', kind: 'text', group: 'Vector search',
|
|
762
|
+
help: 'hybrid: the primary key (e.g. id) that matches the full-text and the vector results. The full-text side uses the tsvector and text search configuration above.' },
|
|
763
|
+
{ name: 'distance', label: 'Distance', kind: 'select', options: ['cosine', 'l2', 'inner_product'], group: 'Vector search',
|
|
764
|
+
help: 'cosine (most embedding models), l2 (Euclidean) or inner_product; the HNSW index must use the same (vector_cosine_ops, vector_l2_ops, vector_ip_ops).' },
|
|
765
|
+
{ name: 'min_similarity', label: 'Minimum similarity', kind: 'text', group: 'Vector search',
|
|
766
|
+
help: 'vector, hybrid: leave out rows less similar than this (cosine: 1 − distance, e.g. 0.3; inner product: the product; l2: 1 / (1 + distance)). Empty: the nearest rows, however far.' },
|
|
736
767
|
{ name: 'title_column', label: 'Title column', kind: 'text', group: 'Result', help: 'Required (table, sql); results are ordered by it.' },
|
|
737
768
|
{ name: 'subtitle_column', label: 'Subtitle column', kind: 'text', group: 'Result' },
|
|
738
769
|
{ name: 'description_column', label: 'Description column', kind: 'text', group: 'Result' },
|