mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-18 12:08:35 +00:00
fix(node): refresh metadata before automatic search
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user