All files / third-parties/langchain/src vectorstore.ts

96.12% Statements 124/129
87.8% Branches 72/82
96.77% Functions 30/31
97.56% Lines 120/123

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736                                                                                    2x                             2x                                                                                                                                                                                                                                                                                                                                                 37x               146x 31x         28x                                         67x 28x 28x 28x 28x 28x 28x   28x 28x 28x 28x   39x 39x 39x 39x 39x 39x           2x                                                             76x     80x       76x         76x 72x 72x 4x 2x 2x   2x         74x 74x   74x 76x               76x         76x 1x         73x 76x   76x   76x       133x         27x       76x 1x         68x 68x 68x       31x 31x   31x 27x     31x                                         37x   37x   37x         36x           36x 144x     144x   36x   36x 37x 101x 101x 101x                   37x             37x                         102x 37x 37x                                   28x 28x   28x   28x 1x                   27x       28x       28x   28x       7x                           28x                       81x                                               2x 1x 1x 1x 1x 1x 1x                   8x 8x   8x 8x       8x 8x                                     2x 1x       1x   1x 1x                                         2x 2x 2x 2x 2x                                               1x   3x         1x                               2x 2x 2x                                           3x 3x 3x       3x      
import { Driver, type DriverOptions, kRegisterLibrary } from '@ydbjs/core'
import {
	type Fragment,
	type QueryClient,
	type UnsafeString,
	query as createQueryClient,
	fragment,
	identifier,
	join,
	unsafe,
} from '@ydbjs/query'
import { Uint64 } from '@ydbjs/value/primitive'
import type { EmbeddingsInterface } from '@langchain/core/embeddings'
import { VectorStore } from '@langchain/core/vectorstores'
import { Document } from '@langchain/core/documents'
 
import pkg from '../package.json' with { type: 'json' }
 
import { escJsonPathKey, vectorToBytes } from './encoding.js'
import {
	YDBVectorStoreArgumentError,
	YDBVectorStoreConfigError,
	YDBVectorStoreOperationError,
} from './errors.js'
 
/**
 * KNN search strategy passed to the YDB `Knn::*` UDF.
 *
 * **Similarity strategies** (suffix `Similarity`) return higher values for
 * more similar vectors. Results are sorted **DESC** — the best match is first.
 *
 * **Distance strategies** (suffix `Distance`) return lower values for closer
 * vectors. Results are sorted **ASC** — the best match is first.
 *
 * | Strategy |
 * |---|
 * | `CosineDistance` |
 * | `CosineSimilarity` |
 * | `EuclideanDistance` |
 * | `ManhattanDistance` |
 * | `InnerProductSimilarity` |
 */
export const YDBSearchStrategy = {
	CosineDistance: 'CosineDistance',
	CosineSimilarity: 'CosineSimilarity',
	ManhattanDistance: 'ManhattanDistance',
	EuclideanDistance: 'EuclideanDistance',
	InnerProductSimilarity: 'InnerProductSimilarity',
} as const
 
export type YDBSearchStrategyType = (typeof YDBSearchStrategy)[keyof typeof YDBSearchStrategy]
 
/**
 * Maps each strategy to its `WITH` clause fragment for `vector_kmeans_tree`
 * index DDL. `Record` enforces exhaustiveness at compile time — a new strategy
 * added to {@link YDBSearchStrategy} must be added here too.
 */
const INDEX_STRATEGY_DDL: Record<YDBSearchStrategyType, string> = {
	CosineSimilarity: "similarity = 'cosine'",
	InnerProductSimilarity: "similarity = 'inner_product'",
	CosineDistance: "distance = 'cosine'",
	EuclideanDistance: "distance = 'euclidean'",
	ManhattanDistance: "distance = 'manhattan'",
}
 
/**
 * Mapping from logical field names to physical column names in the YDB table.
 * Override any field via `columnMap` in the store config when the table
 * schema uses non-default names.
 */
export interface YDBColumnMap {
	/** Primary key column — stores the document ID. Default: `"id"`. */
	id: string
	/** Column that holds the document text (`Utf8`). Default: `"document"`. */
	document: string
	/** Column that holds arbitrary document metadata as JSON. Default: `"metadata"`. */
	metadata: string
	/** Column that holds the packed `Float32` embedding bytes (`String`). Default: `"embedding"`. */
	embedding: string
}
 
/** Common options, independent of how the driver is provided. */
export interface YDBVectorStoreBaseConfig {
	/** YDB table name. Defaults to `"langchain_vectors"`. */
	table?: string
	/**
	 * Override individual column names when the table was not created by this
	 * store (e.g. you have an existing schema). Only the fields you specify are
	 * overridden — omitted fields keep their defaults.
	 */
	columnMap?: Partial<YDBColumnMap>
	/**
	 * Vector similarity/distance function used in KNN search.
	 * Defaults to `YDBSearchStrategy.CosineSimilarity`.
	 * See {@link YDBSearchStrategy} for the full list and sort-order semantics.
	 */
	strategy?: YDBSearchStrategyType
	/**
	 * When `true`, every schema-initialisation step issues `DROP TABLE IF EXISTS`
	 * before `CREATE TABLE`. Useful during development or testing for a
	 * guaranteed fresh start. Defaults to `false`.
	 */
	dropExistingTable?: boolean
	/**
	 * Number of documents flushed per `UPSERT` batch in {@link YDBVectorStore.addVectors}.
	 * Must be a positive integer. Defaults to `32`.
	 */
	batchSize?: number
}
 
/**
 * Vector-index options. When `indexEnabled` is omitted or `false`, the store
 * does exact KNN scans and the index-tuning fields are not accepted. When
 * `indexEnabled: true`, `indexVectorDimension` is required and the rest of the
 * `index*` fields become available.
 */
export type YDBVectorStoreIndexOptions =
	| {
			indexEnabled?: false
			indexName?: never
			indexVectorDimension?: never
			indexConfigLevels?: never
			indexConfigClusters?: never
			indexTreeSearchTopSize?: never
	  }
	| {
			/**
			 * Enable the `vector_kmeans_tree` approximate nearest-neighbour index.
			 * Call {@link YDBVectorStore.createVectorIndex} after inserting the
			 * initial batch of documents to build the index.
			 */
			indexEnabled: true
			/** Name of the vector index. Defaults to `"langchain_vector_index"`. */
			indexName?: string
			/**
			 * Number of dimensions in the embedding vectors. Required because
			 * YDB needs the size at index-build time. Get it from your embeddings
			 * model docs or via `(await embeddings.embedQuery('x')).length`.
			 */
			indexVectorDimension: number
			/**
			 * Number of tree levels in the k-means index. Recommended range: 1–3.
			 * Higher values improve recall at the cost of slower index build time.
			 * Defaults to `2`.
			 */
			indexConfigLevels?: number
			/**
			 * Number of k-means clusters per tree level. Recommended range: 64–512.
			 * Larger values yield finer partitions and better recall, but require
			 * more memory and a longer build. Defaults to `128`.
			 */
			indexConfigClusters?: number
			/**
			 * `KMeansTreeSearchTopSize` PRAGMA value — how many leaf clusters are
			 * visited during an indexed search. Higher values improve recall at the
			 * cost of latency. Defaults to `1`.
			 */
			indexTreeSearchTopSize?: number
	  }
 
/**
 * Pass either a pre-built Driver (you manage its lifecycle) or a connection
 * string (the store creates and owns the Driver; `await store.close()` when done).
 */
export type YDBVectorStoreDriverOptions =
	| {
			/** A ready-to-use Driver instance from `@ydbjs/core`. */
			driver: Driver
			connectionString?: never
			driverOptions?: never
	  }
	| {
			/**
			 * YDB connection string, e.g. `"grpc://localhost:2136/local"`.
			 * The store creates a Driver internally; `await store.close()` to release it.
			 */
			connectionString: string
			/** Additional Driver options (auth, TLS, …). */
			driverOptions?: DriverOptions
			driver?: never
	  }
 
export type YDBVectorStoreConfig = YDBVectorStoreBaseConfig &
	YDBVectorStoreIndexOptions &
	YDBVectorStoreDriverOptions
 
/**
 * Config for {@link YDBVectorStore.fromExistingTable}. Like
 * {@link YDBVectorStoreConfig} but `table` is required (connecting to "the
 * existing table" without naming it is almost always a footgun) and
 * `dropExistingTable` is forbidden (it would defeat the purpose).
 */
export type YDBVectorStoreExistingTableConfig = Omit<
	YDBVectorStoreBaseConfig,
	'table' | 'dropExistingTable'
> & {
	table: string
	dropExistingTable?: never
} & YDBVectorStoreIndexOptions &
	YDBVectorStoreDriverOptions
 
/**
 * Metadata filter for similarity search.
 *
 * Each key-value pair is translated to a
 * `JSON_VALUE(metadata, '$.key') = 'value'` condition.
 * Multiple entries are combined with `AND`.
 *
 * **Limitations:**
 * - Values must be strings (JSON scalars are compared as text).
 * - Cannot be used together with `indexEnabled: true` — the vector index
 *   does not support pre-filtering.
 */
export type YDBFilter = Record<string, string>
 
/**
 * Parameters for {@link YDBVectorStore.delete}. `deleteAll: true` truncates the
 * table; otherwise rows matching `ids` are removed. Empty/omitted `ids` is a
 * no-op.
 */
export type YDBVectorStoreDeleteParams = {
	ids?: string[]
	deleteAll?: boolean
}
 
function assertSameLength(nameA: string, lenA: number, nameB: string, lenB: number): void {
	Iif (lenA !== lenB) {
		throw new YDBVectorStoreArgumentError(
			`${nameA}.length (${lenA}) must equal ${nameB}.length (${lenB}).`
		)
	}
}
 
function assertPositiveInteger(option: string, value: number): void {
	if (!Number.isSafeInteger(value) || value < 1) {
		throw new YDBVectorStoreConfigError(`${option} must be a positive integer, got: ${value}.`)
	}
}
 
function assertNonNegativeInteger(name: string, value: number): void {
	Iif (!Number.isSafeInteger(value) || value < 0) {
		throw new YDBVectorStoreArgumentError(
			`${name} must be a non-negative integer, got: ${value}.`
		)
	}
}
 
/**
 * Resolved snapshot of {@link YDBVectorStoreIndexOptions} with defaults applied
 * and tuning values validated. Fields are always populated; when the index is
 * disabled they hold defaults that are never read (callers gate on `enabled`).
 */
class YDBVectorIndexConfig {
	readonly enabled: boolean
	readonly name: string
	readonly levels: number
	readonly clusters: number
	readonly vectorDimension: number | undefined
	readonly treeSearchTopSize: number
 
	constructor(opts: YDBVectorStoreIndexOptions) {
		if (opts.indexEnabled) {
			this.enabled = true
			this.name = opts.indexName ?? 'langchain_vector_index'
			this.levels = opts.indexConfigLevels ?? 2
			this.clusters = opts.indexConfigClusters ?? 128
			this.vectorDimension = opts.indexVectorDimension
			this.treeSearchTopSize = opts.indexTreeSearchTopSize ?? 1
 
			assertPositiveInteger('indexVectorDimension', this.vectorDimension)
			assertPositiveInteger('indexConfigLevels', this.levels)
			assertPositiveInteger('indexConfigClusters', this.clusters)
			assertPositiveInteger('indexTreeSearchTopSize', this.treeSearchTopSize)
		} else {
			this.enabled = false
			this.name = 'langchain_vector_index'
			this.levels = 2
			this.clusters = 128
			this.vectorDimension = undefined
			this.treeSearchTopSize = 1
		}
	}
 
	/** Type-narrowing guard. After `if (cfg.isEnabled())`, `vectorDimension` is `number`. */
	isEnabled(): this is YDBVectorIndexConfig & { vectorDimension: number } {
		return this.enabled
	}
}
 
/**
 * LangChain vector store backed by YDB. See the package README for usage examples.
 *
 * Uses `@ydbjs/core` (Driver) and `@ydbjs/query` (tagged-template YQL client) —
 * both are bundled as regular dependencies of this package. Embeddings are
 * stored as packed little-endian `Float32` bytes in a `String` column and
 * searched with the built-in `Knn::*` UDFs.
 */
export class YDBVectorStore extends VectorStore {
	declare FilterType: YDBFilter
 
	#sql: QueryClient
	#driver: Driver
	#promise: Promise<void> | undefined
	#ownedDriver: boolean
 
	#table: string
	#columnMap: YDBColumnMap
 
	#strategy: YDBSearchStrategyType
 
	#batchSize: number
 
	#indexConfig: YDBVectorIndexConfig
 
	#dropExistingTable: boolean
 
	#closed: boolean = false
 
	override _vectorstoreType(): string {
		return 'ydb'
	}
 
	constructor(embeddings: EmbeddingsInterface, config: YDBVectorStoreConfig) {
		super(embeddings, config)
 
		// Branch on `driver` first so that callers passing
		// `{ driver, connectionString: undefined }` (or any partially-undefined
		// union shape) land on the right path.
		if ('driver' in config && config.driver !== undefined) {
			this.#driver = config.driver
			this.#ownedDriver = false
		} else if ('connectionString' in config && config.connectionString) {
			this.#driver = new Driver(config.connectionString, config.driverOptions)
			this.#ownedDriver = true
		} else {
			throw new YDBVectorStoreConfigError(
				'Either `driver` or a non-empty `connectionString` must be provided.'
			)
		}
 
		this.#driver[kRegisterLibrary]('@ydbjs/langchain', pkg.version)
		this.#sql = createQueryClient(this.#driver)
 
		this.#table = config.table ?? 'langchain_vectors'
		this.#columnMap = {
			id: 'id',
			metadata: 'metadata',
			document: 'document',
			embedding: 'embedding',
			...config.columnMap,
		}
 
		this.#strategy = config.strategy ?? YDBSearchStrategy.CosineSimilarity
		// `this.#strategy` is spliced into YQL via `unsafe(...)` in Knn::* and the
		// index DDL — guard against TS-bypass / JSON-driven values reaching the
		// query as raw text. Reusing the DDL map doubles as a single source of
		// truth: a strategy is "known" iff it has a DDL mapping.
		if (INDEX_STRATEGY_DDL[this.#strategy] === undefined) {
			throw new YDBVectorStoreConfigError(
				`Unknown search strategy: "${this.#strategy}". Valid values: ${Object.keys(INDEX_STRATEGY_DDL).join(', ')}.`
			)
		}
 
		this.#batchSize = config.batchSize ?? 32
		assertPositiveInteger('batchSize', this.#batchSize)
 
		this.#indexConfig = new YDBVectorIndexConfig(config)
 
		this.#dropExistingTable = config.dropExistingTable ?? false
	}
 
	get #t(): UnsafeString {
		return identifier(this.#table)
	}
 
	// Similarity strategies sort highest-first; distance strategies lowest-first.
	get #sortOrder(): 'ASC' | 'DESC' {
		return this.#strategy.endsWith('Similarity') ? 'DESC' : 'ASC'
	}
 
	#assertOpen(): void {
		if (this.#closed) {
			throw new YDBVectorStoreOperationError('Store is closed.')
		}
	}
 
	async #ensureTable(): Promise<void> {
		this.#assertOpen()
		this.#promise ??= this.#runInit()
		await this.#promise
	}
 
	async #runInit(): Promise<void> {
		await this.#driver.ready()
		let { id, document: doc, embedding: emb, metadata: meta } = this.#columnMap
 
		if (this.#dropExistingTable) {
			await this.#sql`DROP TABLE IF EXISTS ${this.#t}`
		}
 
		await this.#sql`CREATE TABLE IF NOT EXISTS ${this.#t} (
	        ${identifier(id)}   Utf8,
	        ${identifier(doc)}  Utf8,
	        ${identifier(emb)}  String,
	        ${identifier(meta)} Json,
	        PRIMARY KEY (${identifier(id)})
		)`
	}
 
	/**
	 * Insert or replace documents using pre-computed embedding vectors.
	 *
	 * Uses `UPSERT` semantics: if a document with the same ID already exists it
	 * is overwritten, not duplicated.  Documents without an explicit `id` receive
	 * a random UUID.
	 *
	 * @param vectors - Embedding vectors, one per document (same order).
	 * @param documents - Documents to store.
	 * @returns The ID of every inserted/updated document (same order as input).
	 */
	async addVectors(vectors: number[][], documents: Document[]): Promise<string[]> {
		assertSameLength('vectors', vectors.length, 'documents', documents.length)
 
		Iif (vectors.length === 0) return []
 
		await this.#ensureTable()
 
		// Single source of truth for column ↔ JS-key ↔ SQL-expression mapping.
		// Positions must align: UPSERT cols[i] receives the value from selectExprs[i],
		// which reads JS-key `selectExprs[i]` from each `batch` element.
		let mappings: Array<[destCol: string, selectExpr: string]> = [
			[this.#columnMap.id, 'id'],
			[this.#columnMap.document, 'document'],
			[this.#columnMap.embedding, 'embedding'],
			[this.#columnMap.metadata, 'CAST(metadata AS Json)'],
		]
		let destCols = join(
			mappings.map(([d]) => fragment`${identifier(d)}`),
			', '
		)
		let selectExprs = mappings.map(([, e]) => e).join(', ')
 
		let ids: string[] = []
 
		for (let i = 0; i < vectors.length; i += this.#batchSize) {
			let batch = documents.slice(i, i + this.#batchSize).map((d, j) => {
				let docId = d.id ?? crypto.randomUUID()
				ids.push(docId)
				return {
					id: docId,
					document: d.pageContent,
					metadata: JSON.stringify(d.metadata ?? {}),
					embedding: vectorToBytes(vectors[i + j]!),
				}
			})
 
			// Batches are sent sequentially to avoid overwhelming the server.
			// oxlint-disable-next-line no-await-in-loop
			await this.#sql`
				UPSERT INTO ${this.#t} (${destCols})
				SELECT ${unsafe(selectExprs)}
				FROM AS_TABLE(${batch})
			`
		}
 
		return ids
	}
 
	/**
	 * Embed and store documents.
	 *
	 * Embeds each document's `pageContent` with the configured embeddings model,
	 * then delegates to {@link addVectors}.  Uses `UPSERT` semantics.
	 *
	 * @param documents - Documents to embed and store.
	 * @returns The ID of every inserted/updated document (same order as input).
	 */
	async addDocuments(documents: Document[]): Promise<string[]> {
		let texts = documents.map((d) => d.pageContent)
		let vectors = await this.embeddings.embedDocuments(texts)
		return this.addVectors(vectors, documents)
	}
 
	/**
	 * Find the `k` documents most similar to a pre-computed query vector.
	 *
	 * @param query - The query embedding vector.
	 * @param k - Maximum number of results to return.
	 * @param filter - Optional metadata filter (see {@link YDBFilter}).
	 *   Cannot be combined with `indexEnabled: true`.
	 * @returns Pairs of `[document, score]` sorted by the configured strategy:
	 *   descending for similarity strategies, ascending for distance strategies.
	 */
	async similaritySearchVectorWithScore(
		query: number[],
		k: number,
		filter?: this['FilterType']
	): Promise<[Document, number][]> {
		assertNonNegativeInteger('k', k)
		await this.#ensureTable()
 
		let { id, document: doc, embedding: emb, metadata: meta } = this.#columnMap
 
		if (filter && Object.keys(filter).length > 0 && this.#indexConfig.enabled) {
			throw new YDBVectorStoreOperationError(
				'Cannot use metadata filter with vector index enabled.'
			)
		}
 
		// Compose the query from fragments so embedding bytes, filter values, and
		// LIMIT all flow through bound parameters — query text stays stable across
		// k values and filter shapes, letting YDB reuse its compiled plan.
		// JSON path keys cannot be parameterised in YQL, so we escape them and
		// splice them in as raw text via unsafe().
		let pragma: Fragment = this.#indexConfig.enabled
			? fragment`PRAGMA ydb.KMeansTreeSearchTopSize = "${unsafe(String(this.#indexConfig.treeSearchTopSize))}"; `
			: fragment``
 
		let view: Fragment = this.#indexConfig.enabled
			? fragment`VIEW ${identifier(this.#indexConfig.name)}`
			: fragment``
 
		let filterEntries = Object.entries(filter ?? {})
		let where: Fragment =
			filterEntries.length > 0
				? fragment`WHERE ${join(
						filterEntries.map(
							([key, value]) =>
								fragment`JSON_VALUE(${identifier(meta)}, ${unsafe(`'$.${escJsonPathKey(key)}'`)}) = ${value}`
						),
						' AND '
					)}`
				: fragment``
 
		type SearchResultRow = {
			id: string
			document: string
			// Json column — string when the driver returns raw text, parsed value otherwise.
			metadata: string | Record<string, unknown> | null
			score: number
		}
 
		let [rows = []] = await this.#sql<[SearchResultRow]>`
			${pragma}
			SELECT
				${identifier(id)} AS id,
				${identifier(doc)} AS document,
				${identifier(meta)} AS metadata,
				Knn::${unsafe(this.#strategy)}(${identifier(emb)}, ${vectorToBytes(query)}) AS score
			FROM ${this.#t} ${view}
			${where}
			ORDER BY score ${unsafe(this.#sortOrder)} LIMIT ${new Uint64(BigInt(k))}
		`
 
		return rows.map((row) => [
			new Document({
				id: row.id,
				pageContent: row.document,
				metadata:
					typeof row.metadata === 'string'
						? JSON.parse(row.metadata)
						: (row.metadata ?? {}),
			}),
			Number(row.score),
		])
	}
 
	/**
	 * Delete documents from the store. With no matching params (both `ids` and
	 * `deleteAll` omitted, or `ids` is an empty array) it's a no-op — the table
	 * is not touched and not lazily created.
	 *
	 * @param params.ids - Delete only the documents with these IDs.
	 *   An empty array is treated as "nothing to delete".
	 * @param params.deleteAll - When `true`, truncate the entire table.
	 *   Takes precedence over `ids`.
	 */
	override async delete(params: YDBVectorStoreDeleteParams): Promise<void> {
		if (params.deleteAll) {
			await this.#ensureTable()
			await this.#sql`DELETE FROM ${this.#t}`
		} else Eif (params.ids && params.ids.length > 0) {
			await this.#ensureTable()
			let col = identifier(this.#columnMap.id)
			await this.#sql`DELETE FROM ${this.#t} WHERE ${col} IN ${params.ids}`
		}
	}
 
	/**
	 * Drop the backing YDB table (`DROP TABLE IF EXISTS`).
	 * All data is permanently deleted. The store can be reused after this call —
	 * the next operation re-creates the table (without re-dropping).
	 */
	async drop(): Promise<void> {
		this.#assertOpen()
		await this.#driver.ready()
		// Wait out any in-flight CREATE TABLE so DROP doesn't race with it.
		try {
			await this.#promise
		} catch {
			// Init failure is irrelevant — we're dropping the table anyway.
		}
		await this.#sql`DROP TABLE IF EXISTS ${this.#t}`
		this.#promise = undefined
	}
 
	/**
	 * Build a `vector_kmeans_tree` approximate nearest-neighbour index on the
	 * embedding column.
	 *
	 * **When to call:** after inserting the initial batch of documents and before
	 * the store goes into production.  Documents added after index creation are
	 * still searchable — YDB scans them without the index, then merges results.
	 *
	 * **Trade-off:** the index enables sub-linear search at the cost of recall.
	 * Tune `indexConfigLevels`, `indexConfigClusters`, and
	 * `indexTreeSearchTopSize` to balance speed vs. accuracy.
	 *
	 * Requires `indexEnabled: true` AND `indexVectorDimension` in the store config.
	 * Throws if the index already exists (re-building requires dropping first).
	 */
	async createVectorIndex(): Promise<void> {
		if (!this.#indexConfig.isEnabled()) {
			throw new YDBVectorStoreOperationError(
				'Cannot create vector index: indexEnabled is false in config.'
			)
		}
		await this.#ensureTable()
 
		let index = this.#indexConfig
		await this.#sql`
			ALTER TABLE ${this.#t}
			ADD INDEX ${identifier(index.name)}
			GLOBAL USING vector_kmeans_tree
			ON (${identifier(this.#columnMap.embedding)})
			WITH (
				${this.#sql.unsafe(INDEX_STRATEGY_DDL[this.#strategy])},
				vector_type = 'float',
				vector_dimension=${this.#sql.unsafe(String(index.vectorDimension))},
				levels=${this.#sql.unsafe(String(index.levels))},
				clusters=${this.#sql.unsafe(String(index.clusters))}
			)
		`
	}
 
	/**
	 * Drain the QueryClient session pool, then close the Driver if this store
	 * owns it. Idempotent. When an external `driver` was provided, only the
	 * pool is released — the caller owns the Driver lifecycle.
	 */
	async close(): Promise<void> {
		Iif (this.#closed) return
		this.#closed = true
		await this.#sql[Symbol.asyncDispose]()
		Eif (this.#ownedDriver) {
			this.#driver.close()
		}
	}
 
	[Symbol.asyncDispose](): Promise<void> {
		return this.close()
	}
 
	/**
	 * Create a store, embed the provided texts, and insert them in one step.
	 *
	 * @param texts - Raw text strings to embed and store.
	 * @param metadatas - A single metadata object (applied to all texts) or one
	 *   object per text.
	 * @param embeddings - Embeddings model used to vectorise the texts.
	 * @param config - Store configuration (connection + table options).
	 * @returns A ready-to-use store containing the inserted documents.
	 */
	static override async fromTexts(
		texts: string[],
		metadatas: object | object[],
		embeddings: EmbeddingsInterface,
		config: YDBVectorStoreConfig
	): Promise<YDBVectorStore> {
		let docs = texts.map(
			(text, i) =>
				new Document({
					pageContent: text,
					metadata: Array.isArray(metadatas) ? metadatas[i] : metadatas,
				})
		)
		return YDBVectorStore.fromDocuments(docs, embeddings, config)
	}
 
	/**
	 * Create a store, embed the provided documents, and insert them in one step.
	 *
	 * @param docs - Documents to embed and store.
	 * @param embeddings - Embeddings model used to vectorise the document text.
	 * @param config - Store configuration (connection + table options).
	 * @returns A ready-to-use store containing the inserted documents.
	 */
	static override async fromDocuments(
		docs: Document[],
		embeddings: EmbeddingsInterface,
		config: YDBVectorStoreConfig
	): Promise<YDBVectorStore> {
		let store = new YDBVectorStore(embeddings, config)
		await store.addDocuments(docs)
		return store
	}
 
	/**
	 * Connect to an existing YDB table without running `CREATE TABLE`.
	 *
	 * Use this when the table was created by a previous store instance or by
	 * external tooling and you want to search or insert without re-initialising
	 * the schema. `columnMap`, `strategy`, and any index options must match how
	 * the table was originally created.
	 *
	 * @param embeddings - Embeddings model (must produce the same dimension as
	 *   the stored vectors).
	 * @param config - Store configuration. `table` is required;
	 *   `dropExistingTable` is forbidden — call the constructor if you need to
	 *   drop.
	 * @returns A store instance whose table-creation step is already marked done.
	 */
	static fromExistingTable(
		embeddings: EmbeddingsInterface,
		config: YDBVectorStoreExistingTableConfig
	): YDBVectorStore {
		let store = new YDBVectorStore(embeddings, config)
		store.#markInitialized()
		return store
	}
 
	#markInitialized(): void {
		this.#promise = Promise.resolve()
	}
}