From b5ca84f076ca0668e90ca3fc7dd878f732ccd956 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Thu, 5 Sep 2024 12:58:24 -0600 Subject: [PATCH] feat: Return computed distance and set distance thresholds on VectorQueries (#2090) * Return computed distance and set distance thresholds on VectorQueries --- api-report/firestore.api.md | 170 ++-- dev/src/index.ts | 1 + dev/src/reference/query.ts | 80 +- dev/src/reference/vector-query-options.ts | 59 +- dev/src/reference/vector-query.ts | 55 +- dev/system-test/firestore.ts | 973 ++++++++++++++++------ dev/test/vector-query.ts | 347 +++++++- package.json | 4 +- types/firestore.d.ts | 94 ++- 9 files changed, 1365 insertions(+), 418 deletions(-) diff --git a/api-report/firestore.api.md b/api-report/firestore.api.md index 5f9978015..3a146aef2 100644 --- a/api-report/firestore.api.md +++ b/api-report/firestore.api.md @@ -4,24 +4,23 @@ ```ts -/// - import * as $protobuf from 'protobufjs'; import { DocumentData } from '@google-cloud/firestore'; import { Duplex } from 'stream'; import * as firestore from '@google-cloud/firestore'; import { GoogleError } from 'google-gax'; import { Readable } from 'stream'; +import { Span as Span_2 } from '@opentelemetry/api'; // @public export class Aggregate { - constructor(alias: string, aggregateType: AggregateType, fieldPath?: string | FieldPath | undefined); + constructor(alias: string, aggregateType: AggregateType, fieldPath?: (string | FieldPath) | undefined); // (undocumented) readonly aggregateType: AggregateType; // (undocumented) readonly alias: string; // (undocumented) - readonly fieldPath?: string | FieldPath | undefined; + readonly fieldPath?: (string | FieldPath) | undefined; // Warning: (ae-forgotten-export) The symbol "google" needs to be exported by the entry point index.d.ts // // @internal @@ -920,6 +919,11 @@ class Firestore implements firestore.Firestore { // Warning: (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration toJSON(): object; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration + // Warning: (ae-forgotten-export) The symbol "TraceUtil" needs to be exported by the entry point index.d.ts + // + // @internal + _traceUtil: TraceUtil; + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // // @internal unregisterListener(): void; @@ -1039,10 +1043,14 @@ export class Query, options: { limit: number; distanceMeasure: 'EUCLIDEAN' | 'COSINE' | 'DOT_PRODUCT'; }): VectorQuery; + findNearest(options: VectorQueryOptions): VectorQuery; + // (undocumented) + _findNearest(options: VectorQueryOptions): VectorQuery; // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" // Warning: (tsdoc-undefined-tag) The TSDoc tag "@type" is not defined in this configuration @@ -1449,15 +1457,18 @@ export class Transaction implements firestore.Transaction { // @public export class VectorQuery implements firestore.VectorQuery { // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration - // Warning: (ae-forgotten-export) The symbol "VectorQueryOptions" needs to be exported by the entry point index.d.ts // // @internal - constructor(_query: Query, vectorField: string | firestore.FieldPath, queryVector: firestore.VectorValue | Array, options: VectorQueryOptions); + constructor(_query: Query, _options: VectorQueryOptions); // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // // @internal _createSnapshot(readTime: Timestamp, size: number, docs: () => Array>, changes: () => Array>): VectorQuerySnapshot; + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration + explain(options?: firestore.ExplainOptions): Promise>>; get(): Promise>; + // (undocumented) + _getResponse(explainOptions?: firestore.ExplainOptions): Promise>>; isEqual(other: firestore.VectorQuery): boolean; get query(): Query; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration @@ -1475,7 +1486,17 @@ export class VectorQuery; + vectorField: string | firestore.FieldPath; } // @public @@ -1559,6 +1580,8 @@ export class WriteBatch implements firestore.WriteBatch { // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" delete(documentRef: firestore.DocumentReference, precondition?: firestore.Precondition): WriteBatch; + // (undocumented) + protected readonly _firestore: Firestore; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // // @internal @@ -1609,81 +1632,82 @@ export class WriteResult implements firestore.WriteResult { // Warnings were encountered during analysis: // +// build/src/aggregate.d.ts:48:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // build/src/aggregate.d.ts:49:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/aggregate.d.ts:50:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/bulk-writer.d.ts:50:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:84:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:147:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:154:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:161:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:169:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:184:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:191:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:200:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:218:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:230:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:237:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:488:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/bulk-writer.d.ts:491:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:497:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:504:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:510:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bulk-writer.d.ts:517:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/bundle.d.ts:20:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/filter.d.ts:121:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/filter.d.ts:156:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:49:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:83:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:146:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:153:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:160:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:168:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:183:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:190:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:199:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:217:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:229:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:236:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:487:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/bulk-writer.d.ts:490:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:496:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:503:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:509:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bulk-writer.d.ts:516:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/bundle.d.ts:19:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/filter.d.ts:120:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/filter.d.ts:155:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/index.d.ts:292:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/index.d.ts:312:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/index.d.ts:319:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:334:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:341:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:350:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:358:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:365:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:374:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:857:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:876:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:878:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:880:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:881:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:891:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:893:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:894:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:896:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:897:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/path.d.ts:30:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/path.d.ts:32:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration -// build/src/path.d.ts:120:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/path.d.ts:312:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:340:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:347:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:356:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:364:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:371:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:380:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:864:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:883:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:885:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:887:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:888:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:898:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:900:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:901:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:903:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:904:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/path.d.ts:29:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/path.d.ts:31:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration +// build/src/path.d.ts:119:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/path.d.ts:311:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/rate-limiter.d.ts:13:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference/aggregate-query.d.ts:87:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/aggregate-query.d.ts:85:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/reference/field-filter-internal.d.ts:24:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/reference/field-filter-internal.d.ts:26:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration // build/src/reference/field-order.d.ts:22:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/reference/field-order.d.ts:24:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration -// build/src/reference/query-options.d.ts:28:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference/query.d.ts:393:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration -// build/src/reference/query.d.ts:399:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference/query.d.ts:401:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference/query.d.ts:409:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference/query.d.ts:411:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference/query.d.ts:411:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' -// build/src/reference/query.d.ts:413:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference/query.d.ts:413:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' -// build/src/reference/query.d.ts:415:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference/query.d.ts:417:24 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// build/src/reference/query.d.ts:417:17 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// build/src/reference/query.d.ts:426:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference/query.d.ts:428:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration -// build/src/reference/query.d.ts:430:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference/query.d.ts:616:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference/query.d.ts:617:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference/vector-query.d.ts:52:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference/vector-query.d.ts:57:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/serializer.d.ts:26:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/serializer.d.ts:36:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/write-batch.d.ts:86:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/write-batch.d.ts:109:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/query-options.d.ts:27:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/query.d.ts:425:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration +// build/src/reference/query.d.ts:431:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/query.d.ts:433:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference/query.d.ts:441:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/query.d.ts:443:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference/query.d.ts:443:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' +// build/src/reference/query.d.ts:445:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference/query.d.ts:445:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' +// build/src/reference/query.d.ts:447:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference/query.d.ts:449:24 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// build/src/reference/query.d.ts:449:17 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// build/src/reference/query.d.ts:458:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference/query.d.ts:460:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration +// build/src/reference/query.d.ts:462:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/query.d.ts:648:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference/query.d.ts:649:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/vector-query.d.ts:50:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/vector-query.d.ts:55:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/vector-query.d.ts:60:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/serializer.d.ts:25:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/serializer.d.ts:35:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/write-batch.d.ts:85:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/write-batch.d.ts:108:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // (No @packageDocumentation comment for this package) diff --git a/dev/src/index.ts b/dev/src/index.ts index a55aa96d5..0b2f195af 100644 --- a/dev/src/index.ts +++ b/dev/src/index.ts @@ -103,6 +103,7 @@ export type {AggregateQuery} from './reference/aggregate-query'; export type {AggregateQuerySnapshot} from './reference/aggregate-query-snapshot'; export type {VectorQuery} from './reference/vector-query'; export type {VectorQuerySnapshot} from './reference/vector-query-snapshot'; +export type {VectorQueryOptions} from './reference/vector-query-options'; export {BulkWriter} from './bulk-writer'; export type {BulkWriterError} from './bulk-writer'; export type {BundleBuilder} from './bundle'; diff --git a/dev/src/reference/query.ts b/dev/src/reference/query.ts index e2811de3b..558acfe86 100644 --- a/dev/src/reference/query.ts +++ b/dev/src/reference/query.ts @@ -629,6 +629,9 @@ export class Query< * @param options - Options control the vector query. `limit` specifies the upper bound of documents to return, must * be a positive integer with a maximum value of 1000. `distanceMeasure` specifies what type of distance is calculated * when performing the query. + * + * @deprecated Use the new {@link findNearest} implementation + * accepting a single `options` param. */ findNearest( vectorField: string | firestore.FieldPath, @@ -637,17 +640,77 @@ export class Query< limit: number; distanceMeasure: 'EUCLIDEAN' | 'COSINE' | 'DOT_PRODUCT'; } + ): VectorQuery; + + /** + * Returns a query that can perform vector distance (similarity) search with given parameters. + * + * The returned query, when executed, performs a distance (similarity) search on the specified + * `vectorField` against the given `queryVector` and returns the top documents that are closest + * to the `queryVector`. + * + * Only documents whose `vectorField` field is a {@link VectorValue} of the same dimension as `queryVector` + * participate in the query, all other documents are ignored. + * + * @example + * ``` + * // Returns the closest 10 documents whose Euclidean distance from their 'embedding' fields are closed to [41, 42]. + * const vectorQuery = col.findNearest({ + * vectorField: 'embedding', + * queryVector: [41, 42], + * limit: 10, + * distanceMeasure: 'EUCLIDEAN', + * distanceResultField: 'distance', + * distanceThreshold: 0.125 + * }); + * + * const querySnapshot = await aggregateQuery.get(); + * querySnapshot.forEach(...); + * ``` + * @param options - An argument specifying the behavior of the {@link VectorQuery} returned by this function. + * See {@link VectorQueryOptions}. + */ + findNearest( + options: VectorQueryOptions + ): VectorQuery; + + findNearest( + vectorFieldOrOptions: string | firestore.FieldPath | VectorQueryOptions, + queryVector?: firestore.VectorValue | Array, + options?: { + limit?: number; + distanceMeasure?: 'EUCLIDEAN' | 'COSINE' | 'DOT_PRODUCT'; + } ): VectorQuery { - validateFieldPath('vectorField', vectorField); + if ( + typeof vectorFieldOrOptions === 'string' || + vectorFieldOrOptions instanceof FieldPath + ) { + const vqOptions: VectorQueryOptions = { + distanceMeasure: options!.distanceMeasure!, + limit: options!.limit!, + queryVector: queryVector!, + vectorField: vectorFieldOrOptions, + }; + return this._findNearest(vqOptions); + } else { + return this._findNearest(vectorFieldOrOptions as VectorQueryOptions); + } + } + + _findNearest( + options: VectorQueryOptions + ): VectorQuery { + validateFieldPath('vectorField', options.vectorField); if (options.limit <= 0) { - throw invalidArgumentMessage('options.limit', 'positive limit number'); + throw invalidArgumentMessage('limit', 'positive limit number'); } if ( - (Array.isArray(queryVector) - ? queryVector.length - : queryVector.toArray().length) === 0 + (Array.isArray(options.queryVector) + ? options.queryVector.length + : options.queryVector.toArray().length) === 0 ) { throw invalidArgumentMessage( 'queryVector', @@ -655,12 +718,7 @@ export class Query< ); } - return new VectorQuery( - this, - vectorField, - queryVector, - new VectorQueryOptions(options.limit, options.distanceMeasure) - ); + return new VectorQuery(this, options); } /** diff --git a/dev/src/reference/vector-query-options.ts b/dev/src/reference/vector-query-options.ts index cc083aa62..10a851a96 100644 --- a/dev/src/reference/vector-query-options.ts +++ b/dev/src/reference/vector-query-options.ts @@ -14,23 +14,48 @@ * limitations under the License. */ -export class VectorQueryOptions { - constructor( - readonly limit: number, - readonly distanceMeasure: 'EUCLIDEAN' | 'COSINE' | 'DOT_PRODUCT' - ) {} +import * as firestore from '@google-cloud/firestore'; - isEqual(other: VectorQueryOptions): boolean { - if (this === other) { - return true; - } - if (!(other instanceof VectorQueryOptions)) { - return false; - } +/** + * Specifies the behavior of the {@link VectorQuery} generated by a call to {@link Query.findNearest}. + */ +export interface VectorQueryOptions { + /** + * A string or {@link FieldPath} specifying the vector field to search on. + */ + vectorField: string | firestore.FieldPath; + + /** + * The {@link VectorValue} used to measure the distance from `vectorField` values in the documents. + */ + queryVector: firestore.VectorValue | Array; + + /** + * Specifies the upper bound of documents to return, must be a positive integer with a maximum value of 1000. + */ + limit: number; + + /** + * Specifies what type of distance is calculated when performing the query. + */ + distanceMeasure: 'EUCLIDEAN' | 'COSINE' | 'DOT_PRODUCT'; + + /** + * Optionally specifies the name of a field that will be set on each returned DocumentSnapshot, + * which will contain the computed distance for the document. + */ + distanceResultField?: string | firestore.FieldPath; - return ( - this.limit === other.limit && - this.distanceMeasure === other.distanceMeasure - ); - } + /** + * Specifies a threshold for which no less similar documents will be returned. The behavior + * of the specified `distanceMeasure` will affect the meaning of the distance threshold. + * + * - For `distanceMeasure: "EUCLIDEAN"`, the meaning of `distanceThreshold` is: + * SELECT docs WHERE euclidean_distance <= distanceThreshold + * - For `distanceMeasure: "COSINE"`, the meaning of `distanceThreshold` is: + * SELECT docs WHERE cosine_distance <= distanceThreshold + * - For `distanceMeasure: "DOT_PRODUCT"`, the meaning of `distanceThreshold` is: + * SELECT docs WHERE dot_product_distance >= distanceThreshold + */ + distanceThreshold?: number; } diff --git a/dev/src/reference/vector-query.ts b/dev/src/reference/vector-query.ts index 4df93e60f..3fe36194f 100644 --- a/dev/src/reference/vector-query.ts +++ b/dev/src/reference/vector-query.ts @@ -56,9 +56,7 @@ export class VectorQuery< */ constructor( private readonly _query: Query, - private readonly vectorField: string | firestore.FieldPath, - private readonly queryVector: firestore.VectorValue | Array, - private readonly options: VectorQueryOptions + private readonly _options: VectorQueryOptions ) { this._queryUtil = new QueryUtil< AppModelType, @@ -79,9 +77,21 @@ export class VectorQuery< * @internal */ private get _rawVectorField(): string { - return typeof this.vectorField === 'string' - ? this.vectorField - : this.vectorField.toString(); + return typeof this._options.vectorField === 'string' + ? this._options.vectorField + : this._options.vectorField.toString(); + } + + /** + * @private + * @internal + */ + private get _rawDistanceResultField(): string | undefined { + if (typeof this._options.distanceResultField === 'undefined') return; + + return typeof this._options.distanceResultField === 'string' + ? this._options.distanceResultField + : this._options.distanceResultField.toString(); } /** @@ -89,9 +99,9 @@ export class VectorQuery< * @internal */ private get _rawQueryVector(): Array { - return Array.isArray(this.queryVector) - ? this.queryVector - : this.queryVector.toArray(); + return Array.isArray(this._options.queryVector) + ? this._options.queryVector + : this._options.queryVector.toArray(); } /** @@ -157,7 +167,7 @@ export class VectorQuery< } /** - * Internal method for serializing a query to its RunAggregationQuery proto + * Internal method for serializing a query to its proto * representation with an optional transaction id. * * @private @@ -170,17 +180,25 @@ export class VectorQuery< ): api.IRunQueryRequest { const queryProto = this._query.toProto(transactionOrReadTime); - const queryVector = Array.isArray(this.queryVector) - ? new VectorValue(this.queryVector) - : (this.queryVector as VectorValue); + const queryVector = Array.isArray(this._options.queryVector) + ? new VectorValue(this._options.queryVector) + : (this._options.queryVector as VectorValue); queryProto.structuredQuery!.findNearest = { - limit: {value: this.options.limit}, - distanceMeasure: this.options.distanceMeasure, + limit: {value: this._options.limit}, + distanceMeasure: this._options.distanceMeasure, vectorField: { - fieldPath: FieldPath.fromArgument(this.vectorField).formattedName, + fieldPath: FieldPath.fromArgument(this._options.vectorField) + .formattedName, }, queryVector: queryVector._toProto(this._query._serializer), + distanceResultField: this._options?.distanceResultField + ? FieldPath.fromArgument(this._options.distanceResultField!) + .formattedName + : undefined, + distanceThreshold: this._options?.distanceThreshold + ? {value: this._options?.distanceThreshold} + : undefined, }; if (explainOptions) { @@ -253,7 +271,10 @@ export class VectorQuery< return ( this._rawVectorField === other._rawVectorField && isPrimitiveArrayEqual(this._rawQueryVector, other._rawQueryVector) && - this.options.isEqual(other.options) + this._options.limit === other._options.limit && + this._options.distanceMeasure === other._options.distanceMeasure && + this._options.distanceThreshold === other._options.distanceThreshold && + this._rawDistanceResultField === other._rawDistanceResultField ); } } diff --git a/dev/system-test/firestore.ts b/dev/system-test/firestore.ts index 5f7d683d7..f0299b13b 100644 --- a/dev/system-test/firestore.ts +++ b/dev/system-test/firestore.ts @@ -443,7 +443,9 @@ describe('Firestore class', () => { const explainResults = await indexTestHelper .query(collectionReference) - .findNearest('embedding', FieldValue.vector([1, 3]), { + .findNearest({ + vectorField: 'embedding', + queryVector: FieldValue.vector([1, 3]), limit: 10, distanceMeasure: 'COSINE', }) @@ -473,7 +475,9 @@ describe('Firestore class', () => { const explainResults = await indexTestHelper .query(collectionReference) - .findNearest('embedding', FieldValue.vector([1, 3]), { + .findNearest({ + vectorField: 'embedding', + queryVector: FieldValue.vector([1, 3]), limit: 10, distanceMeasure: 'COSINE', }) @@ -1966,326 +1970,785 @@ describe('Query class', () => { }); }); - it('supports findNearest by EUCLIDEAN distance', async () => { - const indexTestHelper = new IndexTestHelper(firestore); + describe('vector search', () => { + it('supports findNearest by EUCLIDEAN distance', async () => { + const indexTestHelper = new IndexTestHelper(firestore); - const collectionReference = await indexTestHelper.createTestDocs([ - {foo: 'bar'}, - {foo: 'xxx', embedding: FieldValue.vector([10, 10])}, - {foo: 'bar', embedding: FieldValue.vector([1, 1])}, - {foo: 'bar', embedding: FieldValue.vector([10, 0])}, - {foo: 'bar', embedding: FieldValue.vector([20, 0])}, - {foo: 'bar', embedding: FieldValue.vector([100, 100])}, - ]); + const collectionReference = await indexTestHelper.createTestDocs([ + {foo: 'bar'}, + {foo: 'xxx', embedding: FieldValue.vector([10, 10])}, + {foo: 'bar', embedding: FieldValue.vector([1, 1])}, + {foo: 'bar', embedding: FieldValue.vector([10, 0])}, + {foo: 'bar', embedding: FieldValue.vector([20, 0])}, + {foo: 'bar', embedding: FieldValue.vector([100, 100])}, + ]); - const vectorQuery = indexTestHelper - .query(collectionReference) - .where('foo', '==', 'bar') - .findNearest('embedding', [10, 10], { - limit: 3, - distanceMeasure: 'EUCLIDEAN', + const vectorQuery = indexTestHelper + .query(collectionReference) + .where('foo', '==', 'bar') + .findNearest({ + vectorField: 'embedding', + queryVector: [10, 10], + limit: 3, + distanceMeasure: 'EUCLIDEAN', + }); + + const res = await vectorQuery.get(); + expect(res.size).to.equal(3); + expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([10, 0]))) + .to.be.true; + expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([1, 1]))).to + .be.true; + expect(res.docs[2].get('embedding').isEqual(FieldValue.vector([20, 0]))) + .to.be.true; + }); + + it('supports findNearest by COSINE distance', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const collectionReference = await indexTestHelper.setTestDocs({ + '1': {foo: 'bar'}, + '2': {foo: 'xxx', embedding: FieldValue.vector([10, 10])}, + '3': {foo: 'bar', embedding: FieldValue.vector([1, 1])}, + '4': {foo: 'bar', embedding: FieldValue.vector([20, 0])}, + '5': {foo: 'bar', embedding: FieldValue.vector([10, 0])}, + '6': {foo: 'bar', embedding: FieldValue.vector([100, 100])}, }); - const res = await vectorQuery.get(); - expect(res.size).to.equal(3); - expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([10, 0]))).to - .be.true; - expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([1, 1]))).to - .be.true; - expect(res.docs[2].get('embedding').isEqual(FieldValue.vector([20, 0]))).to - .be.true; - }); + const vectorQuery = indexTestHelper + .query(collectionReference) + .where('foo', '==', 'bar') + .findNearest({ + vectorField: 'embedding', + queryVector: [10, 10], + limit: 3, + distanceMeasure: 'COSINE', + }); - it('supports findNearest by COSINE distance', async () => { - const indexTestHelper = new IndexTestHelper(firestore); + const res = await vectorQuery.get(); + + expect(res.size).to.equal(3); - const collectionReference = await indexTestHelper.setTestDocs({ - '1': {foo: 'bar'}, - '2': {foo: 'xxx', embedding: FieldValue.vector([10, 10])}, - '3': {foo: 'bar', embedding: FieldValue.vector([1, 1])}, - '4': {foo: 'bar', embedding: FieldValue.vector([20, 0])}, - '5': {foo: 'bar', embedding: FieldValue.vector([10, 0])}, - '6': {foo: 'bar', embedding: FieldValue.vector([100, 100])}, + if (res.docs[0].get('embedding').isEqual(FieldValue.vector([1, 1]))) { + expect( + res.docs[1].get('embedding').isEqual(FieldValue.vector([100, 100])) + ).to.be.true; + } else { + expect( + res.docs[0].get('embedding').isEqual(FieldValue.vector([100, 100])) + ).to.be.true; + expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([1, 1]))) + .to.be.true; + } + + expect( + res.docs[2].get('embedding').isEqual(FieldValue.vector([20, 0])) || + res.docs[2].get('embedding').isEqual(FieldValue.vector([20, 0])) + ).to.be.true; }); - const vectorQuery = indexTestHelper - .query(collectionReference) - .where('foo', '==', 'bar') - .findNearest('embedding', [10, 10], { - limit: 3, - distanceMeasure: 'COSINE', - }); + it('supports findNearest by DOT_PRODUCT distance', async () => { + const indexTestHelper = new IndexTestHelper(firestore); - const res = await vectorQuery.get(); + const collectionReference = await indexTestHelper.createTestDocs([ + {foo: 'bar'}, + {foo: 'xxx', embedding: FieldValue.vector([10, 10])}, + {foo: 'bar', embedding: FieldValue.vector([1, 1])}, + {foo: 'bar', embedding: FieldValue.vector([10, 0])}, + {foo: 'bar', embedding: FieldValue.vector([20, 0])}, + {foo: 'bar', embedding: FieldValue.vector([100, 100])}, + ]); - expect(res.size).to.equal(3); + const vectorQuery = indexTestHelper + .query(collectionReference) + .where('foo', '==', 'bar') + .findNearest({ + vectorField: 'embedding', + queryVector: [10, 10], + limit: 3, + distanceMeasure: 'DOT_PRODUCT', + }); - if (res.docs[0].get('embedding').isEqual(FieldValue.vector([1, 1]))) { + const res = await vectorQuery.get(); + expect(res.size).to.equal(3); expect( - res.docs[1].get('embedding').isEqual(FieldValue.vector([100, 100])) + res.docs[0].get('embedding').isEqual(FieldValue.vector([100, 100])) ).to.be.true; - } else { + expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([20, 0]))) + .to.be.true; + expect(res.docs[2].get('embedding').isEqual(FieldValue.vector([10, 0]))) + .to.be.true; + }); + + it('findNearest works with converters', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + class FooDistance { + constructor( + readonly foo: string, + readonly embedding: Array + ) {} + } + + const fooConverter = { + toFirestore(d: FooDistance): DocumentData { + return {title: d.foo, embedding: FieldValue.vector(d.embedding)}; + }, + fromFirestore(snapshot: QueryDocumentSnapshot): FooDistance { + const data = snapshot.data(); + return new FooDistance(data.foo, data.embedding.toArray()); + }, + }; + + const collectionRef = await indexTestHelper.createTestDocs([ + {foo: 'bar', embedding: FieldValue.vector([5, 5])}, + ]); + + const vectorQuery = indexTestHelper + .query(collectionRef) + .withConverter(fooConverter) + .where('foo', '==', 'bar') + .findNearest({ + vectorField: 'embedding', + queryVector: [10, 10], + limit: 3, + distanceMeasure: 'EUCLIDEAN', + }); + + const res = await vectorQuery.get(); + + expect(res.size).to.equal(1); + expect(res.docs[0].data().foo).to.equal('bar'); + expect(res.docs[0].data().embedding).to.deep.equal([5, 5]); + }); + + it('supports findNearest skipping fields of wrong types', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const collectionRef = await indexTestHelper.createTestDocs([ + {foo: 'bar'}, + + // These documents are skipped because it is not really a vector value + {foo: 'bar', embedding: [10, 10]}, + {foo: 'bar', embedding: 'not actually a vector'}, + {foo: 'bar', embedding: null}, + + // Actual vector values + {foo: 'bar', embedding: FieldValue.vector([9, 9])}, + {foo: 'bar', embedding: FieldValue.vector([50, 50])}, + {foo: 'bar', embedding: FieldValue.vector([100, 100])}, + ]); + + const vectorQuery = indexTestHelper + .query(collectionRef) + .where('foo', '==', 'bar') + .findNearest({ + vectorField: 'embedding', + queryVector: [10, 10], + limit: 100, // Intentionally large to get all matches. + distanceMeasure: 'EUCLIDEAN', + }); + + const res = await vectorQuery.get(); + expect(res.size).to.equal(3); + expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([9, 9]))).to + .be.true; + expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([50, 50]))) + .to.be.true; expect( - res.docs[0].get('embedding').isEqual(FieldValue.vector([100, 100])) + res.docs[2].get('embedding').isEqual(FieldValue.vector([100, 100])) ).to.be.true; - expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([1, 1]))).to + }); + + it('findNearest ignores mismatching dimensions', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const collectionRef = await indexTestHelper.createTestDocs([ + {foo: 'bar'}, + + // Vectors with dimension mismatch + {foo: 'bar', embedding: FieldValue.vector([10])}, + + // Vectors with dimension match + {foo: 'bar', embedding: FieldValue.vector([9, 9])}, + {foo: 'bar', embedding: FieldValue.vector([50, 50])}, + ]); + + const vectorQuery = indexTestHelper + .query(collectionRef) + .where('foo', '==', 'bar') + .findNearest({ + vectorField: 'embedding', + queryVector: [10, 10], + limit: 3, + distanceMeasure: 'EUCLIDEAN', + }); + + const res = await vectorQuery.get(); + expect(res.size).to.equal(2); + expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([9, 9]))).to .be.true; - } + expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([50, 50]))) + .to.be.true; + }); - expect( - res.docs[2].get('embedding').isEqual(FieldValue.vector([20, 0])) || - res.docs[2].get('embedding').isEqual(FieldValue.vector([20, 0])) - ).to.be.true; - }); + it('supports findNearest on non-existent field', async () => { + const indexTestHelper = new IndexTestHelper(firestore); - it('supports findNearest by DOT_PRODUCT distance', async () => { - const indexTestHelper = new IndexTestHelper(firestore); + const collectionRef = await indexTestHelper.createTestDocs([ + {foo: 'bar'}, + {foo: 'bar', otherField: [10, 10]}, + {foo: 'bar', otherField: 'not actually a vector'}, + {foo: 'bar', otherField: null}, + ]); - const collectionReference = await indexTestHelper.createTestDocs([ - {foo: 'bar'}, - {foo: 'xxx', embedding: FieldValue.vector([10, 10])}, - {foo: 'bar', embedding: FieldValue.vector([1, 1])}, - {foo: 'bar', embedding: FieldValue.vector([10, 0])}, - {foo: 'bar', embedding: FieldValue.vector([20, 0])}, - {foo: 'bar', embedding: FieldValue.vector([100, 100])}, - ]); + const vectorQuery = indexTestHelper + .query(collectionRef) + .where('foo', '==', 'bar') + .findNearest({ + vectorField: 'embedding', + queryVector: [10, 10], + limit: 3, + distanceMeasure: 'EUCLIDEAN', + }); - const vectorQuery = indexTestHelper - .query(collectionReference) - .where('foo', '==', 'bar') - .findNearest('embedding', [10, 10], { - limit: 3, - distanceMeasure: 'DOT_PRODUCT', + const res = await vectorQuery.get(); + + expect(res.size).to.equal(0); + }); + + it('supports findNearest on vector nested in a map', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const collectionReference = await indexTestHelper.createTestDocs([ + {nested: {foo: 'bar'}}, + {nested: {foo: 'xxx', embedding: FieldValue.vector([10, 10])}}, + {nested: {foo: 'bar', embedding: FieldValue.vector([1, 1])}}, + {nested: {foo: 'bar', embedding: FieldValue.vector([10, 0])}}, + {nested: {foo: 'bar', embedding: FieldValue.vector([20, 0])}}, + {nested: {foo: 'bar', embedding: FieldValue.vector([100, 100])}}, + ]); + + const vectorQuery = indexTestHelper + .query(collectionReference) + .findNearest({ + vectorField: 'nested.embedding', + queryVector: [10, 10], + limit: 3, + distanceMeasure: 'EUCLIDEAN', + }); + + const res = await vectorQuery.get(); + expect(res.size).to.equal(3); + expect( + res.docs[0].get('nested.embedding').isEqual(FieldValue.vector([10, 10])) + ).to.be.true; + expect( + res.docs[1].get('nested.embedding').isEqual(FieldValue.vector([10, 0])) + ).to.be.true; + expect( + res.docs[2].get('nested.embedding').isEqual(FieldValue.vector([1, 1])) + ).to.be.true; + }); + + it('supports findNearest with select to exclude vector data in response', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const collectionReference = await indexTestHelper.createTestDocs([ + {foo: 1}, + {foo: 2, embedding: FieldValue.vector([10, 10])}, + {foo: 3, embedding: FieldValue.vector([1, 1])}, + {foo: 4, embedding: FieldValue.vector([10, 0])}, + {foo: 5, embedding: FieldValue.vector([20, 0])}, + {foo: 6, embedding: FieldValue.vector([100, 100])}, + ]); + + const vectorQuery = indexTestHelper + .query(collectionReference) + .where('foo', 'in', [1, 2, 3, 4, 5, 6]) + .select('foo') + .findNearest({ + vectorField: 'embedding', + queryVector: [10, 10], + limit: 10, + distanceMeasure: 'EUCLIDEAN', + }); + + const res = await vectorQuery.get(); + expect(res.size).to.equal(5); + expect(res.docs[0].get('foo')).to.equal(2); + expect(res.docs[1].get('foo')).to.equal(4); + expect(res.docs[2].get('foo')).to.equal(3); + expect(res.docs[3].get('foo')).to.equal(5); + expect(res.docs[4].get('foo')).to.equal(6); + + res.docs.forEach(ds => expect(ds.get('embedding')).to.be.undefined); + }); + + it('supports findNearest limits', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const embeddingVector = []; + const queryVector = []; + for (let i = 0; i < 2048; i++) { + embeddingVector.push(i + 1); + queryVector.push(i - 1); + } + + const collectionReference = await indexTestHelper.createTestDocs([ + {embedding: FieldValue.vector(embeddingVector)}, + ]); + + const vectorQuery = indexTestHelper + .query(collectionReference) + .findNearest({ + vectorField: 'embedding', + queryVector: queryVector, + limit: 1000, + distanceMeasure: 'EUCLIDEAN', + }); + + const res = await vectorQuery.get(); + expect(res.size).to.equal(1); + expect( + (res.docs[0].get('embedding') as VectorValue).toArray() + ).to.deep.equal(embeddingVector); + }); + + describe('preview API (deprecated)', () => { + it('supports findNearest with EUCLIDEAN', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const collectionReference = await indexTestHelper.createTestDocs([ + {foo: 'bar'}, + {foo: 'bar', embedding: FieldValue.vector([10, 10])}, + {foo: 'bar', embedding: FieldValue.vector([1, 1.1])}, + {foo: 'x', embedding: FieldValue.vector([1, 1])}, + {foo: 'bar', embedding: FieldValue.vector([10, 0])}, + {foo: 'bar', embedding: FieldValue.vector([-100, -100])}, + ]); + + const vectorQuery = indexTestHelper + .query(collectionReference) + .where('foo', '==', 'bar') + .findNearest('embedding', [1, 1], { + limit: 3, + distanceMeasure: 'EUCLIDEAN', + }); + + const res = await vectorQuery.get(); + expect(res.size).to.equal(3); + expect( + res.docs[0].get('embedding').isEqual(FieldValue.vector([1, 1.1])) + ).to.be.true; + expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([10, 0]))) + .to.be.true; + expect( + res.docs[2].get('embedding').isEqual(FieldValue.vector([10, 10])) + ).to.be.true; }); - const res = await vectorQuery.get(); - expect(res.size).to.equal(3); - expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([100, 100]))) - .to.be.true; - expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([20, 0]))).to - .be.true; - expect(res.docs[2].get('embedding').isEqual(FieldValue.vector([10, 0]))).to - .be.true; - }); + it('supports findNearest with COSINE', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const collectionReference = await indexTestHelper.createTestDocs([ + {foo: 'bar'}, + {foo: 'bar', embedding: FieldValue.vector([10, 10])}, + {foo: 'bar', embedding: FieldValue.vector([1, 1.1])}, + {foo: 'x', embedding: FieldValue.vector([1, 1])}, + {foo: 'bar', embedding: FieldValue.vector([10, 0])}, + {foo: 'bar', embedding: FieldValue.vector([-100, -100])}, + ]); + + const vectorQuery = indexTestHelper + .query(collectionReference) + .where('foo', '==', 'bar') + .findNearest('embedding', [1, 1], { + limit: 3, + distanceMeasure: 'COSINE', + }); - it('findNearest works with converters', async () => { - const indexTestHelper = new IndexTestHelper(firestore); + const res = await vectorQuery.get(); + expect(res.size).to.equal(3); + expect( + res.docs[0].get('embedding').isEqual(FieldValue.vector([10, 10])) + ).to.be.true; + expect( + res.docs[1].get('embedding').isEqual(FieldValue.vector([1, 1.1])) + ).to.be.true; + expect(res.docs[2].get('embedding').isEqual(FieldValue.vector([10, 0]))) + .to.be.true; + }); + }); - class FooDistance { - constructor( - readonly foo: string, - readonly embedding: Array - ) {} - } + describe('requesting computed distance', () => { + it('supports COSINE distance', async () => { + const indexTestHelper = new IndexTestHelper(firestore); - const fooConverter = { - toFirestore(d: FooDistance): DocumentData { - return {title: d.foo, embedding: FieldValue.vector(d.embedding)}; - }, - fromFirestore(snapshot: QueryDocumentSnapshot): FooDistance { - const data = snapshot.data(); - return new FooDistance(data.foo, data.embedding.toArray()); - }, - }; + const collectionReference = await indexTestHelper.setTestDocs({ + '1': {foo: 'bar'}, + '2': {foo: 'bar', embedding: FieldValue.vector([1, 0])}, + '3': {foo: 'bar', embedding: FieldValue.vector([0, 1])}, + '4': {foo: 'bar', embedding: FieldValue.vector([0, -0.1])}, + '5': {foo: 'bar', embedding: FieldValue.vector([-1, 0])}, + }); - const collectionRef = await indexTestHelper.createTestDocs([ - {foo: 'bar', embedding: FieldValue.vector([5, 5])}, - ]); + const vectorQuery = indexTestHelper + .query(collectionReference) + .findNearest({ + vectorField: 'embedding', + queryVector: [1, 0], + limit: 5, + distanceMeasure: 'COSINE', + distanceResultField: 'distance', + }); + + const res = await vectorQuery.get(); - const vectorQuery = indexTestHelper - .query(collectionRef) - .withConverter(fooConverter) - .where('foo', '==', 'bar') - .findNearest('embedding', [10, 10], { - limit: 3, - distanceMeasure: 'EUCLIDEAN', + expect(res.size).to.equal(4); + + expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([1, 0]))) + .to.be.true; + expect(res.docs[0].get('distance')).to.equal(0); + + expect(res.docs[1].get('distance')).to.equal(1); + expect(res.docs[2].get('distance')).to.equal(1); + + expect(res.docs[3].get('embedding').isEqual(FieldValue.vector([-1, 0]))) + .to.be.true; + expect(res.docs[3].get('distance')).to.equal(2); }); - const res = await vectorQuery.get(); + it('supports EUCLIDEAN distance', async () => { + const indexTestHelper = new IndexTestHelper(firestore); - expect(res.size).to.equal(1); - expect(res.docs[0].data().foo).to.equal('bar'); - expect(res.docs[0].data().embedding).to.deep.equal([5, 5]); - }); + const collectionReference = await indexTestHelper.setTestDocs({ + '1': {foo: 'bar'}, + '2': {foo: 'bar', embedding: FieldValue.vector([2, 0])}, + '3': {foo: 'bar', embedding: FieldValue.vector([1, 100])}, + '4': {foo: 'bar', embedding: FieldValue.vector([1, -0.1])}, + '5': {foo: 'bar', embedding: FieldValue.vector([4, 4])}, + }); - it('supports findNearest skipping fields of wrong types', async () => { - const indexTestHelper = new IndexTestHelper(firestore); + const vectorQuery = indexTestHelper + .query(collectionReference) + .findNearest({ + vectorField: 'embedding', + queryVector: [1, 0], + limit: 5, + distanceMeasure: 'EUCLIDEAN', + distanceResultField: 'distance', + }); - const collectionRef = await indexTestHelper.createTestDocs([ - {foo: 'bar'}, + const res = await vectorQuery.get(); - // These documents are skipped because it is not really a vector value - {foo: 'bar', embedding: [10, 10]}, - {foo: 'bar', embedding: 'not actually a vector'}, - {foo: 'bar', embedding: null}, + expect(res.size).to.equal(4); - // Actual vector values - {foo: 'bar', embedding: FieldValue.vector([9, 9])}, - {foo: 'bar', embedding: FieldValue.vector([50, 50])}, - {foo: 'bar', embedding: FieldValue.vector([100, 100])}, - ]); + expect( + res.docs[0].get('embedding').isEqual(FieldValue.vector([1, -0.1])) + ).to.be.true; + expect(res.docs[0].get('distance')).to.equal(0.1); + + expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([2, 0]))) + .to.be.true; + expect(res.docs[1].get('distance')).to.equal(1); - const vectorQuery = indexTestHelper - .query(collectionRef) - .where('foo', '==', 'bar') - .findNearest('embedding', [10, 10], { - limit: 100, // Intentionally large to get all matches. - distanceMeasure: 'EUCLIDEAN', + expect(res.docs[2].get('embedding').isEqual(FieldValue.vector([4, 4]))) + .to.be.true; + expect(res.docs[2].get('distance')).to.equal(5); + + expect( + res.docs[3].get('embedding').isEqual(FieldValue.vector([1, 100])) + ).to.be.true; + expect(res.docs[3].get('distance')).to.equal(100); }); - const res = await vectorQuery.get(); - expect(res.size).to.equal(3); - expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([9, 9]))).to - .be.true; - expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([50, 50]))).to - .be.true; - expect(res.docs[2].get('embedding').isEqual(FieldValue.vector([100, 100]))) - .to.be.true; - }); + it('supports DOT_PRODUCT distance', async () => { + const indexTestHelper = new IndexTestHelper(firestore); - it('findNearest ignores mismatching dimensions', async () => { - const indexTestHelper = new IndexTestHelper(firestore); + const collectionReference = await indexTestHelper.setTestDocs({ + '1': {foo: 'bar'}, + '2': {foo: 'bar', embedding: FieldValue.vector([2, 0])}, + '3': {foo: 'bar', embedding: FieldValue.vector([1, 100])}, + '4': {foo: 'bar', embedding: FieldValue.vector([-20, 0])}, + '5': {foo: 'bar', embedding: FieldValue.vector([0.1, 4])}, + }); - const collectionRef = await indexTestHelper.createTestDocs([ - {foo: 'bar'}, + const vectorQuery = indexTestHelper + .query(collectionReference) + .findNearest({ + vectorField: 'embedding', + queryVector: [1, 0], + limit: 5, + distanceMeasure: 'DOT_PRODUCT', + distanceResultField: 'distance', + }); - // Vectors with dimension mismatch - {foo: 'bar', embedding: FieldValue.vector([10])}, + const res = await vectorQuery.get(); - // Vectors with dimension match - {foo: 'bar', embedding: FieldValue.vector([9, 9])}, - {foo: 'bar', embedding: FieldValue.vector([50, 50])}, - ]); + expect(res.size).to.equal(4); + + expect(res.docs[0].get('distance')).to.equal(2); + expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([2, 0]))) + .to.be.true; + + expect(res.docs[1].get('distance')).to.equal(1); + expect( + res.docs[1].get('embedding').isEqual(FieldValue.vector([1, 100])) + ).to.be.true; + + expect(res.docs[2].get('distance')).to.equal(0.1); + expect( + res.docs[2].get('embedding').isEqual(FieldValue.vector([0.1, 4])) + ).to.be.true; - const vectorQuery = indexTestHelper - .query(collectionRef) - .where('foo', '==', 'bar') - .findNearest('embedding', [10, 10], { - limit: 3, - distanceMeasure: 'EUCLIDEAN', + expect(res.docs[3].get('distance')).to.equal(-20); + expect( + res.docs[3].get('embedding').isEqual(FieldValue.vector([-20, 0])) + ).to.be.true; }); - const res = await vectorQuery.get(); - expect(res.size).to.equal(2); - expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([9, 9]))).to - .be.true; - expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([50, 50]))).to - .be.true; - }); + it('overwrites distance result field on conflict', async () => { + const indexTestHelper = new IndexTestHelper(firestore); - it('supports findNearest on non-existent field', async () => { - const indexTestHelper = new IndexTestHelper(firestore); + const collectionReference = await indexTestHelper.setTestDocs({ + '1': { + foo: 'bar', + embedding: FieldValue.vector([0, 1]), + distance: '100 miles', + }, + }); - const collectionRef = await indexTestHelper.createTestDocs([ - {foo: 'bar'}, - {foo: 'bar', otherField: [10, 10]}, - {foo: 'bar', otherField: 'not actually a vector'}, - {foo: 'bar', otherField: null}, - ]); + const vectorQuery = indexTestHelper + .query(collectionReference) + .findNearest({ + vectorField: 'embedding', + queryVector: [1, 0], + limit: 5, + distanceMeasure: 'COSINE', + distanceResultField: 'distance', + }); - const vectorQuery = indexTestHelper - .query(collectionRef) - .where('foo', '==', 'bar') - .findNearest('embedding', [10, 10], { - limit: 3, - distanceMeasure: 'EUCLIDEAN', + const res = await vectorQuery.get(); + + expect(res.size).to.equal(1); + + expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([0, 1]))) + .to.be.true; + expect(res.docs[0].get('distance')).to.equal(1); }); - const res = await vectorQuery.get(); + it('supports select queries', async () => { + const indexTestHelper = new IndexTestHelper(firestore); - expect(res.size).to.equal(0); - }); + const collectionReference = await indexTestHelper.setTestDocs({ + '1': {foo: 'bar'}, + '2': {foo: 'bar', embedding: FieldValue.vector([1, 0])}, + '3': {foo: 'bar', embedding: FieldValue.vector([0, 1])}, + '4': {foo: 'bar', embedding: FieldValue.vector([0, -0.1])}, + '5': {foo: 'bar', embedding: FieldValue.vector([-1, 0])}, + }); - it('supports findNearest on vector nested in a map', async () => { - const indexTestHelper = new IndexTestHelper(firestore); + const vectorQuery = indexTestHelper + .query(collectionReference) + // value of `distanceResultField` must also be in select statement + .select('embedding', 'distance') + .findNearest({ + vectorField: 'embedding', + queryVector: [1, 0], + limit: 5, + distanceMeasure: 'COSINE', + distanceResultField: 'distance', + }); - const collectionReference = await indexTestHelper.createTestDocs([ - {nested: {foo: 'bar'}}, - {nested: {foo: 'xxx', embedding: FieldValue.vector([10, 10])}}, - {nested: {foo: 'bar', embedding: FieldValue.vector([1, 1])}}, - {nested: {foo: 'bar', embedding: FieldValue.vector([10, 0])}}, - {nested: {foo: 'bar', embedding: FieldValue.vector([20, 0])}}, - {nested: {foo: 'bar', embedding: FieldValue.vector([100, 100])}}, - ]); + const res = await vectorQuery.get(); - const vectorQuery = indexTestHelper - .query(collectionReference) - .findNearest('nested.embedding', [10, 10], { - limit: 3, - distanceMeasure: 'EUCLIDEAN', + expect(res.size).to.equal(4); + + expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([1, 0]))) + .to.be.true; + expect(res.docs[0].get('distance')).to.equal(0); + + expect(res.docs[1].get('distance')).to.equal(1); + expect(res.docs[2].get('distance')).to.equal(1); + + expect(res.docs[3].get('embedding').isEqual(FieldValue.vector([-1, 0]))) + .to.be.true; + expect(res.docs[3].get('distance')).to.equal(2); }); + }); - const res = await vectorQuery.get(); - expect(res.size).to.equal(3); - expect( - res.docs[0].get('nested.embedding').isEqual(FieldValue.vector([10, 10])) - ).to.be.true; - expect( - res.docs[1].get('nested.embedding').isEqual(FieldValue.vector([10, 0])) - ).to.be.true; - expect( - res.docs[2].get('nested.embedding').isEqual(FieldValue.vector([1, 1])) - ).to.be.true; - }); + describe('querying with distance threshold', () => { + it('supports COSINE distance', async () => { + const indexTestHelper = new IndexTestHelper(firestore); - it('supports findNearest with select to exclude vector data in response', async () => { - const indexTestHelper = new IndexTestHelper(firestore); + const collectionReference = await indexTestHelper.setTestDocs({ + '1': {foo: 'bar'}, + '2': {foo: 'bar', embedding: FieldValue.vector([1, 0])}, + '3': {foo: 'bar', embedding: FieldValue.vector([1, 1])}, + '4': {foo: 'bar', embedding: FieldValue.vector([0, -0.1])}, + '5': {foo: 'bar', embedding: FieldValue.vector([-1, 0])}, + }); - const collectionReference = await indexTestHelper.createTestDocs([ - {foo: 1}, - {foo: 2, embedding: FieldValue.vector([10, 10])}, - {foo: 3, embedding: FieldValue.vector([1, 1])}, - {foo: 4, embedding: FieldValue.vector([10, 0])}, - {foo: 5, embedding: FieldValue.vector([20, 0])}, - {foo: 6, embedding: FieldValue.vector([100, 100])}, - ]); + const vectorQuery = indexTestHelper + .query(collectionReference) + .findNearest({ + vectorField: 'embedding', + queryVector: [1, 0], + limit: 5, + distanceMeasure: 'COSINE', + distanceThreshold: 1, + }); - const vectorQuery = indexTestHelper - .query(collectionReference) - .where('foo', 'in', [1, 2, 3, 4, 5, 6]) - .select('foo') - .findNearest('embedding', [10, 10], { - limit: 10, - distanceMeasure: 'EUCLIDEAN', + const res = await vectorQuery.get(); + + expect(res.size).to.equal(3); + + expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([1, 0]))) + .to.be.true; + expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([1, 1]))) + .to.be.true; + expect( + res.docs[2].get('embedding').isEqual(FieldValue.vector([0, -0.1])) + ).to.be.true; }); - const res = await vectorQuery.get(); - expect(res.size).to.equal(5); - expect(res.docs[0].get('foo')).to.equal(2); - expect(res.docs[1].get('foo')).to.equal(4); - expect(res.docs[2].get('foo')).to.equal(3); - expect(res.docs[3].get('foo')).to.equal(5); - expect(res.docs[4].get('foo')).to.equal(6); + it('supports EUCLIDEAN distance', async () => { + const indexTestHelper = new IndexTestHelper(firestore); - res.docs.forEach(ds => expect(ds.get('embedding')).to.be.undefined); - }); + const collectionReference = await indexTestHelper.setTestDocs({ + '1': {foo: 'bar'}, + '2': {foo: 'bar', embedding: FieldValue.vector([2, 0])}, + '3': {foo: 'bar', embedding: FieldValue.vector([1, 100])}, + '4': {foo: 'bar', embedding: FieldValue.vector([1, -0.1])}, + '5': {foo: 'bar', embedding: FieldValue.vector([4, 4])}, + }); - it('supports findNearest limits', async () => { - const indexTestHelper = new IndexTestHelper(firestore); + const vectorQuery = indexTestHelper + .query(collectionReference) + .findNearest({ + vectorField: 'embedding', + queryVector: [1, 0], + limit: 5, + distanceMeasure: 'EUCLIDEAN', + distanceThreshold: 5, + }); - const embeddingVector = []; - const queryVector = []; - for (let i = 0; i < 2048; i++) { - embeddingVector.push(i + 1); - queryVector.push(i - 1); - } + const res = await vectorQuery.get(); - const collectionReference = await indexTestHelper.createTestDocs([ - {embedding: FieldValue.vector(embeddingVector)}, - ]); + expect(res.size).to.equal(3); - const vectorQuery = indexTestHelper - .query(collectionReference) - .findNearest('embedding', queryVector, { - limit: 1000, - distanceMeasure: 'EUCLIDEAN', + expect( + res.docs[0].get('embedding').isEqual(FieldValue.vector([1, -0.1])) + ).to.be.true; + expect(res.docs[1].get('embedding').isEqual(FieldValue.vector([2, 0]))) + .to.be.true; + expect(res.docs[2].get('embedding').isEqual(FieldValue.vector([4, 4]))) + .to.be.true; }); - const res = await vectorQuery.get(); - expect(res.size).to.equal(1); - expect( - (res.docs[0].get('embedding') as VectorValue).toArray() - ).to.deep.equal(embeddingVector); + it('supports DOT_PRODUCT distance', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const collectionReference = await indexTestHelper.setTestDocs({ + '1': {foo: 'bar'}, + '2': {foo: 'bar', embedding: FieldValue.vector([2, 0])}, + '3': {foo: 'bar', embedding: FieldValue.vector([1, 100])}, + '4': {foo: 'bar', embedding: FieldValue.vector([-20, 0])}, + '5': {foo: 'bar', embedding: FieldValue.vector([0.1, 4])}, + }); + + const vectorQuery = indexTestHelper + .query(collectionReference) + .findNearest({ + vectorField: 'embedding', + queryVector: [1, 0], + limit: 5, + distanceMeasure: 'DOT_PRODUCT', + distanceThreshold: 1, + }); + + const res = await vectorQuery.get(); + + expect(res.size).to.equal(2); + + expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([2, 0]))) + .to.be.true; + + expect( + res.docs[1].get('embedding').isEqual(FieldValue.vector([1, 100])) + ).to.be.true; + }); + + it('works with distance threshold', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const collectionReference = await indexTestHelper.setTestDocs({ + '1': {foo: 'bar'}, + '2': {foo: 'bar', embedding: FieldValue.vector([2, 0])}, + '3': {foo: 'bar', embedding: FieldValue.vector([1, 100])}, + '4': {foo: 'bar', embedding: FieldValue.vector([-20, 0])}, + '5': {foo: 'bar', embedding: FieldValue.vector([0.1, 4])}, + }); + + const vectorQuery = indexTestHelper + .query(collectionReference) + .findNearest({ + vectorField: 'embedding', + queryVector: [1, 0], + limit: 5, + distanceMeasure: 'DOT_PRODUCT', + distanceThreshold: 0.11, + distanceResultField: 'foo', + }); + + const res = await vectorQuery.get(); + + expect(res.size).to.equal(2); + + expect(res.docs[0].get('foo')).to.equal(2); + expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([2, 0]))) + .to.be.true; + + expect(res.docs[1].get('foo')).to.equal(1); + expect( + res.docs[1].get('embedding').isEqual(FieldValue.vector([1, 100])) + ).to.be.true; + }); + + it('will not exceed limit even if there are more results more similar than distanceThreshold', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const collectionReference = await indexTestHelper.setTestDocs({ + '1': {foo: 'bar'}, + '2': {foo: 'bar', embedding: FieldValue.vector([2, 0])}, + '3': {foo: 'bar', embedding: FieldValue.vector([1, 100])}, + '4': {foo: 'bar', embedding: FieldValue.vector([-20, 0])}, + '5': {foo: 'bar', embedding: FieldValue.vector([0.1, 4])}, + }); + + const vectorQuery = indexTestHelper + .query(collectionReference) + .findNearest({ + vectorField: 'embedding', + queryVector: [1, 0], + limit: 2, + distanceMeasure: 'DOT_PRODUCT', + distanceThreshold: 0.0, + }); + + const res = await vectorQuery.get(); + + expect(res.size).to.equal(2); + + expect(res.docs[0].get('embedding').isEqual(FieldValue.vector([2, 0]))) + .to.be.true; + + expect( + res.docs[1].get('embedding').isEqual(FieldValue.vector([1, 100])) + ).to.be.true; + }); + }); }); it('supports !=', async () => { diff --git a/dev/test/vector-query.ts b/dev/test/vector-query.ts index 4da0d6e66..9897e0afc 100644 --- a/dev/test/vector-query.ts +++ b/dev/test/vector-query.ts @@ -25,6 +25,7 @@ import { import { DocumentSnapshot, FieldValue, + FieldPath, Firestore, Query, Timestamp, @@ -86,12 +87,16 @@ describe('Vector(findNearest) query interface', () => { expect( queryA - .findNearest('embedding', [40, 41, 42], { + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], limit: 10, distanceMeasure: 'COSINE', }) .isEqual( - queryA.findNearest('embedding', [40, 41, 42], { + queryA.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], distanceMeasure: 'COSINE', limit: 10, }) @@ -99,41 +104,110 @@ describe('Vector(findNearest) query interface', () => { ).to.be.true; expect( queryA - .findNearest('embedding', [40, 41, 42], { + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', limit: 10, - distanceMeasure: 'COSINE', }) .isEqual( - queryB.findNearest('embedding', [40, 41, 42], { - distanceMeasure: 'COSINE', + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', limit: 10, }) ) ).to.be.true; + expect( + queryA + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceThreshold: 0.125, + }) + .isEqual( + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceThreshold: 0.125, + }) + ) + ).to.be.true; + expect( + queryA + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceThreshold: 0.125, + distanceResultField: new FieldPath('foo'), + }) + .isEqual( + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceThreshold: 0.125, + distanceResultField: new FieldPath('foo'), + }) + ) + ).to.be.true; + expect( + queryA + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceResultField: 'distance', + }) + .isEqual( + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceResultField: new FieldPath('distance'), + }) + ) + ).to.be.true; expect( queryA - .findNearest('embedding', [40, 41, 42], { + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], limit: 10, distanceMeasure: 'COSINE', }) .isEqual( - firestore - .collection('collectionId') - .findNearest('embedding', [40, 41, 42], { - distanceMeasure: 'COSINE', - limit: 10, - }) + firestore.collection('collectionId').findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'COSINE', + limit: 10, + }) ) ).to.be.false; expect( queryA - .findNearest('embedding', [40, 41, 42], { + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], limit: 10, distanceMeasure: 'COSINE', }) .isEqual( - queryB.findNearest('embedding', [40, 42], { + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 42], distanceMeasure: 'COSINE', limit: 10, }) @@ -141,12 +215,16 @@ describe('Vector(findNearest) query interface', () => { ).to.be.false; expect( queryA - .findNearest('embedding', [40, 41, 42], { + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], limit: 10, distanceMeasure: 'COSINE', }) .isEqual( - queryB.findNearest('embedding', [40, 42], { + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 42], distanceMeasure: 'COSINE', limit: 1000, }) @@ -154,19 +232,188 @@ describe('Vector(findNearest) query interface', () => { ).to.be.false; expect( queryA - .findNearest('embedding', [40, 41, 42], { + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], limit: 10, distanceMeasure: 'COSINE', }) .isEqual( - queryB.findNearest('embedding', [40, 42], { + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 42], distanceMeasure: 'EUCLIDEAN', - limit: 1000, + limit: 10, + }) + ) + ).to.be.false; + expect( + queryA + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceThreshold: 1.125, + }) + .isEqual( + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceThreshold: 0.125, + }) + ) + ).to.be.false; + expect( + queryA + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + }) + .isEqual( + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceThreshold: 1, + }) + ) + ).to.be.false; + expect( + queryA + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceThreshold: 1, + }) + .isEqual( + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + }) + ) + ).to.be.false; + expect( + queryA + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceResultField: 'distance', + }) + .isEqual( + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceResultField: 'result', + }) + ) + ).to.be.false; + expect( + queryA + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceResultField: new FieldPath('bar'), + }) + .isEqual( + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceResultField: new FieldPath('foo'), + }) + ) + ).to.be.false; + expect( + queryA + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + }) + .isEqual( + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceResultField: new FieldPath('foo'), + }) + ) + ).to.be.false; + expect( + queryA + .findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, + distanceResultField: 'result', + }) + .isEqual( + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'EUCLIDEAN', + limit: 10, }) ) ).to.be.false; }); + it('generates equal vector queries with deprecated API', () => { + const queryA = firestore.collection('collectionId').where('foo', '==', 42); + const queryB = firestore.collection('collectionId').where('foo', '==', 42); + + expect( + queryA + .findNearest('embedding', [40, 41, 42], { + limit: 10, + distanceMeasure: 'COSINE', + }) + .isEqual( + queryB.findNearest({ + vectorField: 'embedding', + queryVector: [40, 41, 42], + distanceMeasure: 'COSINE', + limit: 10, + }) + ) + ).to.be.true; + expect( + queryA + .findNearest('foo', [40, 41, 42, 43], { + limit: 1, + distanceMeasure: 'DOT_PRODUCT', + }) + .isEqual( + queryB.findNearest({ + vectorField: 'foo', + queryVector: [40, 41, 42, 43], + distanceMeasure: 'DOT_PRODUCT', + limit: 1, + }) + ) + ).to.be.true; + }); + it('generates proto', async () => { const overrides: ApiOverride = { runQuery: request => { @@ -193,6 +440,26 @@ describe('Vector(findNearest) query interface', () => { }); it('validates inputs', async () => { + const query: Query = firestore.collection('collectionId'); + expect(() => { + query.findNearest({ + vectorField: 'embedding', + queryVector: [], + limit: 10, + distanceMeasure: 'EUCLIDEAN', + }); + }).to.throw('not a valid vector size'); + expect(() => { + query.findNearest({ + vectorField: 'embedding', + queryVector: [10, 1000], + limit: 0, + distanceMeasure: 'EUCLIDEAN', + }); + }).to.throw('not a valid positive limit number'); + }); + + it('validates inputs - preview (deprecated) API', async () => { const query: Query = firestore.collection('collectionId'); expect(() => { query.findNearest('embedding', [], { @@ -227,12 +494,12 @@ describe('Vector(findNearest) query interface', () => { return createInstance(overrides).then(firestoreInstance => { firestore = firestoreInstance; - const query = firestore - .collection('collectionId') - .findNearest('embedding', [1], { - limit: 2, - distanceMeasure: distanceMeasure, - }); + const query = firestore.collection('collectionId').findNearest({ + vectorField: 'embedding', + queryVector: [1], + limit: 2, + distanceMeasure: distanceMeasure, + }); return query.get().then(results => { expect(results.size).to.equal(2); expect(results.empty).to.be.false; @@ -268,9 +535,12 @@ describe('Vector(findNearest) query interface', () => { let counter = 0; return createInstance(overrides).then(firestoreInstance => { firestore = firestoreInstance; - const query = firestore - .collection('collectionId') - .findNearest('vector', [1], {limit: 10, distanceMeasure: 'COSINE'}); + const query = firestore.collection('collectionId').findNearest({ + vectorField: 'vector', + queryVector: [1], + limit: 10, + distanceMeasure: 'COSINE', + }); return query.get().then(results => { expect(++counter).to.equal(1); expect(results.size).to.equal(2); @@ -285,12 +555,12 @@ describe('Vector(findNearest) query interface', () => { it('handles stream exception at initialization', async () => { let attempts = 0; - const query = firestore - .collection('collectionId') - .findNearest('embedding', [1], { - limit: 100, - distanceMeasure: 'EUCLIDEAN', - }); + const query = firestore.collection('collectionId').findNearest({ + vectorField: 'embedding', + queryVector: [1], + limit: 100, + distanceMeasure: 'EUCLIDEAN', + }); query._queryUtil._stream = () => { ++attempts; @@ -322,7 +592,12 @@ describe('Vector(findNearest) query interface', () => { firestore = firestoreInstance; return firestore .collection('collectionId') - .findNearest('embedding', [1], {limit: 10, distanceMeasure: 'COSINE'}) + .findNearest({ + vectorField: 'embedding', + queryVector: [1], + limit: 10, + distanceMeasure: 'COSINE', + }) .get() .then(() => { throw new Error('Unexpected success in Promise'); diff --git a/package.json b/package.json index c82e0e890..655543ee3 100644 --- a/package.json +++ b/package.json @@ -73,9 +73,9 @@ "@google-cloud/trace-agent": "^8.0.0", "@googleapis/cloudtrace": "^1.1.2", "@google-cloud/cloud-rad": "^0.4.0", - "@opentelemetry/sdk-trace-node": "^1.24.1", - "@opentelemetry/context-async-hooks": "^1.24.1", "@google-cloud/opentelemetry-cloud-trace-exporter": "^2.0.0", + "@opentelemetry/context-async-hooks": "^1.24.1", + "@opentelemetry/sdk-trace-node": "^1.24.1", "@types/assert": "^1.4.0", "@types/chai": "^4.2.7", "@types/chai-as-promised": "^7.1.2", diff --git a/types/firestore.d.ts b/types/firestore.d.ts index 702e5ee84..51113bb1d 100644 --- a/types/firestore.d.ts +++ b/types/firestore.d.ts @@ -20,6 +20,7 @@ // Declare a global (ambient) namespace // (used when not using import statement, but just script include). + declare namespace FirebaseFirestore { /** Alias for `any` but used where a Firestore field value would be provided. */ export type DocumentFieldValue = any; @@ -2035,11 +2036,11 @@ declare namespace FirebaseFirestore { * `vectorField` against the given `queryVector` and returns the top documents that are closest * to the `queryVector`. * - * Only documents whose `vectorField` field is a `VectorValue` of the same dimension as `queryVector` + * Only documents whose `vectorField` field is a {@link VectorValue} of the same dimension as `queryVector` * participate in the query, all other documents are ignored. * * @example - * ```typescript + * ``` * // Returns the closest 10 documents whose Euclidean distance from their 'embedding' fields are closed to [41, 42]. * const vectorQuery = col.findNearest('embedding', [41, 42], {limit: 10, distanceMeasure: 'EUCLIDEAN'}); * @@ -2047,11 +2048,14 @@ declare namespace FirebaseFirestore { * querySnapshot.forEach(...); * ``` * - * @param vectorField The field path this vector query executes on. - * @param queryVector The vector value used to measure the distance from `vectorField` values in the documents. - * @param options Options control the vector query. `limit` specifies the upper bound of documents to return, must - * be a positive integer with a maximum value of 1000. `distanceMeasure` specifies what type of distance is - * calculated when performing the query. + * @param vectorField - A string or {@link FieldPath} specifying the vector field to search on. + * @param queryVector - The {@link VectorValue} used to measure the distance from `vectorField` values in the documents. + * @param options - Options control the vector query. `limit` specifies the upper bound of documents to return, must + * be a positive integer with a maximum value of 1000. `distanceMeasure` specifies what type of distance is calculated + * when performing the query. + * + * @deprecated Use the new {@link findNearest} implementation + * accepting a single `options` param. */ findNearest( vectorField: string | FieldPath, @@ -2062,6 +2066,38 @@ declare namespace FirebaseFirestore { } ): VectorQuery; + /** + * Returns a query that can perform vector distance (similarity) search with given parameters. + * + * The returned query, when executed, performs a distance (similarity) search on the specified + * `vectorField` against the given `queryVector` and returns the top documents that are closest + * to the `queryVector`. + * + * Only documents whose `vectorField` field is a {@link VectorValue} of the same dimension as `queryVector` + * participate in the query, all other documents are ignored. + * + * @example + * ``` + * // Returns the closest 10 documents whose Euclidean distance from their 'embedding' fields are closed to [41, 42]. + * const vectorQuery = col.findNearest({ + * vectorField: 'embedding', + * queryVector: [41, 42], + * limit: 10, + * distanceMeasure: 'EUCLIDEAN', + * distanceResultField: 'distance', + * distanceThreshold: 0.125 + * }); + * + * const querySnapshot = await aggregateQuery.get(); + * querySnapshot.forEach(...); + * ``` + * @param options - An argument specifying the behavior of the {@link VectorQuery} returned by this function. + * See {@link VectorQueryOptions}. + */ + findNearest( + options: VectorQueryOptions + ): VectorQuery; + /** * Returns true if this `Query` is equal to the provided one. * @@ -3192,6 +3228,50 @@ declare namespace FirebaseFirestore { */ readonly snapshot: T | null; } + + /** + * Specifies the behavior of the {@link VectorQuery} generated by a call to {@link Query.findNearest}. + */ + export interface VectorQueryOptions { + /** + * A string or {@link FieldPath} specifying the vector field to search on. + */ + vectorField: string | FieldPath; + + /** + * The {@link VectorValue} used to measure the distance from `vectorField` values in the documents. + */ + queryVector: VectorValue | Array; + + /** + * Specifies the upper bound of documents to return, must be a positive integer with a maximum value of 1000. + */ + limit: number; + + /** + * Specifies what type of distance is calculated when performing the query. + */ + distanceMeasure: 'EUCLIDEAN' | 'COSINE' | 'DOT_PRODUCT'; + + /** + * Optionally specifies the name of a field that will be set on each returned DocumentSnapshot, + * which will contain the computed distance for the document. + */ + distanceResultField?: string | FieldPath; + + /** + * Specifies a threshold for which no less similar documents will be returned. The behavior + * of the specified `distanceMeasure` will affect the meaning of the distance threshold. + * + * - For `distanceMeasure: "EUCLIDEAN"`, the meaning of `distanceThreshold` is: + * SELECT docs WHERE euclidean_distance <= distanceThreshold + * - For `distanceMeasure: "COSINE"`, the meaning of `distanceThreshold` is: + * SELECT docs WHERE cosine_distance <= distanceThreshold + * - For `distanceMeasure: "DOT_PRODUCT"`, the meaning of `distanceThreshold` is: + * SELECT docs WHERE dot_product_distance >= distanceThreshold + */ + distanceThreshold?: number; + } } declare module '@google-cloud/firestore' {