@dbx-tools/search 0.6.9

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/src/client.ts ADDED
@@ -0,0 +1,848 @@
1
+ /**
2
+ * A small, Meilisearch-shaped client over Databricks AI Search (Vector
3
+ * Search). The whole point of this module is ergonomics: the Databricks SDK's
4
+ * `vectorSearchIndexes.queryIndex({ index_name, columns, query_text,
5
+ * query_type, num_results, filters_json })` is powerful but verbose, and the
6
+ * response is columnar. This client hides all of that behind two objects:
7
+ *
8
+ * ```ts
9
+ * const client = createSearchClient();
10
+ * const index = client.index("main.support.docs");
11
+ * const { hits } = await index.search("reset my password", { limit: 5 });
12
+ * await index.addDocuments([{ id: "42", title: "Reset", body: "..." }]);
13
+ * ```
14
+ *
15
+ * `client.search(query, opts)` searches the default index; `client.index(name)`
16
+ * returns a handle bound to one index; `client.universalSearch(query)` fans a
17
+ * query across several indexes and merges the results (Meilisearch's federated
18
+ * / multi-search, the "universal search" the caller asked for). Everything is
19
+ * async and cancellable, resolves the OBO workspace client from the active
20
+ * AppKit execution context (falling back to a service-principal client outside
21
+ * a request), and returns the browser-safe shapes from
22
+ * `@dbx-tools/shared-search`.
23
+ *
24
+ * Autocomplete is just a search with a small `limit` and the raw query text -
25
+ * hybrid mode already prefix-matches, so no separate endpoint is needed; the
26
+ * {@link SearchIndex.autocomplete} helper is a thin, self-documenting alias.
27
+ *
28
+ * @module
29
+ */
30
+
31
+ import { ExecutionError, getExecutionContext } from "@databricks/appkit";
32
+ import { Context } from "@databricks/sdk-experimental";
33
+ import { appkit, databricks } from "@dbx-tools/appkit";
34
+ import { invoke, resolve as modelResolve, serving } from "@dbx-tools/model";
35
+ import type {
36
+ SearchDocument,
37
+ SearchHit,
38
+ SearchMode,
39
+ SearchResult,
40
+ UpsertResult,
41
+ } from "@dbx-tools/shared-search";
42
+ import { async as asyncUtil, json, log, string } from "@dbx-tools/shared-core";
43
+ import { ModelClass } from "@dbx-tools/shared-model";
44
+ import {
45
+ DEFAULT_MODE,
46
+ DEFAULT_PAGE_SIZE,
47
+ DEFAULT_TIMEOUT_MS,
48
+ indexConfigFor,
49
+ resolveIndexName,
50
+ type ResolvedSearchConfig,
51
+ } from "./config.ts";
52
+ import {
53
+ compileFilter,
54
+ toHits,
55
+ toQueryType,
56
+ toRequestColumns,
57
+ type QueryResponseLike,
58
+ } from "./query.ts";
59
+ import type { LakebaseSearchBackend } from "./lakebase.ts";
60
+
61
+ type WorkspaceClientLike = appkit.WorkspaceClientLike;
62
+ const logger = log.logger("search/client");
63
+
64
+ /** Options accepted by a single-index search. */
65
+ export interface SearchOptions {
66
+ /** Maximum hits to return. Defaults to the configured page size. */
67
+ limit?: number;
68
+ /** Match mode. Defaults to the configured mode (hybrid). */
69
+ mode?: SearchMode;
70
+ /** Columns to return per hit. Defaults to the index's configured columns. */
71
+ columns?: readonly string[];
72
+ /** Attribute filters as `{ column: value }` or `{ column: { ">=": n } }`. */
73
+ filter?: Record<string, unknown>;
74
+ /** Drop hits below this score. */
75
+ scoreThreshold?: number;
76
+ /** External cancellation. */
77
+ signal?: AbortSignal;
78
+ }
79
+
80
+ /** Options accepted by a universal (federated) search. */
81
+ export interface UniversalSearchOptions {
82
+ /** Indexes to search. Defaults to every known index. */
83
+ indexes?: readonly string[];
84
+ /** Maximum hits per index before merging. */
85
+ limit?: number;
86
+ /** Match mode. */
87
+ mode?: SearchMode;
88
+ /** External cancellation. */
89
+ signal?: AbortSignal;
90
+ }
91
+
92
+ /**
93
+ * A handle bound to one index. Modeled on Meilisearch's `client.index(uid)`:
94
+ * `search` / `autocomplete` read, `addDocuments` / `deleteDocuments` write (to
95
+ * a direct-access index), and `info` fetches the live definition.
96
+ */
97
+ export class SearchIndex {
98
+ constructor(
99
+ readonly name: string,
100
+ private readonly client: SearchClient,
101
+ ) {}
102
+
103
+ /** Search this index. See {@link SearchClient.search}. */
104
+ search(query: string, options?: SearchOptions): Promise<SearchResult> {
105
+ return this.client.search(query, { ...options, index: this.name });
106
+ }
107
+
108
+ /**
109
+ * Autocomplete against this index: a search with a small default `limit` and
110
+ * the raw prefix as the query. Hybrid mode already handles prefixes, so this
111
+ * is a self-documenting alias rather than a separate code path.
112
+ */
113
+ autocomplete(prefix: string, options?: SearchOptions): Promise<SearchResult> {
114
+ return this.search(prefix, { limit: 5, ...options });
115
+ }
116
+
117
+ /** Add or update documents in this (direct-access) index. */
118
+ addDocuments(documents: SearchDocument[], signal?: AbortSignal): Promise<UpsertResult> {
119
+ return this.client.addDocuments(this.name, documents, signal);
120
+ }
121
+
122
+ /** Delete documents from this (direct-access) index by primary key. */
123
+ deleteDocuments(ids: Array<string | number>, signal?: AbortSignal): Promise<UpsertResult> {
124
+ return this.client.deleteDocuments(this.name, ids, signal);
125
+ }
126
+
127
+ /** Fetch this index's live definition (primary key, columns, readiness). */
128
+ info(signal?: AbortSignal): Promise<IndexInfo> {
129
+ return this.client.getIndex(this.name, signal);
130
+ }
131
+
132
+ /** Trigger a sync of this (Delta Sync) index from its source table. */
133
+ sync(signal?: AbortSignal): Promise<void> {
134
+ return this.client.syncIndex(this.name, signal);
135
+ }
136
+
137
+ /** Delete this index. */
138
+ delete(signal?: AbortSignal): Promise<void> {
139
+ return this.client.deleteIndex(this.name, signal);
140
+ }
141
+
142
+ /** Create this index if it does not exist, otherwise return the existing one. */
143
+ ensure(options?: CreateIndexOptions): Promise<IndexInfo> {
144
+ return this.client.ensureIndex(this.name, options);
145
+ }
146
+ }
147
+
148
+ /** A resolved live index definition. */
149
+ export interface IndexInfo {
150
+ name: string;
151
+ endpoint?: string;
152
+ primaryKey?: string;
153
+ columns: string[];
154
+ ready: boolean;
155
+ rowCount?: number;
156
+ /** True for a DIRECT_ACCESS index (you supply vectors; queries embed client-side). */
157
+ directAccess?: boolean;
158
+ }
159
+
160
+ /**
161
+ * Options for {@link SearchClient.createIndex} / {@link SearchClient.ensureIndex}.
162
+ * The goal is the same as the rest of the client: name a source table and a
163
+ * text column, and everything else - the endpoint, the embedding model, the
164
+ * index type, the sync mode - has a sensible default that infers from the
165
+ * workspace, overridable when a deployment needs to go deeper.
166
+ */
167
+ export interface CreateIndexOptions {
168
+ /**
169
+ * Source Delta table (catalog.schema.table) for a Delta Sync index. Provide
170
+ * this for the common case: Databricks computes and syncs embeddings from it.
171
+ * Omit it (and pass {@link embeddingDimension}) to create a direct-access
172
+ * index you write vectors to yourself.
173
+ */
174
+ sourceTable?: string;
175
+ /** Primary-key column. Defaults to `id`. */
176
+ primaryKey?: string;
177
+ /**
178
+ * The text column embeddings are computed from (Delta Sync). Defaults to the
179
+ * first of `text` / `content` / `body` present, else the caller must set it.
180
+ */
181
+ embeddingSourceColumn?: string;
182
+ /**
183
+ * Embedding model endpoint. A loose name is fuzzy-matched; when omitted the
184
+ * best embedding endpoint in the workspace is chosen ({@link resolveEmbeddingModel}).
185
+ */
186
+ embeddingModel?: string;
187
+ /** Vector Search endpoint to host the index on. Defaults to the plugin's `endpoint`. */
188
+ endpoint?: string;
189
+ /** For a direct-access index: the embedding vector dimension (self-managed vectors, no source table). */
190
+ embeddingDimension?: number;
191
+ /** For a direct-access index: the column the vector is stored in. Defaults to `embedding`. */
192
+ embeddingVectorColumn?: string;
193
+ /**
194
+ * The column-name -> type map used to build a direct-access index's
195
+ * `schema_json` (types: `string`, `int`, `long`, `float`, `double`,
196
+ * `boolean`, `date`, `timestamp`). Must include the primary key and any
197
+ * columns you upsert. Defaults to `{ id: "string", text: "string" }`. The
198
+ * embedding vector column is added automatically.
199
+ */
200
+ schema?: Record<string, string>;
201
+ /** Sync mode for a Delta Sync index. `TRIGGERED` (default) syncs on demand; `CONTINUOUS` keeps fresh. */
202
+ pipelineType?: "TRIGGERED" | "CONTINUOUS";
203
+ /** Extra columns to sync alongside the embedding source (Delta Sync). */
204
+ columnsToSync?: string[];
205
+ /** External cancellation. */
206
+ signal?: AbortSignal;
207
+ }
208
+
209
+ /** Options for {@link SearchClient.ensureEndpoint}. */
210
+ export interface EnsureEndpointOptions {
211
+ /** Wait for the endpoint to come online before returning. Defaults to false. */
212
+ wait?: boolean;
213
+ /** External cancellation. */
214
+ signal?: AbortSignal;
215
+ }
216
+
217
+ /**
218
+ * Options for {@link SearchClient.provision} - a one-call "make this index real
219
+ * and searchable" used at boot or in a seed script. It ensures the endpoint,
220
+ * ensures the index, and (optionally) seeds documents when the index is empty.
221
+ */
222
+ export interface ProvisionOptions extends CreateIndexOptions {
223
+ /** Documents to seed when the index has no rows yet. Skipped if it already has data. */
224
+ seed?: SearchDocument[];
225
+ /**
226
+ * Wait for the endpoint AND index to come online before returning (needed
227
+ * before seeding). Defaults to true. Endpoint creation can take many minutes.
228
+ */
229
+ wait?: boolean;
230
+ /** How long to wait for readiness before giving up. Defaults to 20 minutes. */
231
+ timeoutMs?: number;
232
+ }
233
+
234
+ /**
235
+ * The AI Search client. Construct it with {@link createSearchClient} (which
236
+ * reads a resolved config) or directly for one-off use. All reads resolve the
237
+ * OBO workspace client from the active execution context.
238
+ */
239
+ export class SearchClient {
240
+ constructor(
241
+ private readonly config: ResolvedSearchConfig = {
242
+ indexes: [],
243
+ pageSize: DEFAULT_PAGE_SIZE,
244
+ mode: DEFAULT_MODE,
245
+ basePath: "/api/search",
246
+ timeoutMs: DEFAULT_TIMEOUT_MS,
247
+ allowWrite: false,
248
+ },
249
+ private readonly workspaceClientFactory: () => WorkspaceClientLike = defaultWorkspaceClient,
250
+ /**
251
+ * Optional Lakebase full-text FALLBACK backend. Present only when no Vector
252
+ * Search endpoint is configured but a Lakebase pool is available; when set,
253
+ * search / provision / write operations delegate to it and return the exact
254
+ * same shapes, so nothing downstream can tell which backend answered.
255
+ */
256
+ private readonly lakebase?: LakebaseSearchBackend,
257
+ ) {}
258
+
259
+ /** True when this client is answering out of the Lakebase fallback backend. */
260
+ get usesLakebase(): boolean {
261
+ return this.lakebase !== undefined;
262
+ }
263
+
264
+ /** A handle bound to one index (by full UC name or configured alias). */
265
+ index(reference: string): SearchIndex {
266
+ const name = resolveIndexName(this.config, reference) ?? reference;
267
+ return new SearchIndex(name, this);
268
+ }
269
+
270
+ /**
271
+ * Search one index. `index` may be a full UC name, a configured alias, or
272
+ * omitted to use the default index. Returns hits sorted most-relevant-first.
273
+ */
274
+ async search(
275
+ query: string,
276
+ options: SearchOptions & { index?: string } = {},
277
+ ): Promise<SearchResult> {
278
+ const text = string.trimToEmpty(query);
279
+ const name = resolveIndexName(this.config, options.index);
280
+ if (name === null) {
281
+ throw new ExecutionError("search: no index configured; set a default index or pass one", {
282
+ context: { operation: "search" },
283
+ });
284
+ }
285
+ if (this.lakebase) {
286
+ return this.lakebase.search(name, text, {
287
+ limit: options.limit ?? this.config.pageSize,
288
+ ...(options.scoreThreshold !== undefined ? { scoreThreshold: options.scoreThreshold } : {}),
289
+ ...(options.signal ? { signal: options.signal } : {}),
290
+ });
291
+ }
292
+ const known = indexConfigFor(this.config, name);
293
+ const primaryKey = known?.primaryKey;
294
+ const columns = toRequestColumns(
295
+ options.columns,
296
+ known?.columns ?? this.config.columns,
297
+ primaryKey,
298
+ );
299
+ const mode = options.mode ?? this.config.mode;
300
+ const limit = options.limit ?? this.config.pageSize;
301
+
302
+ // Databricks only manages embeddings for Delta Sync indexes, so a query
303
+ // against a DIRECT_ACCESS index must carry a query VECTOR, not text. Embed
304
+ // the query client-side in that case (the index type is cached after the
305
+ // first lookup so this costs one extra call per index, not per search).
306
+ const directAccess = await this.isDirectAccess(name, options.signal);
307
+ const queryVector = directAccess
308
+ ? (await this.embed([text], this.config.embeddingModel, options.signal))[0]
309
+ : undefined;
310
+
311
+ const response = await this.withClient("search", options.signal, async (client, context) => {
312
+ return client.vectorSearchIndexes.queryIndex(
313
+ {
314
+ index_name: name,
315
+ columns,
316
+ ...(queryVector ? { query_vector: queryVector } : { query_text: text }),
317
+ query_type: toQueryType(mode),
318
+ num_results: limit,
319
+ ...(compileFilter(options.filter) ? { filters_json: compileFilter(options.filter) } : {}),
320
+ ...(options.scoreThreshold !== undefined
321
+ ? { score_threshold: options.scoreThreshold }
322
+ : {}),
323
+ },
324
+ context,
325
+ );
326
+ });
327
+
328
+ const hits = toHits(response as QueryResponseLike, primaryKey);
329
+ return { query: text, index: name, hits, count: hits.length };
330
+ }
331
+
332
+ /** Cache of index-name -> is-DIRECT_ACCESS, so a search embeds its query only when needed. */
333
+ private readonly directAccessCache = new Map<string, boolean>();
334
+
335
+ /** Whether an index is DIRECT_ACCESS (memoized); a lookup failure assumes Delta Sync. */
336
+ private async isDirectAccess(name: string, signal?: AbortSignal): Promise<boolean> {
337
+ const cached = this.directAccessCache.get(name);
338
+ if (cached !== undefined) return cached;
339
+ try {
340
+ const info = await this.getIndex(name, signal);
341
+ const value = info.directAccess ?? false;
342
+ this.directAccessCache.set(name, value);
343
+ return value;
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Fan a query across several indexes and merge the hits, sorted by score -
351
+ * the "universal search" a single box over many collections needs. Each
352
+ * index is searched concurrently; an index that errors is logged and skipped
353
+ * so one bad index does not sink the whole search.
354
+ */
355
+ async universalSearch(
356
+ query: string,
357
+ options: UniversalSearchOptions = {},
358
+ ): Promise<SearchResult> {
359
+ const text = string.trimToEmpty(query);
360
+ const names =
361
+ options.indexes && options.indexes.length > 0
362
+ ? options.indexes.map((ref) => resolveIndexName(this.config, ref) ?? ref)
363
+ : this.config.indexes.map((i) => i.name);
364
+ const perIndex = options.limit ?? this.config.pageSize;
365
+
366
+ const settled = await Promise.allSettled(
367
+ names.map(async (name) => {
368
+ const result = await this.search(text, {
369
+ index: name,
370
+ limit: perIndex,
371
+ ...(options.mode ? { mode: options.mode } : {}),
372
+ ...(options.signal ? { signal: options.signal } : {}),
373
+ });
374
+ return result.hits.map((hit): SearchHit => ({ ...hit, index: name }));
375
+ }),
376
+ );
377
+
378
+ const hits = settled
379
+ .flatMap((outcome, i) => {
380
+ if (outcome.status === "fulfilled") return outcome.value;
381
+ logger.warn("universal-index-failed", { index: names[i] });
382
+ return [];
383
+ })
384
+ .sort((a, b) => b.score - a.score);
385
+
386
+ return { query: text, hits, count: hits.length };
387
+ }
388
+
389
+ /**
390
+ * Resolve an index reference (full UC name or configured alias) to the name
391
+ * the API expects, failing fast when it resolves to nothing. Without this a
392
+ * blank reference - what an omitted `index` becomes when no default is
393
+ * configured - reaches the SDK as an empty `index_name`, which builds a
394
+ * URL with the name segment missing and comes back as a confusing
395
+ * `ENDPOINT_NOT_FOUND` instead of naming the real problem.
396
+ */
397
+ private requireIndexName(reference: string, operation: string): string {
398
+ const name = string.trimToNull(resolveIndexName(this.config, reference) ?? reference);
399
+ if (name === null) {
400
+ throw new ExecutionError(
401
+ `search: no index configured; set a default index or pass one to ${operation}`,
402
+ { context: { operation } },
403
+ );
404
+ }
405
+ return name;
406
+ }
407
+
408
+ /** Fetch an index's live definition. */
409
+ async getIndex(reference: string, signal?: AbortSignal): Promise<IndexInfo> {
410
+ const name = this.requireIndexName(reference, "getIndex");
411
+ const index = await this.withClient("getIndex", signal, (client, context) =>
412
+ client.vectorSearchIndexes.getIndex({ index_name: name }, context),
413
+ );
414
+ const directAccess = index.direct_access_index_spec !== undefined;
415
+ const spec = index.delta_sync_index_spec ?? index.direct_access_index_spec;
416
+ // Delta Sync surfaces its embedding source columns directly; a direct-access
417
+ // index carries its columns in schema_json (minus the vector column).
418
+ let columns = (spec?.embedding_source_columns ?? []).map((c) => c.name ?? "").filter(Boolean);
419
+ const schemaJson = index.direct_access_index_spec?.schema_json;
420
+ if (directAccess && schemaJson) {
421
+ const schema = json.parseRecord(schemaJson) ?? {};
422
+ const vectorNames = new Set(
423
+ (index.direct_access_index_spec?.embedding_vector_columns ?? [])
424
+ .map((c) => c.name)
425
+ .filter((n): n is string => Boolean(n)),
426
+ );
427
+ columns = Object.keys(schema).filter((c) => !vectorNames.has(c));
428
+ }
429
+ return {
430
+ name: index.name ?? name,
431
+ ...(index.endpoint_name ? { endpoint: index.endpoint_name } : {}),
432
+ ...(index.primary_key ? { primaryKey: index.primary_key } : {}),
433
+ columns,
434
+ ready: index.status?.ready ?? false,
435
+ ...(index.status?.indexed_row_count !== undefined
436
+ ? { rowCount: index.status.indexed_row_count }
437
+ : {}),
438
+ ...(directAccess ? { directAccess: true } : {}),
439
+ };
440
+ }
441
+
442
+ /** Add or update documents in a direct-access index. */
443
+ async addDocuments(
444
+ reference: string,
445
+ documents: SearchDocument[],
446
+ signal?: AbortSignal,
447
+ ): Promise<UpsertResult> {
448
+ const name = this.requireIndexName(reference, "addDocuments");
449
+ if (this.lakebase) {
450
+ return this.lakebase.addDocuments(
451
+ name,
452
+ documents,
453
+ this.config.ensureOnSetup?.textColumn ?? "text",
454
+ signal,
455
+ );
456
+ }
457
+ await this.withClient("addDocuments", signal, (client, context) =>
458
+ client.vectorSearchIndexes.upsertDataVectorIndex(
459
+ { index_name: name, inputs_json: JSON.stringify(documents) },
460
+ context,
461
+ ),
462
+ );
463
+ return { index: name, count: documents.length };
464
+ }
465
+
466
+ /** Delete documents from a direct-access index by primary key. */
467
+ async deleteDocuments(
468
+ reference: string,
469
+ ids: Array<string | number>,
470
+ signal?: AbortSignal,
471
+ ): Promise<UpsertResult> {
472
+ const name = this.requireIndexName(reference, "deleteDocuments");
473
+ if (this.lakebase) {
474
+ return this.lakebase.deleteDocuments(name, ids, signal);
475
+ }
476
+ await this.withClient("deleteDocuments", signal, (client, context) =>
477
+ client.vectorSearchIndexes.deleteDataVectorIndex(
478
+ { index_name: name, primary_keys: ids.map(String) },
479
+ context,
480
+ ),
481
+ );
482
+ return { index: name, count: ids.length };
483
+ }
484
+
485
+ /**
486
+ * Resolve an embedding endpoint id for creating a Delta Sync index. Reuses
487
+ * the model resolver: a configured / passed name is fuzzy-matched against the
488
+ * live catalogue, otherwise the highest-ranked embedding endpoint is chosen.
489
+ */
490
+ async resolveEmbeddingModel(requested?: string, signal?: AbortSignal): Promise<string | null> {
491
+ const explicit = string.trimToNull(requested ?? this.config.embeddingModel);
492
+ // An explicit name that already looks like an endpoint id (no whitespace)
493
+ // is used verbatim - no need to fetch and fuzzy-match the live catalogue.
494
+ // A genuinely loose name (e.g. "gte large") still resolves against it.
495
+ if (explicit && !/\s/.test(explicit)) return explicit;
496
+ return this.withClient("resolveEmbeddingModel", signal, async (client) => {
497
+ const host = (await client.config.getHost()).toString();
498
+ const endpoints = await serving.listServingEndpoints(client, host);
499
+ const { modelId } = modelResolve.resolveModel(endpoints, {
500
+ ...(explicit ? { explicit } : { modelClass: ModelClass.Embedding }),
501
+ });
502
+ return string.trimToNull(modelId);
503
+ });
504
+ }
505
+
506
+ /**
507
+ * Embed text via a Databricks embedding serving endpoint, returning one
508
+ * vector per input. Used to seed a direct-access index and to turn a search
509
+ * query into a query vector - Databricks only manages embeddings for Delta
510
+ * Sync indexes, so a direct-access index (no Delta table, no warehouse) needs
511
+ * the client to embed on write and on query. The endpoint is resolved the
512
+ * same way as for index creation when not named.
513
+ */
514
+ async embed(texts: string[], model?: string, signal?: AbortSignal): Promise<number[][]> {
515
+ if (texts.length === 0) return [];
516
+ const endpoint = await this.resolveEmbeddingModel(model, signal);
517
+ if (!endpoint) {
518
+ throw new ExecutionError("search: could not resolve an embedding model to embed text", {
519
+ context: { operation: "embed" },
520
+ });
521
+ }
522
+ return this.withClient("embed", signal, async (client) => {
523
+ const host = (await client.config.getHost()).toString();
524
+ const url = invoke.invocationsUrl(host, endpoint);
525
+ const headers = await invoke.authHeaders(client);
526
+ const response = await fetch(url, {
527
+ method: "POST",
528
+ headers: { ...headers, "content-type": "application/json" },
529
+ body: JSON.stringify({ input: texts }),
530
+ ...(signal ? { signal } : {}),
531
+ });
532
+ if (!response.ok) {
533
+ throw new ExecutionError(
534
+ `search: embedding endpoint ${endpoint} failed (${response.status})`,
535
+ { context: { operation: "embed" } },
536
+ );
537
+ }
538
+ const body = (await response.json()) as { data?: Array<{ embedding: number[] }> };
539
+ const vectors = (body.data ?? []).map((row) => row.embedding);
540
+ if (vectors.length !== texts.length) {
541
+ throw new ExecutionError("search: embedding response did not match the input count", {
542
+ context: { operation: "embed" },
543
+ });
544
+ }
545
+ return vectors;
546
+ });
547
+ }
548
+
549
+ /** The vector dimension a model produces (embeds a probe string once). */
550
+ private async embeddingDimension(model?: string, signal?: AbortSignal): Promise<number> {
551
+ const [vector] = await this.embed(["dimension probe"], model, signal);
552
+ if (!vector || vector.length === 0) {
553
+ throw new ExecutionError("search: could not determine the embedding dimension", {
554
+ context: { operation: "createIndex" },
555
+ });
556
+ }
557
+ return vector.length;
558
+ }
559
+
560
+ /**
561
+ * Create an AI Search index with as little ceremony as possible. Two shapes:
562
+ *
563
+ * - **Delta Sync** (the default): pass `sourceTable`; Databricks computes
564
+ * embeddings from the text column and keeps the index synced. The
565
+ * embedding model is resolved automatically when not named.
566
+ * - **Direct Access**: omit `sourceTable` and pass `embeddingDimension`;
567
+ * you write vectors yourself via {@link addDocuments}.
568
+ *
569
+ * Everything else infers: the endpoint from the plugin config, the primary
570
+ * key (`id`), the text column (`text` / `content` / `body`), and the vector
571
+ * column (`embedding`). Returns the created index's {@link IndexInfo}.
572
+ */
573
+ async createIndex(name: string, options: CreateIndexOptions = {}): Promise<IndexInfo> {
574
+ const endpoint = options.endpoint ?? this.config.endpoint;
575
+ if (!endpoint) {
576
+ throw new ExecutionError(
577
+ "search: no Vector Search endpoint configured; pass `endpoint` or set it on the plugin",
578
+ { context: { operation: "createIndex" } },
579
+ );
580
+ }
581
+ const primaryKey = options.primaryKey ?? "id";
582
+ const direct = !options.sourceTable;
583
+ const sourceColumn = options.embeddingSourceColumn ?? "text";
584
+ const vectorColumn = options.embeddingVectorColumn ?? "embedding";
585
+
586
+ // Delta Sync lets Databricks embed a source column, so it needs an embedding
587
+ // model. A direct-access index stores vectors YOU supply (Databricks only
588
+ // supports managed embeddings on Delta Sync), so it needs a dimension; we
589
+ // resolve one from the embedding model when not given so a direct-access
590
+ // index still works with zero extra config (embed via `embed()` on seed +
591
+ // query - see `provision` / `search`).
592
+ const embeddingModel =
593
+ (await this.resolveEmbeddingModel(options.embeddingModel, options.signal)) ?? undefined;
594
+ if (!embeddingModel) {
595
+ throw new ExecutionError(
596
+ "search: could not resolve an embedding model; pass `embeddingModel`",
597
+ { context: { operation: "createIndex" } },
598
+ );
599
+ }
600
+ const dimension =
601
+ options.embeddingDimension ?? (await this.embeddingDimension(embeddingModel, options.signal));
602
+
603
+ const schema = {
604
+ [primaryKey]: "string",
605
+ [sourceColumn]: "string",
606
+ ...(options.schema ?? {}),
607
+ };
608
+
609
+ const directSpec = {
610
+ direct_access_index_spec: {
611
+ embedding_vector_columns: [{ name: vectorColumn, embedding_dimension: dimension }],
612
+ schema_json: JSON.stringify({ ...schema, [vectorColumn]: `array<float>` }),
613
+ },
614
+ };
615
+
616
+ await this.withClient("createIndex", options.signal, (client, context) =>
617
+ client.vectorSearchIndexes.createIndex(
618
+ {
619
+ name,
620
+ endpoint_name: endpoint,
621
+ primary_key: primaryKey,
622
+ index_type: direct ? "DIRECT_ACCESS" : "DELTA_SYNC",
623
+ ...(direct
624
+ ? directSpec
625
+ : {
626
+ delta_sync_index_spec: {
627
+ source_table: options.sourceTable,
628
+ pipeline_type: options.pipelineType ?? "TRIGGERED",
629
+ embedding_source_columns: [
630
+ { name: sourceColumn, embedding_model_endpoint_name: embeddingModel },
631
+ ],
632
+ ...(options.columnsToSync && options.columnsToSync.length > 0
633
+ ? { columns_to_sync: options.columnsToSync }
634
+ : {}),
635
+ },
636
+ }),
637
+ },
638
+ context,
639
+ ),
640
+ );
641
+ logger.info("index-created", {
642
+ index: name,
643
+ endpoint,
644
+ type: direct ? "DIRECT_ACCESS" : "DELTA_SYNC",
645
+ });
646
+ return this.getIndex(name, options.signal);
647
+ }
648
+
649
+ /**
650
+ * Create the index if it does not already exist, otherwise return the
651
+ * existing one. Idempotent - safe to call on every boot to guarantee an
652
+ * index is present.
653
+ */
654
+ async ensureIndex(name: string, options: CreateIndexOptions = {}): Promise<IndexInfo> {
655
+ try {
656
+ return await this.getIndex(name, options.signal);
657
+ } catch {
658
+ return this.createIndex(name, options);
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Ensure an index exists, is online, and (optionally) holds seed data - the
664
+ * "wire up a real index on boot" path. Idempotent and cheap to call every
665
+ * boot: it creates the endpoint and index only if missing, waits for them to
666
+ * come online, and seeds documents ONLY when the index is still empty.
667
+ *
668
+ * For the demo/dummy-data case this needs no Delta table and no warehouse:
669
+ * the default is a MANAGED direct-access index (Databricks embeds the `text`
670
+ * column on write and query), so seeding is just an `addDocuments` of plain
671
+ * rows and search-by-text works immediately.
672
+ */
673
+ async provision(name: string, options: ProvisionOptions = {}): Promise<IndexInfo> {
674
+ if (this.lakebase) {
675
+ // Lakebase fallback: no endpoint, no embeddings, no vectors - just a
676
+ // Postgres full-text table. Returns an IndexInfo-shaped result so the
677
+ // caller's logging + readiness handling is identical to Vector Search.
678
+ const rowCount = await this.lakebase.provision(name, {
679
+ textColumn: options.embeddingSourceColumn ?? "text",
680
+ ...(options.seed ? { seed: options.seed } : {}),
681
+ ...(options.signal ? { signal: options.signal } : {}),
682
+ });
683
+ return {
684
+ name,
685
+ primaryKey: options.primaryKey ?? "id",
686
+ columns: [],
687
+ ready: true,
688
+ rowCount,
689
+ };
690
+ }
691
+ const wait = options.wait ?? true;
692
+ const timeoutMs = options.timeoutMs ?? 20 * 60 * 1000;
693
+ const endpoint = options.endpoint ?? this.config.endpoint;
694
+ if (endpoint) await this.ensureEndpoint(endpoint, { wait, signal: options.signal });
695
+
696
+ const { seed: _seed, wait: _wait, timeoutMs: _timeoutMs, ...createOptions } = options;
697
+ let info = await this.ensureIndex(name, createOptions);
698
+
699
+ if (wait && !info.ready) info = await this.waitForIndexReady(name, timeoutMs, options.signal);
700
+
701
+ const seed = options.seed ?? [];
702
+ if (seed.length > 0 && (info.rowCount ?? 0) === 0) {
703
+ const sourceColumn = options.embeddingSourceColumn ?? "text";
704
+ const vectorColumn = options.embeddingVectorColumn ?? "embedding";
705
+ // Direct-access indexes store vectors we supply, so embed the text column
706
+ // for any seed row that did not already carry a vector.
707
+ const needEmbed = seed.some((doc) => doc[vectorColumn] === undefined);
708
+ let rows = seed;
709
+ if (needEmbed) {
710
+ const texts = seed.map((doc) => string.trimToEmpty(String(doc[sourceColumn] ?? "")));
711
+ const vectors = await this.embed(texts, options.embeddingModel, options.signal);
712
+ rows = seed.map((doc, i) =>
713
+ doc[vectorColumn] === undefined ? { ...doc, [vectorColumn]: vectors[i] } : doc,
714
+ );
715
+ }
716
+ await this.addDocuments(name, rows, options.signal);
717
+ logger.info("index-seeded", { index: name, count: rows.length });
718
+ info = await this.getIndex(name, options.signal);
719
+ }
720
+ return info;
721
+ }
722
+
723
+ /** Poll an index until it reports ready, or throw after {@link timeoutMs}. */
724
+ private async waitForIndexReady(
725
+ name: string,
726
+ timeoutMs: number,
727
+ signal?: AbortSignal,
728
+ ): Promise<IndexInfo> {
729
+ const deadline = Date.now() + timeoutMs;
730
+ let info = await this.getIndex(name, signal);
731
+ while (!info.ready) {
732
+ if (Date.now() > deadline) {
733
+ throw new ExecutionError(`search: index ${name} did not come online in time`, {
734
+ context: { operation: "provision" },
735
+ });
736
+ }
737
+ await asyncUtil.sleep(5000, signal);
738
+ info = await this.getIndex(name, signal);
739
+ }
740
+ return info;
741
+ }
742
+
743
+ /** Trigger a sync of a Delta Sync index from its source table. */
744
+ async syncIndex(reference: string, signal?: AbortSignal): Promise<void> {
745
+ const name = this.requireIndexName(reference, "syncIndex");
746
+ await this.withClient("syncIndex", signal, (client, context) =>
747
+ client.vectorSearchIndexes.syncIndex({ index_name: name }, context),
748
+ );
749
+ logger.info("index-synced", { index: name });
750
+ }
751
+
752
+ /** Delete an index. */
753
+ async deleteIndex(reference: string, signal?: AbortSignal): Promise<void> {
754
+ const name = this.requireIndexName(reference, "deleteIndex");
755
+ await this.withClient("deleteIndex", signal, (client, context) =>
756
+ client.vectorSearchIndexes.deleteIndex({ index_name: name }, context),
757
+ );
758
+ logger.info("index-deleted", { index: name });
759
+ }
760
+
761
+ /** List the indexes hosted on a Vector Search endpoint (name + type only). */
762
+ async listIndexes(endpoint?: string, signal?: AbortSignal): Promise<string[]> {
763
+ const endpointName = endpoint ?? this.config.endpoint;
764
+ if (!endpointName) {
765
+ throw new ExecutionError("search: no endpoint configured to list indexes", {
766
+ context: { operation: "listIndexes" },
767
+ });
768
+ }
769
+ return this.withClient("listIndexes", signal, async (client, context) => {
770
+ const names: string[] = [];
771
+ for await (const index of client.vectorSearchIndexes.listIndexes(
772
+ { endpoint_name: endpointName },
773
+ context,
774
+ )) {
775
+ if (index.name) names.push(index.name);
776
+ }
777
+ return names;
778
+ });
779
+ }
780
+
781
+ /**
782
+ * Ensure a Vector Search endpoint exists, creating a `STANDARD` one when it
783
+ * does not. Optionally wait for it to come online. Idempotent.
784
+ */
785
+ async ensureEndpoint(name?: string, options: EnsureEndpointOptions = {}): Promise<void> {
786
+ const endpoint = name ?? this.config.endpoint;
787
+ if (!endpoint) {
788
+ throw new ExecutionError("search: no endpoint name to ensure", {
789
+ context: { operation: "ensureEndpoint" },
790
+ });
791
+ }
792
+ await this.withClient("ensureEndpoint", options.signal, async (client, context) => {
793
+ try {
794
+ await client.vectorSearchEndpoints.getEndpoint({ endpoint_name: endpoint }, context);
795
+ return;
796
+ } catch {
797
+ const waiter = await client.vectorSearchEndpoints.createEndpoint(
798
+ { name: endpoint, endpoint_type: "STANDARD" },
799
+ context,
800
+ );
801
+ logger.info("endpoint-created", { endpoint });
802
+ if (options.wait) await waiter.wait();
803
+ }
804
+ });
805
+ }
806
+
807
+ /**
808
+ * Resolve the workspace client and run one call under a bounded timeout. The
809
+ * caller's signal (if any) and a timeout are merged into one SDK `Context`
810
+ * via {@link appkit.databricks.toContext}, so either unwinds the request.
811
+ */
812
+ private async withClient<T>(
813
+ operation: string,
814
+ signal: AbortSignal | undefined,
815
+ fn: (client: WorkspaceClientLike, context?: Context) => Promise<T>,
816
+ ): Promise<T> {
817
+ const client = this.workspaceClientFactory();
818
+ const controller = new AbortController();
819
+ const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
820
+ const context = databricks.toContext(controller, signal);
821
+ try {
822
+ return await fn(client, context);
823
+ } catch (err) {
824
+ if (signal?.aborted) throw ExecutionError.canceled();
825
+ logger.warn("execution-failed", { operation });
826
+ throw err;
827
+ } finally {
828
+ clearTimeout(timer);
829
+ }
830
+ }
831
+ }
832
+
833
+ /** The OBO workspace client from the active context, or a service-principal client. */
834
+ function defaultWorkspaceClient(): WorkspaceClientLike {
835
+ const ctx = appkit.tryGetExecutionContext();
836
+ if (ctx?.client) return ctx.client;
837
+ // Outside a request scope (a script, a test): a fresh env-auth client.
838
+ return getExecutionContext().client;
839
+ }
840
+
841
+ /** Construct a {@link SearchClient} from a resolved config. */
842
+ export function createSearchClient(
843
+ config?: ResolvedSearchConfig,
844
+ workspaceClientFactory?: () => WorkspaceClientLike,
845
+ lakebase?: LakebaseSearchBackend,
846
+ ): SearchClient {
847
+ return new SearchClient(config, workspaceClientFactory, lakebase);
848
+ }