mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-18 20:18:37 +00:00
fix(node): defer auto search routing
This commit is contained in:
@@ -2337,6 +2337,60 @@ describe.each([arrow15, arrow16, arrow17, arrow18])(
|
||||
expect(results2[0].text).toBe(data[1].text);
|
||||
});
|
||||
|
||||
test("auto search follows embedding metadata across executions", async () => {
|
||||
@register("refresh-test")
|
||||
class TestEmbedding extends EmbeddingFunction<string> {
|
||||
ndims() {
|
||||
return 1;
|
||||
}
|
||||
embeddingDataType() {
|
||||
return new arrow.Float32();
|
||||
}
|
||||
async computeQueryEmbeddings(value: string) {
|
||||
return value === "greetings" ? [0.1] : [0.2];
|
||||
}
|
||||
async computeSourceEmbeddings(values: string[]) {
|
||||
return values.map((value) =>
|
||||
value === "hello world" ? [0.1] : [0.2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const writer = await connect(tmpDir.name);
|
||||
await writer.createTable("test", [{ text: "plain", vector: [0.0] }]);
|
||||
const reader = await connect(tmpDir.name, {
|
||||
readConsistencyInterval: 0,
|
||||
});
|
||||
const tracked = await reader.openTable("test");
|
||||
const autoQuery = tracked.search("greetings").select(["text"]).limit(1);
|
||||
|
||||
const func = new TestEmbedding();
|
||||
const schema = LanceSchema({
|
||||
text: func.sourceField(new arrow.Utf8()),
|
||||
vector: func.vectorField(),
|
||||
});
|
||||
const data = [{ text: "hello world" }, { text: "goodbye world" }];
|
||||
await writer.createTable("test", data, { mode: "overwrite", schema });
|
||||
|
||||
expect(
|
||||
(await tracked.schema()).metadata.get("embedding_functions"),
|
||||
).toBeDefined();
|
||||
const results = await autoQuery.toArray();
|
||||
expect(results[0].text).toBe(data[0].text);
|
||||
|
||||
const ftsData = [{ text: "greetings from full text", vector: [0.0] }];
|
||||
const ftsTable = await writer.createTable("test", ftsData, {
|
||||
mode: "overwrite",
|
||||
});
|
||||
await ftsTable.createIndex("text", { config: Index.fts() });
|
||||
|
||||
expect(
|
||||
(await tracked.schema()).metadata.get("embedding_functions"),
|
||||
).toBeUndefined();
|
||||
const ftsResults = await autoQuery.toArray();
|
||||
expect(ftsResults[0].text).toBe(ftsData[0].text);
|
||||
});
|
||||
|
||||
test("tokenizes FTS queries by column or index name", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const data = [
|
||||
|
||||
@@ -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 await LocalTable.create(innerTable);
|
||||
return new LocalTable(innerTable);
|
||||
}
|
||||
|
||||
private getStorageOptions(
|
||||
@@ -652,7 +652,7 @@ export class LocalConnection extends Connection {
|
||||
storageOptions,
|
||||
);
|
||||
|
||||
return await LocalTable.create(innerTable);
|
||||
return new LocalTable(innerTable);
|
||||
}
|
||||
|
||||
async createEmptyTable(
|
||||
@@ -698,7 +698,7 @@ export class LocalConnection extends Connection {
|
||||
namespacePath ?? [],
|
||||
storageOptions,
|
||||
);
|
||||
return await 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 await LocalTable.create(nativeTable);
|
||||
return new LocalTable(nativeTable);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+101
-38
@@ -100,6 +100,37 @@ export interface FullTextSearchOptions {
|
||||
columns?: string | string[];
|
||||
}
|
||||
|
||||
type NativeQueryLike = NativeQuery | NativeVectorQuery | NativeTakeQuery;
|
||||
|
||||
class DeferredNativeQuery<NativeQueryType extends NativeQueryLike> {
|
||||
private readonly calls: Array<(inner: NativeQueryType) => void> = [];
|
||||
|
||||
constructor(private readonly factory: () => Promise<NativeQueryType>) {}
|
||||
|
||||
doCall(fn: (inner: NativeQueryType) => void) {
|
||||
this.calls.push(fn);
|
||||
}
|
||||
|
||||
async resolve(): Promise<NativeQueryType> {
|
||||
const inner = await this.factory();
|
||||
for (const call of this.calls) {
|
||||
call(inner);
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
|
||||
function nearestToNative(
|
||||
inner: NativeQuery,
|
||||
vector: Awaited<IntoVector>,
|
||||
): NativeVectorQuery {
|
||||
const raw = Array.isArray(vector) ? null : extractVectorBuffer(vector);
|
||||
if (raw) {
|
||||
return inner.nearestToRaw(raw.data, raw.dtype);
|
||||
}
|
||||
return inner.nearestTo(Float32Array.from(vector as number[]));
|
||||
}
|
||||
|
||||
/** Common methods supported by all query types
|
||||
*
|
||||
* @see {@link Query}
|
||||
@@ -125,7 +156,11 @@ export class QueryBase<
|
||||
* @hidden
|
||||
*/
|
||||
protected doCall(fn: (inner: NativeQueryType) => void) {
|
||||
if (this.inner instanceof Promise) {
|
||||
if ((this.inner as unknown) instanceof DeferredNativeQuery) {
|
||||
const deferred = this
|
||||
.inner as unknown as DeferredNativeQuery<NativeQueryType>;
|
||||
deferred.doCall(fn);
|
||||
} else if (this.inner instanceof Promise) {
|
||||
this.inner = this.inner.then((inner) => {
|
||||
fn(inner);
|
||||
return inner;
|
||||
@@ -135,6 +170,18 @@ export class QueryBase<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @hidden
|
||||
*/
|
||||
protected resolveInner(): NativeQueryType | Promise<NativeQueryType> {
|
||||
if ((this.inner as unknown) instanceof DeferredNativeQuery) {
|
||||
const deferred = this
|
||||
.inner as unknown as DeferredNativeQuery<NativeQueryType>;
|
||||
return deferred.resolve();
|
||||
}
|
||||
return this.inner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only the specified columns.
|
||||
*
|
||||
@@ -210,12 +257,13 @@ export class QueryBase<
|
||||
protected nativeExecute(
|
||||
options?: Partial<QueryExecutionOptions>,
|
||||
): Promise<NativeBatchIterator> {
|
||||
if (this.inner instanceof Promise) {
|
||||
return this.inner.then((inner) =>
|
||||
const inner = this.resolveInner();
|
||||
if (inner instanceof Promise) {
|
||||
return inner.then((inner) =>
|
||||
inner.execute(options?.maxBatchLength, options?.timeoutMs),
|
||||
);
|
||||
} else {
|
||||
return this.inner.execute(options?.maxBatchLength, options?.timeoutMs);
|
||||
return inner.execute(options?.maxBatchLength, options?.timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,12 +293,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.resolveInner();
|
||||
for await (const batch of new RecordBatchIterable(inner, options)) {
|
||||
batches.push(batch);
|
||||
}
|
||||
@@ -279,10 +322,11 @@ 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));
|
||||
const inner = this.resolveInner();
|
||||
if (inner instanceof Promise) {
|
||||
return inner.then((inner) => inner.explainPlan(verbose));
|
||||
} else {
|
||||
return this.inner.explainPlan(verbose);
|
||||
return inner.explainPlan(verbose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,12 +365,11 @@ export class QueryBase<
|
||||
distributedMetrics?: AnalyzePlanDistributedMetrics,
|
||||
): Promise<string> {
|
||||
const distributedMetricsMode = distributedMetrics ?? "aggregate";
|
||||
if (this.inner instanceof Promise) {
|
||||
return this.inner.then((inner) =>
|
||||
inner.analyzePlan(distributedMetricsMode),
|
||||
);
|
||||
const inner = this.resolveInner();
|
||||
if (inner instanceof Promise) {
|
||||
return inner.then((inner) => inner.analyzePlan(distributedMetricsMode));
|
||||
} else {
|
||||
return this.inner.analyzePlan(distributedMetricsMode);
|
||||
return inner.analyzePlan(distributedMetricsMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,10 +383,11 @@ export class QueryBase<
|
||||
*/
|
||||
async outputSchema(): Promise<import("./arrow").Schema> {
|
||||
let schemaBuffer: Buffer;
|
||||
if (this.inner instanceof Promise) {
|
||||
schemaBuffer = await this.inner.then((inner) => inner.outputSchema());
|
||||
const inner = this.resolveInner();
|
||||
if (inner instanceof Promise) {
|
||||
schemaBuffer = await inner.then((inner) => inner.outputSchema());
|
||||
} else {
|
||||
schemaBuffer = await this.inner.outputSchema();
|
||||
schemaBuffer = await inner.outputSchema();
|
||||
}
|
||||
const schema = tableFromIPC(schemaBuffer).schema;
|
||||
return schema;
|
||||
@@ -763,6 +807,37 @@ export class VectorQuery extends StandardQueryBase<NativeVectorQuery> {
|
||||
}
|
||||
}
|
||||
|
||||
class DeferredAutoQuery extends VectorQuery {
|
||||
constructor(factory: () => Promise<NativeQuery | NativeVectorQuery>) {
|
||||
super(
|
||||
new DeferredNativeQuery(factory) as unknown as Promise<NativeVectorQuery>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a string query whose vector/FTS routing is resolved against the active
|
||||
* table schema when the query executes.
|
||||
*
|
||||
* @hidden
|
||||
*/
|
||||
export function createAutoQuery(
|
||||
table: NativeTable,
|
||||
query: string,
|
||||
columns: string[] | null,
|
||||
getVector: () => Promise<Awaited<IntoVector> | undefined>,
|
||||
): VectorQuery {
|
||||
return new DeferredAutoQuery(async () => {
|
||||
const vector = await getVector();
|
||||
const inner = table.query();
|
||||
if (vector === undefined) {
|
||||
inner.fullTextSearch({ query, columns });
|
||||
return inner;
|
||||
}
|
||||
return nearestToNative(inner, vector);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A query that returns a subset of the rows in the table.
|
||||
*
|
||||
@@ -840,23 +915,11 @@ export class Query extends StandardQueryBase<NativeQuery> {
|
||||
* a default `limit` of 10 will be used. @see {@link Query#limit}
|
||||
*/
|
||||
nearestTo(vector: IntoVector): VectorQuery {
|
||||
const callNearestTo = (
|
||||
inner: NativeQuery,
|
||||
resolved: Float32Array | Float64Array | Uint8Array | number[],
|
||||
): 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[]));
|
||||
};
|
||||
|
||||
if (this.inner instanceof Promise) {
|
||||
const nativeQuery = this.inner.then(async (inner) => {
|
||||
const inner = this.resolveInner();
|
||||
if (inner instanceof Promise) {
|
||||
const nativeQuery = inner.then(async (inner) => {
|
||||
const resolved = vector instanceof Promise ? await vector : vector;
|
||||
return callNearestTo(inner, resolved);
|
||||
return nearestToNative(inner, resolved);
|
||||
});
|
||||
return new VectorQuery(nativeQuery);
|
||||
}
|
||||
@@ -876,7 +939,7 @@ export class Query extends StandardQueryBase<NativeQuery> {
|
||||
})();
|
||||
return new VectorQuery(res);
|
||||
} else {
|
||||
const vectorQuery = callNearestTo(this.inner, vector);
|
||||
const vectorQuery = nearestToNative(inner, vector);
|
||||
return new VectorQuery(vectorQuery);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-33
@@ -44,6 +44,7 @@ import {
|
||||
Query,
|
||||
TakeQuery,
|
||||
VectorQuery,
|
||||
createAutoQuery,
|
||||
instanceOfFullTextQuery,
|
||||
} from "./query";
|
||||
import { sanitizeType } from "./sanitize";
|
||||
@@ -814,30 +815,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;
|
||||
}
|
||||
|
||||
static async create(inner: _NativeTable): Promise<LocalTable> {
|
||||
const schemaBuf = await inner.schema();
|
||||
const schema = tableFromIPC(schemaBuf).schema;
|
||||
const serializedFunctions = schema.metadata.get("embedding_functions");
|
||||
let hasEmbeddingFunctions = false;
|
||||
if (serializedFunctions !== undefined) {
|
||||
try {
|
||||
const functions: unknown = JSON.parse(serializedFunctions);
|
||||
hasEmbeddingFunctions =
|
||||
!Array.isArray(functions) || functions.length > 0;
|
||||
} catch {
|
||||
// Let parseFunctions report malformed metadata when the query executes.
|
||||
hasEmbeddingFunctions = true;
|
||||
}
|
||||
}
|
||||
return new LocalTable(inner, hasEmbeddingFunctions);
|
||||
}
|
||||
get name(): string {
|
||||
return this.inner.name;
|
||||
@@ -1054,14 +1035,25 @@ 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))
|
||||
) {
|
||||
return this.query().fullTextSearch(query, {
|
||||
columns: ftsColumns,
|
||||
if (queryType === "auto") {
|
||||
if (instanceOfFullTextQuery(query)) {
|
||||
return this.query().fullTextSearch(query, {
|
||||
columns: ftsColumns,
|
||||
});
|
||||
}
|
||||
|
||||
const columns =
|
||||
typeof ftsColumns === "string" ? [ftsColumns] : (ftsColumns ?? null);
|
||||
return createAutoQuery(this.inner, query, columns, async () => {
|
||||
const functions = await this.getEmbeddingFunctions();
|
||||
// TODO: Support multiple embedding functions
|
||||
const embeddingFunc: EmbeddingFunctionConfig | undefined = functions
|
||||
.values()
|
||||
.next().value;
|
||||
if (!embeddingFunc) {
|
||||
return undefined;
|
||||
}
|
||||
return await embeddingFunc.function.computeQueryEmbeddings(query);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1481,9 +1473,7 @@ export class Branches {
|
||||
fromRef?: string,
|
||||
fromVersion?: number,
|
||||
): Promise<Table> {
|
||||
return await LocalTable.create(
|
||||
await this.#inner.create(name, fromRef, fromVersion),
|
||||
);
|
||||
return new LocalTable(await this.#inner.create(name, fromRef, fromVersion));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1494,7 +1484,7 @@ export class Branches {
|
||||
* latest and stays writable.
|
||||
*/
|
||||
async checkout(name: string, version?: number): Promise<Table> {
|
||||
return await 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