fix(node): preserve embedding registration in server bundles (#3806)

## Summary

- lazily initialize built-in OpenAI and Hugging Face providers when
consumers call the public embedding registry API
- choose automatic vector versus FTS search from embedding metadata on a
fresh pinned table revision for every execution
- expose automatic string searches as an `AutoQuery` with only
operations common to both native query families
- keep the registry shared and built-in registration safe across
duplicated module graphs

## Root cause

Nitro treats dependency modules as side-effect-free and removes the bare
OpenAI provider import from its generated route. Registration therefore
never runs, so `getRegistry().get("openai")` remains undefined even when
the registry itself is shared globally. Bundlers may also duplicate the
provider and registry module graphs.

The public embedding entry point now initializes built-in providers only
when `getRegistry()` is explicitly called, keeping initialization on a
live path that Nitro retains. Each terminal automatic-search execution
pins the exact table revision visible at dispatch, reads embedding
metadata and computes an embedding from that snapshot, replays the
builder operations, and constructs and executes the selected native
query against the same snapshot. Pinned native snapshots execute locally
when namespace pushdown cannot carry their revision, while remote
snapshots are seeded directly from one version-and-schema response. The
public `AutoQuery` builder exposes only the operations shared by FTS and
vector search, so runtime class narrowing cannot expose invalid
vector-only methods. Repeated built-in registration replaces stale
constructors from duplicated module graphs while public `register()`
retains its duplicate-alias error.

## Validation

- `cargo fmt --all`
- `cargo check --quiet --features remote --tests --examples`
- `cargo clippy --quiet --features remote --tests --examples`
- `pnpm build`
- `pnpm lint`
- `pnpm run docs`
- `pnpm test --runInBand` (783 passed, 5 skipped)
- serial examples suite with a local OpenAI mock (11 passed), including
`sentence-transformers.test.ts`
- packaged Nitro 2.13.4 server route using the reported imports returned
`{"registered":true}`
- fresh-process FTS fixture initialized both public built-ins and
confirmed automatic string search still returned the indexed row
- schema-consistency regressions cover read-consistency refresh,
checkout, checkoutLatest, restore, runtime class narrowing, concurrent
overwrite during embedding computation, and reused automatic-search
builders
- focused regressions confirm pinned native snapshots bypass unversioned
namespace pushdown and remote snapshots use one describe request

Fixes #2429

<!-- lance-gatekeeper-fix:v1 agent=2adf0f21b8bfb634606ed8897a849e30
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
Co-authored-by: Xuanwo <github@xuanwo.io>
This commit is contained in:
lancedb-gatefixer[bot]
2026-08-26 04:17:32 +08:00
committed by GitHub
parent a57fb68891
commit 35b5d015ac
18 changed files with 1211 additions and 68 deletions
+102 -37
View File
@@ -111,13 +111,15 @@ export class QueryBase<
NativeQueryType extends NativeQuery | NativeVectorQuery | NativeTakeQuery,
> implements AsyncIterable<RecordBatch>
{
protected inner!: NativeQueryType | Promise<NativeQueryType>;
/**
* @hidden
*/
protected constructor(
protected inner: NativeQueryType | Promise<NativeQueryType>,
) {
// intentionally empty
protected constructor(inner?: NativeQueryType | Promise<NativeQueryType>) {
if (inner !== undefined) {
this.inner = inner;
}
}
// call a function on the inner (either a promise or the actual object)
@@ -135,6 +137,15 @@ export class QueryBase<
}
}
/**
* Return the native query used by the next terminal operation.
*
* @hidden
*/
protected async getInner(): Promise<NativeQueryType> {
return this.inner;
}
/**
* Return only the specified columns.
*
@@ -207,16 +218,11 @@ export class QueryBase<
/**
* @hidden
*/
protected nativeExecute(
protected async nativeExecute(
options?: Partial<QueryExecutionOptions>,
): Promise<NativeBatchIterator> {
if (this.inner instanceof Promise) {
return this.inner.then((inner) =>
inner.execute(options?.maxBatchLength, options?.timeoutMs),
);
} else {
return this.inner.execute(options?.maxBatchLength, options?.timeoutMs);
}
const inner = await this.getInner();
return inner.execute(options?.maxBatchLength, options?.timeoutMs);
}
/**
@@ -245,12 +251,7 @@ export class QueryBase<
/** Collect the results as an Arrow @see {@link ArrowTable}. */
async toArrow(options?: Partial<QueryExecutionOptions>): Promise<ArrowTable> {
const batches = [];
let inner;
if (this.inner instanceof Promise) {
inner = await this.inner;
} else {
inner = this.inner;
}
const inner = await this.getInner();
for await (const batch of new RecordBatchIterable(inner, options)) {
batches.push(batch);
}
@@ -279,11 +280,8 @@ export class QueryBase<
* @returns A Promise that resolves to a string containing the query execution plan explanation.
*/
async explainPlan(verbose = false): Promise<string> {
if (this.inner instanceof Promise) {
return this.inner.then((inner) => inner.explainPlan(verbose));
} else {
return this.inner.explainPlan(verbose);
}
const inner = await this.getInner();
return inner.explainPlan(verbose);
}
/**
@@ -321,13 +319,8 @@ export class QueryBase<
distributedMetrics?: AnalyzePlanDistributedMetrics,
): Promise<string> {
const distributedMetricsMode = distributedMetrics ?? "aggregate";
if (this.inner instanceof Promise) {
return this.inner.then((inner) =>
inner.analyzePlan(distributedMetricsMode),
);
} else {
return this.inner.analyzePlan(distributedMetricsMode);
}
const inner = await this.getInner();
return inner.analyzePlan(distributedMetricsMode);
}
/**
@@ -339,12 +332,8 @@ export class QueryBase<
* @returns An Arrow Schema describing the output columns.
*/
async outputSchema(): Promise<import("./arrow").Schema> {
let schemaBuffer: Buffer;
if (this.inner instanceof Promise) {
schemaBuffer = await this.inner.then((inner) => inner.outputSchema());
} else {
schemaBuffer = await this.inner.outputSchema();
}
const inner = await this.getInner();
const schemaBuffer = await inner.outputSchema();
const schema = tableFromIPC(schemaBuffer).schema;
return schema;
}
@@ -356,7 +345,7 @@ export class StandardQueryBase<
extends QueryBase<NativeQueryType>
implements ExecutableQuery
{
constructor(inner: NativeQueryType | Promise<NativeQueryType>) {
constructor(inner?: NativeQueryType | Promise<NativeQueryType>) {
super(inner);
}
@@ -788,6 +777,51 @@ export class TakeQuery extends QueryBase<NativeTakeQuery> {
}
}
/**
* A builder for automatic string searches.
*
* Automatic search determines whether to use full-text or vector search from
* the table revision selected for each execution. This builder exposes the
* common operations supported by both query families.
*
* @hideconstructor
*/
export class AutoQuery extends StandardQueryBase<
NativeQuery | NativeVectorQuery
> {
private readonly calls: Array<
(inner: NativeQuery | NativeVectorQuery) => void
> = [];
/** @hidden */
constructor(
private readonly createInner: () => Promise<
NativeQuery | NativeVectorQuery
>,
) {
super();
}
/** @hidden */
protected override doCall(
fn: (inner: NativeQuery | NativeVectorQuery) => void,
) {
this.calls.push(fn);
}
/** @hidden */
protected override async getInner(): Promise<
NativeQuery | NativeVectorQuery
> {
const calls = [...this.calls];
const inner = await this.createInner();
for (const call of calls) {
call(inner);
}
return inner;
}
}
/** A builder for LanceDB queries.
*
* @see {@link Table#query}, {@link Table#search}
@@ -802,6 +836,37 @@ export class Query extends StandardQueryBase<NativeQuery> {
super(tbl.query());
}
/** @hidden */
static autoSearch(
tbl: () => Promise<NativeTable>,
query: string,
vector: (tbl: NativeTable) => Promise<Awaited<IntoVector> | undefined>,
columns?: string[],
): AutoQuery {
const nativeQuery = async () => {
const snapshot = await Promise.resolve(tbl());
const resolved = await vector(snapshot);
const inner = snapshot.query();
if (resolved === undefined) {
inner.fullTextSearch({
query,
columns: columns ?? null,
});
return inner;
}
const raw = Array.isArray(resolved)
? null
: extractVectorBuffer(resolved);
if (raw) {
return inner.nearestToRaw(raw.data, raw.dtype);
}
return inner.nearestTo(Float32Array.from(resolved as number[]));
};
return new AutoQuery(nativeQuery);
}
/**
* Find the nearest vectors to the given query vector.
*