@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.
@@ -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
- * A Lakebase (Postgres) full-text search backend - the FALLBACK used when no
3
- * Databricks Vector Search endpoint/index is configured but a Lakebase pool is
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 FROM ${table} ORDER BY id LIMIT $1`,
162
- [limit],
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 $2`,
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 $3`,
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
+ }