@dbx-tools/search 0.6.107 → 0.6.111
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/README.md +96 -110
- package/index.ts +6 -2
- package/lib/index.d.ts +6 -2
- package/lib/index.js +4 -1
- package/lib/src/client.d.ts +23 -43
- package/lib/src/client.js +51 -114
- package/lib/src/lakebase-plugin.d.ts +73 -0
- package/lib/src/lakebase-plugin.js +191 -0
- package/lib/src/lakebase.d.ts +6 -4
- package/lib/src/lakebase.js +45 -13
- package/lib/src/native.d.ts +18 -0
- package/lib/src/native.js +79 -0
- package/lib/src/plugin.d.ts +31 -59
- package/lib/src/plugin.js +63 -130
- package/lib/src/query.d.ts +1 -57
- package/lib/src/query.js +2 -93
- package/lib/src/runtime.d.ts +4 -11
- package/lib/src/runtime.js +3 -7
- package/lib/src/schema.d.ts +1 -1
- package/lib/src/tool.d.ts +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -9
- package/src/client.ts +69 -137
- package/src/lakebase-plugin.ts +258 -0
- package/src/lakebase.ts +56 -15
- package/src/native.ts +117 -0
- package/src/plugin.ts +63 -148
- package/src/query.ts +1 -109
- package/src/runtime.ts +6 -16
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppKit-compatible AI Search provider backed by Lakebase full-text search.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
lakebase,
|
|
9
|
+
Plugin,
|
|
10
|
+
toPlugin,
|
|
11
|
+
ValidationError,
|
|
12
|
+
type BasePluginConfig,
|
|
13
|
+
type IAppRouter,
|
|
14
|
+
type PluginManifest,
|
|
15
|
+
} from "@databricks/appkit";
|
|
16
|
+
import type { IndexConfig, SearchRequest, SearchResponse } from "@databricks/appkit/beta";
|
|
17
|
+
import { plugin as appkitPlugin } from "@dbx-tools/appkit";
|
|
18
|
+
import { log, object, string } from "@dbx-tools/shared-core";
|
|
19
|
+
import type { SearchDocument } from "@dbx-tools/shared-search";
|
|
20
|
+
import type express from "express";
|
|
21
|
+
import type { JSONSchema7 } from "json-schema";
|
|
22
|
+
import { LakebaseSearchBackend } from "./lakebase.ts";
|
|
23
|
+
|
|
24
|
+
const logger = log.logger("search/lakebase-plugin");
|
|
25
|
+
|
|
26
|
+
/** Per-alias Lakebase search configuration. */
|
|
27
|
+
export interface LakebaseAiSearchIndexConfig extends Omit<
|
|
28
|
+
IndexConfig,
|
|
29
|
+
"embeddingFn" | "endpointName" | "pagination" | "reranker"
|
|
30
|
+
> {
|
|
31
|
+
/** Documents used to seed an empty full-text table during setup. */
|
|
32
|
+
documents?: SearchDocument[];
|
|
33
|
+
/** Document field indexed first. Defaults to `text`. */
|
|
34
|
+
textColumn?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Configuration for the Lakebase implementation of AppKit AI Search. */
|
|
38
|
+
export interface LakebaseAiSearchConfig extends BasePluginConfig {
|
|
39
|
+
indexes?: Record<string, LakebaseAiSearchIndexConfig>;
|
|
40
|
+
/** PostgreSQL schema that owns the generated full-text tables. */
|
|
41
|
+
schema?: string;
|
|
42
|
+
/** Enable the document upsert route and exported method. */
|
|
43
|
+
allowWrite?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const CONFIG_SCHEMA: JSONSchema7 = {
|
|
47
|
+
type: "object",
|
|
48
|
+
required: ["indexes"],
|
|
49
|
+
properties: {
|
|
50
|
+
indexes: {
|
|
51
|
+
type: "object",
|
|
52
|
+
additionalProperties: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
indexName: { type: "string" },
|
|
56
|
+
columns: { type: "array", items: { type: "string" } },
|
|
57
|
+
numResults: { type: "number" },
|
|
58
|
+
queryType: { enum: ["full_text"] },
|
|
59
|
+
textColumn: { type: "string" },
|
|
60
|
+
documents: { type: "array", items: { type: "object" } },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
schema: { type: "string" },
|
|
65
|
+
allowWrite: { type: "boolean" },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
interface ResolvedLakebaseIndex {
|
|
70
|
+
alias: string;
|
|
71
|
+
indexName: string;
|
|
72
|
+
config: LakebaseAiSearchIndexConfig;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function routeParam(value: string | string[]): string {
|
|
76
|
+
return Array.isArray(value) ? (value[0] ?? "") : value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Lakebase implementation of the native AppKit `aiSearch` contract. */
|
|
80
|
+
export class LakebaseAiSearchPlugin extends Plugin<LakebaseAiSearchConfig> {
|
|
81
|
+
static manifest = {
|
|
82
|
+
name: "aiSearch",
|
|
83
|
+
displayName: "Lakebase AI Search",
|
|
84
|
+
description: "AppKit AI Search contract backed by PostgreSQL full-text search",
|
|
85
|
+
stability: "beta",
|
|
86
|
+
resources: { required: [], optional: [] },
|
|
87
|
+
config: { schema: CONFIG_SCHEMA },
|
|
88
|
+
} satisfies PluginManifest<"aiSearch">;
|
|
89
|
+
|
|
90
|
+
declare protected config: LakebaseAiSearchConfig;
|
|
91
|
+
private backend: LakebaseSearchBackend | undefined;
|
|
92
|
+
|
|
93
|
+
override async setup(): Promise<void> {
|
|
94
|
+
const lake = appkitPlugin.require(this.context, lakebase, this);
|
|
95
|
+
this.backend = new LakebaseSearchBackend(
|
|
96
|
+
() => lake.exports().getPgConfig(),
|
|
97
|
+
string.trimToNull(this.config.schema) ?? "public",
|
|
98
|
+
);
|
|
99
|
+
for (const index of this.indexes()) {
|
|
100
|
+
await this.backend.provision(index.indexName, {
|
|
101
|
+
textColumn: index.config.textColumn ?? "text",
|
|
102
|
+
...(index.config.documents ? { seed: index.config.documents } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
logger.info("ready", {
|
|
106
|
+
indexes: this.indexes().map((index) => index.alias),
|
|
107
|
+
schema: string.trimToNull(this.config.schema) ?? "public",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
override injectRoutes(router: IAppRouter): void {
|
|
112
|
+
this.route(router, {
|
|
113
|
+
name: "query",
|
|
114
|
+
method: "post",
|
|
115
|
+
path: "/:alias/query",
|
|
116
|
+
handler: async (req: express.Request, res: express.Response) => {
|
|
117
|
+
try {
|
|
118
|
+
res.json(await this.query(routeParam(req.params.alias), req.body as SearchRequest));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
res.status(400).json({
|
|
121
|
+
error: error instanceof Error ? error.message : "Search failed",
|
|
122
|
+
plugin: this.name,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
this.route(router, {
|
|
128
|
+
name: "getConfig",
|
|
129
|
+
method: "get",
|
|
130
|
+
path: "/:alias/config",
|
|
131
|
+
handler: async (req: express.Request, res: express.Response) => {
|
|
132
|
+
const index = this.resolveIndex(routeParam(req.params.alias));
|
|
133
|
+
res.json({
|
|
134
|
+
alias: index.alias,
|
|
135
|
+
columns: index.config.columns,
|
|
136
|
+
queryType: "full_text",
|
|
137
|
+
numResults: index.config.numResults ?? 20,
|
|
138
|
+
reranker: false,
|
|
139
|
+
pagination: false,
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
if (this.config.allowWrite) {
|
|
144
|
+
this.route(router, {
|
|
145
|
+
name: "addDocuments",
|
|
146
|
+
method: "post",
|
|
147
|
+
path: "/:alias/documents",
|
|
148
|
+
handler: async (req: express.Request, res: express.Response) => {
|
|
149
|
+
const documents = Array.isArray(req.body?.documents)
|
|
150
|
+
? req.body.documents.filter(object.isRecord)
|
|
151
|
+
: [];
|
|
152
|
+
res.json(await this.addDocuments(routeParam(req.params.alias), documents));
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Query one configured Lakebase full-text index using AppKit's response shape. */
|
|
159
|
+
async query(
|
|
160
|
+
alias: string,
|
|
161
|
+
request: SearchRequest,
|
|
162
|
+
): Promise<SearchResponse<Record<string, unknown>>> {
|
|
163
|
+
if (!request.queryText) {
|
|
164
|
+
throw new ValidationError("Lakebase AI Search requires queryText");
|
|
165
|
+
}
|
|
166
|
+
if (request.queryVector) {
|
|
167
|
+
throw new ValidationError("Lakebase AI Search does not accept queryVector");
|
|
168
|
+
}
|
|
169
|
+
if (request.queryType && request.queryType !== "full_text") {
|
|
170
|
+
throw new ValidationError("Lakebase AI Search supports only full_text queries");
|
|
171
|
+
}
|
|
172
|
+
const index = this.resolveIndex(alias);
|
|
173
|
+
const startedAt = performance.now();
|
|
174
|
+
const result = await this.requireBackend().search(index.indexName, request.queryText, {
|
|
175
|
+
limit: request.numResults ?? index.config.numResults ?? 20,
|
|
176
|
+
...(request.filters ? { filter: request.filters } : {}),
|
|
177
|
+
});
|
|
178
|
+
const columns = request.columns ?? index.config.columns;
|
|
179
|
+
return {
|
|
180
|
+
results: result.hits.map((hit) => ({
|
|
181
|
+
score: hit.score,
|
|
182
|
+
data: this.project({ id: hit.id, ...hit.fields }, columns),
|
|
183
|
+
})),
|
|
184
|
+
totalCount: result.count,
|
|
185
|
+
queryTimeMs: Math.max(0, performance.now() - startedAt),
|
|
186
|
+
queryType: "full_text",
|
|
187
|
+
nextPageToken: null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Add or update documents in one configured Lakebase full-text index. */
|
|
192
|
+
async addDocuments(alias: string, documents: SearchDocument[]) {
|
|
193
|
+
if (!this.config.allowWrite) {
|
|
194
|
+
throw new ValidationError("Lakebase AI Search writes are disabled");
|
|
195
|
+
}
|
|
196
|
+
const index = this.resolveIndex(alias);
|
|
197
|
+
return this.requireBackend().addDocuments(
|
|
198
|
+
index.indexName,
|
|
199
|
+
documents.filter(object.isRecord),
|
|
200
|
+
index.config.textColumn ?? "text",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
override clientConfig() {
|
|
205
|
+
return {
|
|
206
|
+
indexes: this.indexes().map((index) => ({
|
|
207
|
+
alias: index.alias,
|
|
208
|
+
queryType: "full_text" as const,
|
|
209
|
+
pagination: false,
|
|
210
|
+
})),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
exports() {
|
|
215
|
+
return {
|
|
216
|
+
providerKind: "lakebase" as const,
|
|
217
|
+
query: this.query.bind(this),
|
|
218
|
+
addDocuments: this.addDocuments.bind(this),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async shutdown(): Promise<void> {
|
|
223
|
+
await this.backend?.close();
|
|
224
|
+
this.backend = undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private indexes(): ResolvedLakebaseIndex[] {
|
|
228
|
+
return Object.entries(this.config.indexes ?? {}).map(([alias, config]) => ({
|
|
229
|
+
alias,
|
|
230
|
+
indexName: string.trimToNull(config.indexName) ?? alias,
|
|
231
|
+
config,
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private resolveIndex(alias: string): ResolvedLakebaseIndex {
|
|
236
|
+
const index = this.indexes().find((candidate) => candidate.alias === alias);
|
|
237
|
+
if (!index) throw new ValidationError(`Unknown AI Search index alias "${alias}"`);
|
|
238
|
+
return index;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private requireBackend(): LakebaseSearchBackend {
|
|
242
|
+
if (!this.backend) throw new ValidationError("Lakebase AI Search is not initialized");
|
|
243
|
+
return this.backend;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private project(
|
|
247
|
+
data: Record<string, unknown>,
|
|
248
|
+
columns: string[] | undefined,
|
|
249
|
+
): Record<string, unknown> {
|
|
250
|
+
if (!columns?.length) return data;
|
|
251
|
+
return Object.fromEntries(
|
|
252
|
+
columns.filter((column) => column in data).map((column) => [column, data[column]]),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** AppKit-compatible Lakebase AI Search provider. */
|
|
258
|
+
export const lakebaseAiSearch = toPlugin(LakebaseAiSearchPlugin);
|
package/src/lakebase.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* available. It provisions a single table per index, indexes a generated
|
|
2
|
+
* Lakebase (Postgres) full-text runtime behind `lakebaseAiSearch`. It provisions
|
|
3
|
+
* a single table per index alias, indexes a generated
|
|
5
4
|
* `tsvector`, and answers queries with a prefix `to_tsquery` + `ts_rank`.
|
|
6
5
|
*
|
|
7
6
|
* Queries are compiled rather than passed through `websearch_to_tsquery`,
|
|
@@ -30,6 +29,7 @@ import { log, object, string } from "@dbx-tools/shared-core";
|
|
|
30
29
|
import type {
|
|
31
30
|
SearchDocument,
|
|
32
31
|
SearchHit,
|
|
32
|
+
SearchRequest,
|
|
33
33
|
SearchResult,
|
|
34
34
|
UpsertResult,
|
|
35
35
|
} from "@dbx-tools/shared-search";
|
|
@@ -81,6 +81,7 @@ export function toTsQuery(terms: readonly string[], operator: "&" | "|" = "&"):
|
|
|
81
81
|
/** Options for a single-index Lakebase search (mirrors the client's `SearchOptions`). */
|
|
82
82
|
export interface LakebaseSearchOptions {
|
|
83
83
|
limit?: number;
|
|
84
|
+
filter?: SearchRequest["filter"];
|
|
84
85
|
scoreThreshold?: number;
|
|
85
86
|
signal?: AbortSignal;
|
|
86
87
|
}
|
|
@@ -156,24 +157,33 @@ export class LakebaseSearchBackend {
|
|
|
156
157
|
// An empty (or punctuation-only) box returns rows rather than nothing, so
|
|
157
158
|
// the UI shows content before the user types - as a keyword index would.
|
|
158
159
|
if (terms.length === 0) {
|
|
160
|
+
const filter = this.filterClause(options.filter, 1);
|
|
161
|
+
const limitPosition = filter.params.length + 1;
|
|
159
162
|
const { rows } = await this.query<SearchRow>(
|
|
160
163
|
pool,
|
|
161
|
-
`SELECT id, document, 0::float4 AS score
|
|
162
|
-
|
|
164
|
+
`SELECT id, document, 0::float4 AS score
|
|
165
|
+
FROM ${table}
|
|
166
|
+
WHERE TRUE${filter.sql}
|
|
167
|
+
ORDER BY id
|
|
168
|
+
LIMIT $${limitPosition}`,
|
|
169
|
+
[...filter.params, limit],
|
|
163
170
|
options.signal,
|
|
164
171
|
);
|
|
165
172
|
return this.toResult(text, index, rows, options);
|
|
166
173
|
}
|
|
167
174
|
|
|
168
175
|
// Precise pass: every term must match, each as a prefix.
|
|
176
|
+
const strictFilter = this.filterClause(options.filter, 2);
|
|
177
|
+
const strictLimitPosition = strictFilter.params.length + 2;
|
|
169
178
|
const strict = await this.query<SearchRow>(
|
|
170
179
|
pool,
|
|
171
180
|
`SELECT id, document, ts_rank(search_vector, to_tsquery('english', $1)) AS score
|
|
172
181
|
FROM ${table}
|
|
173
182
|
WHERE search_vector @@ to_tsquery('english', $1)
|
|
183
|
+
${strictFilter.sql}
|
|
174
184
|
ORDER BY score DESC
|
|
175
|
-
LIMIT
|
|
176
|
-
[toTsQuery(terms), limit],
|
|
185
|
+
LIMIT $${strictLimitPosition}`,
|
|
186
|
+
[toTsQuery(terms), ...strictFilter.params, limit],
|
|
177
187
|
options.signal,
|
|
178
188
|
);
|
|
179
189
|
if (strict.rows.length > 0) return this.toResult(text, index, strict.rows, options);
|
|
@@ -183,15 +193,18 @@ export class LakebaseSearchBackend {
|
|
|
183
193
|
// fragment that is not a prefix (`telligence`) or a token the text-search
|
|
184
194
|
// parser split differently than expected. Substring matching cannot use
|
|
185
195
|
// the GIN index, which is why it only runs once the indexed pass fails.
|
|
196
|
+
const relaxedFilter = this.filterClause(options.filter, 3);
|
|
197
|
+
const relaxedLimitPosition = relaxedFilter.params.length + 3;
|
|
186
198
|
const relaxed = await this.query<SearchRow>(
|
|
187
199
|
pool,
|
|
188
200
|
`SELECT id, document, ts_rank(search_vector, to_tsquery('english', $1)) AS score
|
|
189
201
|
FROM ${table}
|
|
190
|
-
WHERE search_vector @@ to_tsquery('english', $1)
|
|
191
|
-
OR search_text ILIKE ANY($2::text[])
|
|
202
|
+
WHERE (search_vector @@ to_tsquery('english', $1)
|
|
203
|
+
OR search_text ILIKE ANY($2::text[]))
|
|
204
|
+
${relaxedFilter.sql}
|
|
192
205
|
ORDER BY score DESC
|
|
193
|
-
LIMIT
|
|
194
|
-
[toTsQuery(terms, "|"), terms.map((term) => `%${term}%`), limit],
|
|
206
|
+
LIMIT $${relaxedLimitPosition}`,
|
|
207
|
+
[toTsQuery(terms, "|"), terms.map((term) => `%${term}%`), ...relaxedFilter.params, limit],
|
|
195
208
|
options.signal,
|
|
196
209
|
);
|
|
197
210
|
return this.toResult(text, index, relaxed.rows, options);
|
|
@@ -283,6 +296,14 @@ export class LakebaseSearchBackend {
|
|
|
283
296
|
[],
|
|
284
297
|
signal,
|
|
285
298
|
);
|
|
299
|
+
await this.query(
|
|
300
|
+
pool,
|
|
301
|
+
`ALTER TABLE ${table}
|
|
302
|
+
ADD COLUMN IF NOT EXISTS search_vector tsvector
|
|
303
|
+
GENERATED ALWAYS AS (to_tsvector('english', search_text)) STORED`,
|
|
304
|
+
[],
|
|
305
|
+
signal,
|
|
306
|
+
);
|
|
286
307
|
await this.query(
|
|
287
308
|
pool,
|
|
288
309
|
`CREATE INDEX IF NOT EXISTS ${this.ident(`${this.bareName(table)}_fts`)}
|
|
@@ -342,6 +363,29 @@ export class LakebaseSearchBackend {
|
|
|
342
363
|
return fields;
|
|
343
364
|
}
|
|
344
365
|
|
|
366
|
+
/** Compile AppKit scalar/array filters against the stored JSON document. */
|
|
367
|
+
private filterClause(
|
|
368
|
+
filter: SearchRequest["filter"],
|
|
369
|
+
startPosition: number,
|
|
370
|
+
): { sql: string; params: unknown[] } {
|
|
371
|
+
if (!filter || Object.keys(filter).length === 0) return { sql: "", params: [] };
|
|
372
|
+
const clauses: string[] = [];
|
|
373
|
+
const params: unknown[] = [];
|
|
374
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
375
|
+
const keyPosition = startPosition + params.length;
|
|
376
|
+
params.push(key);
|
|
377
|
+
const valuePosition = startPosition + params.length;
|
|
378
|
+
if (Array.isArray(value)) {
|
|
379
|
+
params.push(value.map(String));
|
|
380
|
+
clauses.push(`document ->> $${keyPosition} = ANY($${valuePosition}::text[])`);
|
|
381
|
+
} else {
|
|
382
|
+
params.push(String(value));
|
|
383
|
+
clauses.push(`document ->> $${keyPosition} = $${valuePosition}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { sql: ` AND ${clauses.join(" AND ")}`, params };
|
|
387
|
+
}
|
|
388
|
+
|
|
345
389
|
/** The fully-qualified table name for an index reference. */
|
|
346
390
|
private tableFor(index: string): string {
|
|
347
391
|
return `${this.ident(this.schema)}.${this.ident(this.bareName(index))}`;
|
|
@@ -350,10 +394,7 @@ export class LakebaseSearchBackend {
|
|
|
350
394
|
/** A safe bare table name derived from an index reference. */
|
|
351
395
|
private bareName(reference: string): string {
|
|
352
396
|
const last = reference.split(".").filter(Boolean).pop() ?? reference;
|
|
353
|
-
const slug = last
|
|
354
|
-
.toLowerCase()
|
|
355
|
-
.replace(/[^a-z0-9_]+/g, "_")
|
|
356
|
-
.replace(/^_+|_+$/g, "");
|
|
397
|
+
const slug = string.toSlug(last).replace(/-/g, "_");
|
|
357
398
|
return slug.length > 0 ? slug : "documents";
|
|
358
399
|
}
|
|
359
400
|
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter from AppKit's native AI Search provider to the dbx-tools extension
|
|
3
|
+
* contract used by federated search and agent tools.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ValidationError } from "@databricks/appkit";
|
|
9
|
+
import type {
|
|
10
|
+
SearchFilters as AppKitSearchFilters,
|
|
11
|
+
SearchRequest as AppKitSearchRequest,
|
|
12
|
+
SearchResponse as AppKitSearchResponse,
|
|
13
|
+
} from "@databricks/appkit/beta";
|
|
14
|
+
import {
|
|
15
|
+
search as sharedSearch,
|
|
16
|
+
type SearchDocument,
|
|
17
|
+
type SearchHit,
|
|
18
|
+
type SearchRequest as ExtensionSearchRequest,
|
|
19
|
+
type SearchResult,
|
|
20
|
+
type UpsertResult,
|
|
21
|
+
} from "@dbx-tools/shared-search";
|
|
22
|
+
import { string } from "@dbx-tools/shared-core";
|
|
23
|
+
import type { SearchOptions, SearchReadBackend } from "./client.ts";
|
|
24
|
+
import { indexConfigFor, type ResolvedSearchConfig } from "./config.ts";
|
|
25
|
+
|
|
26
|
+
/** Minimal query surface exposed by AppKit's native `aiSearch` plugin. */
|
|
27
|
+
export interface AiSearchProvider {
|
|
28
|
+
providerKind?: "lakebase";
|
|
29
|
+
query(alias: string, request: AppKitSearchRequest): Promise<AppKitSearchResponse>;
|
|
30
|
+
addDocuments?(alias: string, documents: SearchDocument[]): Promise<UpsertResult>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function filters(value: ExtensionSearchRequest["filter"]): AppKitSearchFilters | undefined {
|
|
34
|
+
if (!value) return undefined;
|
|
35
|
+
const result: AppKitSearchFilters = {};
|
|
36
|
+
for (const [key, item] of Object.entries(value)) {
|
|
37
|
+
if (
|
|
38
|
+
typeof item === "string" ||
|
|
39
|
+
typeof item === "number" ||
|
|
40
|
+
typeof item === "boolean" ||
|
|
41
|
+
(Array.isArray(item) &&
|
|
42
|
+
item.every((entry) => typeof entry === "string" || typeof entry === "number"))
|
|
43
|
+
) {
|
|
44
|
+
result[key] = item;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
throw new ValidationError(
|
|
48
|
+
`AI Search filter "${key}" must be a string, number, boolean, or string/number array`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hitId(
|
|
55
|
+
data: Record<string, unknown>,
|
|
56
|
+
primaryKey: string | undefined,
|
|
57
|
+
index: number,
|
|
58
|
+
): string {
|
|
59
|
+
const value = data[primaryKey ?? "id"] ?? Object.values(data)[0] ?? index;
|
|
60
|
+
return String(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function aliasFor(index: string, config: ResolvedSearchConfig): string {
|
|
64
|
+
const alias = indexConfigFor(config, index)?.alias ?? string.trimToNull(index);
|
|
65
|
+
if (!alias) throw new ValidationError("AI Search requires a configured index alias");
|
|
66
|
+
return alias;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build a read backend that delegates every Vector Search query to AppKit. */
|
|
70
|
+
export function nativeAiSearchBackend(
|
|
71
|
+
provider: AiSearchProvider,
|
|
72
|
+
config: ResolvedSearchConfig,
|
|
73
|
+
): SearchReadBackend {
|
|
74
|
+
const backend: SearchReadBackend = {
|
|
75
|
+
supportsLifecycle: provider.providerKind !== "lakebase",
|
|
76
|
+
async search(index: string, query: string, options: SearchOptions = {}): Promise<SearchResult> {
|
|
77
|
+
options.signal?.throwIfAborted();
|
|
78
|
+
const known = indexConfigFor(config, index);
|
|
79
|
+
const alias = aliasFor(index, config);
|
|
80
|
+
const resolvedQueryType = sharedSearch.toAiSearchQueryType(options.mode);
|
|
81
|
+
const resolvedFilters = filters(options.filter);
|
|
82
|
+
const response = await provider.query(alias, {
|
|
83
|
+
queryText: query,
|
|
84
|
+
numResults: options.limit ?? config.pageSize,
|
|
85
|
+
...(options.columns ? { columns: [...options.columns] } : {}),
|
|
86
|
+
...(resolvedQueryType ? { queryType: resolvedQueryType } : {}),
|
|
87
|
+
...(resolvedFilters ? { filters: resolvedFilters } : {}),
|
|
88
|
+
});
|
|
89
|
+
options.signal?.throwIfAborted();
|
|
90
|
+
const hits: SearchHit[] = response.results
|
|
91
|
+
.map((result, resultIndex) => ({
|
|
92
|
+
id: hitId(result.data, known?.primaryKey, resultIndex),
|
|
93
|
+
score: result.score,
|
|
94
|
+
fields: result.data,
|
|
95
|
+
}))
|
|
96
|
+
.filter(
|
|
97
|
+
(hit) => options.scoreThreshold === undefined || hit.score >= options.scoreThreshold,
|
|
98
|
+
);
|
|
99
|
+
return { query, index, hits, count: hits.length };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const addDocuments = provider.addDocuments?.bind(provider);
|
|
103
|
+
if (!addDocuments) return backend;
|
|
104
|
+
return {
|
|
105
|
+
...backend,
|
|
106
|
+
async addDocuments(
|
|
107
|
+
index: string,
|
|
108
|
+
documents: SearchDocument[],
|
|
109
|
+
signal?: AbortSignal,
|
|
110
|
+
): Promise<UpsertResult> {
|
|
111
|
+
signal?.throwIfAborted();
|
|
112
|
+
const result = await addDocuments(aliasFor(index, config), documents);
|
|
113
|
+
signal?.throwIfAborted();
|
|
114
|
+
return result;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|