// Copyright (c) 2023 Cloudflare, Inc. // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 const queryMetadataOptional = !!Cloudflare.compatibilityFlags['vectorize_query_metadata_optional']; interface Fetcher { fetch: typeof fetch; } const Operation = { INDEX_GET: 'INDEX_GET', VECTOR_QUERY: 'VECTOR_QUERY', VECTOR_INSERT: 'VECTOR_INSERT', VECTOR_UPSERT: 'VECTOR_UPSERT', VECTOR_GET: 'VECTOR_GET', VECTOR_DELETE: 'VECTOR_DELETE', } as const; type OperationKey = keyof typeof Operation; type VectorizeVersion = 'v1' | 'v2'; type QueryImplV2Params = | { vector: VectorFloatArray | number[]; vectorId?: undefined } | { vector?: undefined; vectorId: string }; function toNdJson(arr: object[]): string { return arr.reduce((acc, o) => acc + JSON.stringify(o) + '\n', '').trim(); } /* * The Vectorize beta VectorizeIndex shares the same methods, so to keep things simple, they share one implementation. * The types here are specific to Vectorize GA, but the types here don't actually matter as they are stripped away * and not visible to end users. */ class VectorizeIndexImpl implements Vectorize { // eslint-disable-next-line no-restricted-syntax private readonly fetcher: Fetcher; // eslint-disable-next-line no-restricted-syntax private readonly indexId: string; // eslint-disable-next-line no-restricted-syntax private readonly indexVersion: VectorizeVersion; // eslint-disable-next-line no-restricted-syntax private readonly useNdJson: boolean; constructor( fetcher: Fetcher, indexId: string, indexVersion: VectorizeVersion, useNdJson: boolean ) { this.fetcher = fetcher; this.indexId = indexId; this.indexVersion = indexVersion; this.useNdJson = useNdJson; } async describe(): Promise { const endpoint = this.indexVersion === 'v2' ? `info` : `binding/indexes/${this.indexId}`; const res = await this._send(Operation.INDEX_GET, endpoint, { method: 'GET', }); return await toJson(res); } async query( vector: VectorFloatArray | number[], options?: VectorizeQueryOptions ): Promise { if (this.indexVersion === 'v2') { return await this.queryImplV2( { vector: Array.isArray(vector) ? vector : Array.from(vector) }, options ); } else { if ( options && options.returnMetadata && typeof options.returnMetadata !== 'boolean' ) { throw new Error( `Invalid returnMetadata option. Expected boolean; got: ${options.returnMetadata}` ); } const compat = { queryMetadataOptional, }; const res = await this._send( Operation.VECTOR_QUERY, `binding/indexes/${this.indexId}/query`, { method: 'POST', body: JSON.stringify({ ...options, vector: Array.isArray(vector) ? vector : Array.from(vector), compat, }), headers: { 'content-type': 'application/json', accept: 'application/json', 'cf-vector-search-query-compat': JSON.stringify(compat), }, } ); return await toJson(res); } } async queryById( vectorId: string, options?: VectorizeQueryOptions ): Promise { if (this.indexVersion === 'v1') { throw new Error(`QueryById operation is not supported for v1 indexes.`); } else { return await this.queryImplV2({ vectorId }, options); } } async insert(vectors: VectorizeVector[]): Promise { const endpoint = this.indexVersion === 'v2' ? `insert` : `binding/indexes/${this.indexId}/insert`; const bodyVecArr = vectors.map((vec) => ({ ...vec, values: Array.isArray(vec.values) ? vec.values : Array.from(vec.values), })); const body = this.useNdJson ? toNdJson(bodyVecArr) : JSON.stringify({ vectors: bodyVecArr }); const contentType = this.useNdJson ? 'application/x-ndjson' : 'application/json'; const res = await this._send(Operation.VECTOR_INSERT, endpoint, { method: 'POST', body, headers: { 'content-type': contentType, 'cf-vector-search-dim-width': String( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition vectors.length ? vectors.at(0)?.values?.length : 0 ), 'cf-vector-search-dim-height': String(vectors.length), accept: 'application/json', }, }); return await toJson(res); } async upsert(vectors: VectorizeVector[]): Promise { const endpoint = this.indexVersion === 'v2' ? `upsert` : `binding/indexes/${this.indexId}/upsert`; const bodyVecArr = vectors.map((vec) => ({ ...vec, values: Array.isArray(vec.values) ? vec.values : Array.from(vec.values), })); const body = this.useNdJson ? toNdJson(bodyVecArr) : JSON.stringify({ vectors: bodyVecArr }); const contentType = this.useNdJson ? 'application/x-ndjson' : 'application/json'; const res = await this._send(Operation.VECTOR_UPSERT, endpoint, { method: 'POST', body, headers: { 'content-type': contentType, 'cf-vector-search-dim-width': String( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition vectors.length ? vectors.at(0)?.values?.length : 0 ), 'cf-vector-search-dim-height': String(vectors.length), accept: 'application/json', }, }); return await toJson(res); } async getByIds(ids: string[]): Promise { const endpoint = this.indexVersion === 'v2' ? `getByIds` : `binding/indexes/${this.indexId}/getByIds`; const res = await this._send(Operation.VECTOR_GET, endpoint, { method: 'POST', body: JSON.stringify({ ids }), headers: { 'content-type': 'application/json', accept: 'application/json', }, }); return await toJson(res); } async deleteByIds(ids: string[]): Promise { const endpoint = this.indexVersion === 'v2' ? `deleteByIds` : `binding/indexes/${this.indexId}/deleteByIds`; const res = await this._send(Operation.VECTOR_DELETE, endpoint, { method: 'POST', body: JSON.stringify({ ids }), headers: { 'content-type': 'application/json', accept: 'application/json', }, }); return await toJson(res); } // eslint-disable-next-line no-restricted-syntax private async _send( operation: OperationKey, endpoint: string, init: RequestInit ): Promise { const res = await this.fetcher.fetch( `http://vector-search/${endpoint}`, // `http://vector-search` is just a dummy host, the attached fetcher will receive the request init ); if (res.status !== 200) { let err: Error | null = null; try { const errResponse = (await res.json()) as VectorizeError; err = new Error( `${Operation[operation]}_ERROR${ typeof errResponse.code === 'number' ? ` (code = ${errResponse.code})` : '' }: ${errResponse.error}`, { cause: new Error(errResponse.error), } ); } catch { // do nothing } if (err) { throw err; } else { throw new Error( `${Operation[operation]}_ERROR: Status + ${res.status}`, { cause: new Error(`Status ${res.status}`), } ); } } return res; } // eslint-disable-next-line no-restricted-syntax private async queryImplV2( vectorParams: QueryImplV2Params, options?: VectorizeQueryOptions ): Promise { if (options?.returnMetadata) { if ( typeof options.returnMetadata !== 'boolean' && !isVectorizeMetadataRetrievalLevel(options.returnMetadata) ) { throw new Error( `Invalid returnMetadata option. Expected: true, false, "none", "indexed" or "all"; got: ${options.returnMetadata}` ); } if (typeof options.returnMetadata === 'boolean') { // Allow boolean returnMetadata for backward compatibility. true converts to 'all' and false converts to 'none' // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition options.returnMetadata = options.returnMetadata ? 'all' : 'none'; } } const res = await this._send(Operation.VECTOR_QUERY, `query`, { method: 'POST', body: JSON.stringify({ ...options, ...(vectorParams.vector ? { vector: vectorParams.vector } : { vectorId: vectorParams.vectorId }), }), headers: { 'content-type': 'application/json', accept: 'application/json', }, }); return await toJson(res); } } function isVectorizeMetadataRetrievalLevel(value: unknown): boolean { return ( typeof value === 'string' && (value === 'all' || value === 'indexed' || value === 'none') ); } const maxBodyLogChars = 1_000; async function toJson(response: Response): Promise { const body = await response.text(); try { return JSON.parse(body) as T; } catch { throw new Error( `Failed to parse body as JSON, got: ${ body.length > maxBodyLogChars ? `${body.slice(0, maxBodyLogChars)}…` : body }` ); } } export function makeBinding(env: { fetcher: Fetcher; indexId: string; indexVersion?: VectorizeVersion; useNdJson?: boolean; }): Vectorize { return new VectorizeIndexImpl( env.fetcher, env.indexId, env.indexVersion ?? 'v1', env.useNdJson ?? false ); } export default makeBinding;