fix(node): refresh metadata before automatic search

This commit is contained in:
Gatefixer
2026-08-08 13:12:38 +00:00
parent cdbf6e1029
commit db3b448917
5 changed files with 159 additions and 37 deletions
+99
View File
@@ -1731,6 +1731,105 @@ describe("Read consistency interval", () => {
});
});
describe("automatic search schema consistency", () => {
let tmpDir: tmp.DirResult;
class SchemaRefreshEmbedding extends EmbeddingFunction<string> {
ndims() {
return 2;
}
embeddingDataType() {
return new Float32();
}
async computeSourceEmbeddings(data: string[]) {
return data.map((value) => [value.length, 1]);
}
async computeQueryEmbeddings(value: string) {
return [value.length, 1];
}
}
function embeddingSchema() {
const func = new SchemaRefreshEmbedding();
return LanceSchema({
text: func.sourceField(new Utf8()),
vector: func.vectorField(),
});
}
beforeEach(() => {
getRegistry().reset();
register("schema-refresh")(SchemaRefreshEmbedding);
tmpDir = tmp.dirSync({ unsafeCleanup: true });
});
afterEach(() => {
getRegistry().reset();
tmpDir.removeCallback();
});
it("uses the schema refreshed from another connection", async () => {
const first = await connect(tmpDir.name, { readConsistencyInterval: 0 });
const second = await connect(tmpDir.name, { readConsistencyInterval: 0 });
try {
const stale = await first.createTable("docs", [{ text: "before" }], {
schema: embeddingSchema(),
});
const replacement = await second.createTable(
"docs",
[{ text: "after hello" }],
{ mode: "overwrite" },
);
await replacement.createIndex("text", { config: Index.fts() });
const rows = await stale.search("hello").toArray();
expect(rows[0].text).toBe("after hello");
expect((await stale.schema()).metadata.has("embedding_functions")).toBe(
false,
);
} finally {
first.close();
second.close();
}
});
it("tracks embedding metadata across checkout and restore", async () => {
const first = await connect(tmpDir.name, { readConsistencyInterval: 0 });
const second = await connect(tmpDir.name, { readConsistencyInterval: 0 });
try {
await first.createTable("docs", [{ text: "before" }], {
schema: embeddingSchema(),
});
const table = await second.createTable(
"docs",
[{ text: "after hello" }],
{ mode: "overwrite" },
);
await table.createIndex("text", { config: Index.fts() });
await table.checkout(1);
expect((await table.search("before").toArray())[0].text).toBe("before");
await table.checkoutLatest();
expect((await table.search("hello").toArray())[0].text).toBe(
"after hello",
);
await table.checkout(1);
await table.restore();
expect((await table.search("before").toArray())[0].text).toBe("before");
} finally {
first.close();
second.close();
}
});
});
describe("schema evolution", function () {
let tmpDir: tmp.DirResult;
beforeEach(() => {
+4 -4
View File
@@ -535,7 +535,7 @@ export class LocalConnection extends Connection {
options?.indexCacheSize,
);
let table: Table = await LocalTable.create(innerTable);
let table: Table = new LocalTable(innerTable);
// "main" is the default branch, so treat it as no branch. On a real branch,
// scope and pin in one step (yielding "version V of branch B"); otherwise
// pin the version, if any, against main.
@@ -570,7 +570,7 @@ export class LocalConnection extends Connection {
options?.isShallow ?? true,
);
return LocalTable.create(innerTable);
return new LocalTable(innerTable);
}
private getStorageOptions(
@@ -652,7 +652,7 @@ export class LocalConnection extends Connection {
storageOptions,
);
return LocalTable.create(innerTable);
return new LocalTable(innerTable);
}
async createEmptyTable(
@@ -698,7 +698,7 @@ export class LocalConnection extends Connection {
namespacePath ?? [],
storageOptions,
);
return LocalTable.create(innerTable);
return new LocalTable(innerTable);
}
async dropTable(name: string, namespacePath?: string[]): Promise<void> {
+1 -1
View File
@@ -172,7 +172,7 @@ export class PermutationBuilder {
*/
async execute(): Promise<Table> {
const nativeTable: NativeTable = await this.inner.execute();
return LocalTable.create(nativeTable);
return new LocalTable(nativeTable);
}
}
+34
View File
@@ -802,6 +802,40 @@ export class Query extends StandardQueryBase<NativeQuery> {
super(tbl.query());
}
/** @hidden */
static autoSearch(
tbl: NativeTable,
query: string,
vector: Promise<Awaited<IntoVector> | undefined>,
columns?: string[],
): VectorQuery {
const nativeQuery = vector.then((resolved) => {
const inner = tbl.query();
if (resolved === undefined) {
inner.fullTextSearch({
query,
columns: columns ?? null,
});
// Native Query and NativeVectorQuery share all operations exposed by
// Table.search's union return type. Keeping the wrapper as VectorQuery
// also preserves the existing runtime type when auto search selects a
// vector query after the asynchronous schema check.
return inner as unknown as NativeVectorQuery;
}
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 VectorQuery(nativeQuery);
}
/**
* Find the nearest vectors to the given query vector.
*
+21 -32
View File
@@ -814,31 +814,10 @@ export abstract class Table {
export class LocalTable extends Table {
private readonly inner: _NativeTable;
private readonly hasEmbeddingFunctions: boolean;
private constructor(inner: _NativeTable, hasEmbeddingFunctions: boolean) {
constructor(inner: _NativeTable) {
super();
this.inner = inner;
this.hasEmbeddingFunctions = hasEmbeddingFunctions;
}
/** @hidden */
static async create(inner: _NativeTable): Promise<LocalTable> {
const schemaBuf = await inner.schema();
const schema = tableFromIPC(schemaBuf).schema;
const serializedFunctions = schema.metadata.get("embedding_functions");
if (serializedFunctions === undefined) {
return new LocalTable(inner, false);
}
let hasEmbeddingFunctions = true;
try {
const functions = JSON.parse(serializedFunctions);
hasEmbeddingFunctions = !Array.isArray(functions) || functions.length > 0;
} catch {
// Preserve the existing parse error when the search is executed.
}
return new LocalTable(inner, hasEmbeddingFunctions);
}
get name(): string {
return this.inner.name;
@@ -1055,17 +1034,29 @@ export class LocalTable extends Table {
});
}
// The query type is auto or vector
// fall back to full text search if no embedding functions are defined and the query is a string
if (
queryType === "auto" &&
(!this.hasEmbeddingFunctions || instanceOfFullTextQuery(query))
) {
if (queryType === "auto" && typeof query !== "string") {
return this.query().fullTextSearch(query, {
columns: ftsColumns,
});
}
if (queryType === "auto" && typeof query === "string") {
const vector = this.getEmbeddingFunctions().then(async (functions) => {
// TODO: Support multiple embedding functions
const embeddingFunc: EmbeddingFunctionConfig | undefined = functions
.values()
.next().value;
if (embeddingFunc === undefined) {
return undefined;
}
return await embeddingFunc.function.computeQueryEmbeddings(query);
});
const columns =
typeof ftsColumns === "string" ? [ftsColumns] : ftsColumns;
return Query.autoSearch(this.inner, query, vector, columns);
}
const queryPromise = this.getEmbeddingFunctions().then(
async (functions) => {
// TODO: Support multiple embedding functions
@@ -1482,9 +1473,7 @@ export class Branches {
fromRef?: string,
fromVersion?: number,
): Promise<Table> {
return LocalTable.create(
await this.#inner.create(name, fromRef, fromVersion),
);
return new LocalTable(await this.#inner.create(name, fromRef, fromVersion));
}
/**
@@ -1495,7 +1484,7 @@ export class Branches {
* latest and stays writable.
*/
async checkout(name: string, version?: number): Promise<Table> {
return LocalTable.create(await this.#inner.checkout(name, version));
return new LocalTable(await this.#inner.checkout(name, version));
}
/** Delete a branch. */