ydb-qdrant 4.5.1 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  [![Build](https://img.shields.io/github/actions/workflow/status/astandrik/ydb-qdrant/ci-build.yml?branch=main&label=build)](https://github.com/astandrik/ydb-qdrant/actions/workflows/ci-build.yml)
4
4
  [![Tests](https://img.shields.io/github/actions/workflow/status/astandrik/ydb-qdrant/ci-tests.yml?branch=main&label=tests)](https://github.com/astandrik/ydb-qdrant/actions/workflows/ci-tests.yml)
5
5
  [![Integration Tests](https://img.shields.io/github/actions/workflow/status/astandrik/ydb-qdrant/ci-integration.yml?branch=main&label=integration%20tests)](https://github.com/astandrik/ydb-qdrant/actions/workflows/ci-integration.yml)
6
+ [![k6 Soak Load Test](https://img.shields.io/github/actions/workflow/status/astandrik/ydb-qdrant/ci-load-soak.yml?branch=main&label=k6%20soak%20load%20test)](https://github.com/astandrik/ydb-qdrant/actions/workflows/ci-load-soak.yml)
7
+ [![k6 Stress Load Test](https://img.shields.io/github/actions/workflow/status/astandrik/ydb-qdrant/ci-load-stress.yml?branch=main&label=k6%20stress%20load%20test)](https://github.com/astandrik/ydb-qdrant/actions/workflows/ci-load-stress.yml)
6
8
  [![Coverage](https://coveralls.io/repos/github/astandrik/ydb-qdrant/badge.svg?branch=main)](https://coveralls.io/github/astandrik/ydb-qdrant?branch=main)
7
9
 
8
10
  [![Recall (multi_table)](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astandrik/ydb-qdrant/recall-badges/recall-multi-table.json)](https://github.com/astandrik/ydb-qdrant/actions/workflows/ci-recall.yml)
@@ -90,6 +92,9 @@ export PORT=8080
90
92
  export LOG_LEVEL=info
91
93
  # Collection storage mode (optional; default is multi_table)
92
94
  export YDB_QDRANT_COLLECTION_STORAGE_MODE=multi_table # or one_table
95
+ # One-table search tuning (one_table mode only)
96
+ export YDB_QDRANT_SEARCH_MODE=approximate # or exact
97
+ export YDB_QDRANT_OVERFETCH_MULTIPLIER=10 # candidate multiplier in approximate mode
93
98
  ```
94
99
 
95
100
  ## Use as a Node.js library (npm package)
@@ -11,3 +11,11 @@ export declare const COLLECTION_STORAGE_MODE: CollectionStorageMode;
11
11
  export declare const GLOBAL_POINTS_AUTOMIGRATE_ENABLED: boolean;
12
12
  export declare const VECTOR_INDEX_BUILD_ENABLED: boolean;
13
13
  export declare function isOneTableMode(mode: CollectionStorageMode): mode is CollectionStorageMode.OneTable;
14
+ export declare enum SearchMode {
15
+ Exact = "exact",
16
+ Approximate = "approximate"
17
+ }
18
+ export declare const SEARCH_MODE: SearchMode;
19
+ export declare const OVERFETCH_MULTIPLIER: number;
20
+ export declare const CLIENT_SIDE_SERIALIZATION_ENABLED: boolean;
21
+ export declare const UPSERT_BATCH_SIZE: number;
@@ -3,6 +3,23 @@ export const YDB_ENDPOINT = process.env.YDB_ENDPOINT ?? "";
3
3
  export const YDB_DATABASE = process.env.YDB_DATABASE ?? "";
4
4
  export const PORT = process.env.PORT ? Number(process.env.PORT) : 8080;
5
5
  export const LOG_LEVEL = process.env.LOG_LEVEL ?? "info";
6
+ function parseIntegerEnv(value, defaultValue, opts) {
7
+ if (value === undefined) {
8
+ return defaultValue;
9
+ }
10
+ const parsed = Number.parseInt(value.trim(), 10);
11
+ if (!Number.isFinite(parsed)) {
12
+ return defaultValue;
13
+ }
14
+ let result = parsed;
15
+ if (opts?.min !== undefined && result < opts.min) {
16
+ result = opts.min;
17
+ }
18
+ if (opts?.max !== undefined && result > opts.max) {
19
+ result = opts.max;
20
+ }
21
+ return result;
22
+ }
6
23
  function parseBooleanEnv(value, defaultValue) {
7
24
  if (value === undefined) {
8
25
  return defaultValue;
@@ -36,3 +53,28 @@ export const VECTOR_INDEX_BUILD_ENABLED = parseBooleanEnv(process.env.VECTOR_IND
36
53
  export function isOneTableMode(mode) {
37
54
  return mode === CollectionStorageMode.OneTable;
38
55
  }
56
+ export var SearchMode;
57
+ (function (SearchMode) {
58
+ SearchMode["Exact"] = "exact";
59
+ SearchMode["Approximate"] = "approximate";
60
+ })(SearchMode || (SearchMode = {}));
61
+ function resolveSearchModeEnv(mode) {
62
+ const raw = process.env.YDB_QDRANT_SEARCH_MODE;
63
+ const normalized = raw?.trim().toLowerCase();
64
+ if (normalized === SearchMode.Exact) {
65
+ return SearchMode.Exact;
66
+ }
67
+ if (normalized === SearchMode.Approximate) {
68
+ return SearchMode.Approximate;
69
+ }
70
+ // Default: keep current behavior for one-table (approximate two-phase search).
71
+ if (isOneTableMode(mode)) {
72
+ return SearchMode.Approximate;
73
+ }
74
+ // For multi-table, this value is currently unused but defaults to approximate.
75
+ return SearchMode.Approximate;
76
+ }
77
+ export const SEARCH_MODE = resolveSearchModeEnv(COLLECTION_STORAGE_MODE);
78
+ export const OVERFETCH_MULTIPLIER = parseIntegerEnv(process.env.YDB_QDRANT_OVERFETCH_MULTIPLIER, 10, { min: 1 });
79
+ export const CLIENT_SIDE_SERIALIZATION_ENABLED = parseBooleanEnv(process.env.YDB_QDRANT_CLIENT_SIDE_SERIALIZATION_ENABLED, false);
80
+ export const UPSERT_BATCH_SIZE = parseIntegerEnv(process.env.YDB_QDRANT_UPSERT_BATCH_SIZE, 100, { min: 1 });
@@ -1,4 +1,5 @@
1
1
  import { upsertPointsMultiTable, searchPointsMultiTable, deletePointsMultiTable, } from "./pointsRepo.multi-table.js";
2
+ import { SEARCH_MODE, OVERFETCH_MULTIPLIER, } from "../config/env.js";
2
3
  import { upsertPointsOneTable, searchPointsOneTable, deletePointsOneTable, } from "./pointsRepo.one-table.js";
3
4
  export async function upsertPoints(tableName, points, dimension, uid) {
4
5
  if (uid) {
@@ -7,8 +8,9 @@ export async function upsertPoints(tableName, points, dimension, uid) {
7
8
  return await upsertPointsMultiTable(tableName, points, dimension);
8
9
  }
9
10
  export async function searchPoints(tableName, queryVector, top, withPayload, distance, dimension, uid) {
11
+ const mode = SEARCH_MODE;
10
12
  if (uid) {
11
- return await searchPointsOneTable(tableName, queryVector, top, withPayload, distance, dimension, uid);
13
+ return await searchPointsOneTable(tableName, queryVector, top, withPayload, distance, dimension, uid, mode, OVERFETCH_MULTIPLIER);
12
14
  }
13
15
  return await searchPointsMultiTable(tableName, queryVector, top, withPayload, distance, dimension);
14
16
  }
@@ -1,39 +1,54 @@
1
- import { TypedValues, withSession } from "../ydb/client.js";
2
- import { buildJsonOrEmpty, buildVectorParam } from "../ydb/helpers.js";
1
+ import { Types, TypedValues, withSession } from "../ydb/client.js";
2
+ import { buildVectorParam } from "../ydb/helpers.js";
3
3
  import { logger } from "../logging/logger.js";
4
4
  import { notifyUpsert } from "../indexing/IndexScheduler.js";
5
5
  import { VECTOR_INDEX_BUILD_ENABLED } from "../config/env.js";
6
6
  import { mapDistanceToKnnFn } from "../utils/distance.js";
7
7
  import { withRetry, isTransientYdbError } from "../utils/retry.js";
8
+ import { UPSERT_BATCH_SIZE } from "../ydb/schema.js";
8
9
  export async function upsertPointsMultiTable(tableName, points, dimension) {
10
+ for (const p of points) {
11
+ const id = String(p.id);
12
+ if (p.vector.length !== dimension) {
13
+ throw new Error(`Vector dimension mismatch for id=${id}: got ${p.vector.length}, expected ${dimension}`);
14
+ }
15
+ }
9
16
  let upserted = 0;
10
17
  await withSession(async (s) => {
11
- for (const p of points) {
12
- const id = String(p.id);
13
- if (p.vector.length !== dimension) {
14
- throw new Error(`Vector dimension mismatch for id=${id}: got ${p.vector.length}, expected ${dimension}`);
15
- }
18
+ for (let i = 0; i < points.length; i += UPSERT_BATCH_SIZE) {
19
+ const batch = points.slice(i, i + UPSERT_BATCH_SIZE);
16
20
  const ddl = `
17
- DECLARE $id AS Utf8;
18
- DECLARE $vec AS List<Float>;
19
- DECLARE $payload AS JsonDocument;
21
+ DECLARE $rows AS List<Struct<
22
+ point_id: Utf8,
23
+ vec: List<Float>,
24
+ payload: JsonDocument
25
+ >>;
26
+
20
27
  UPSERT INTO ${tableName} (point_id, embedding, payload)
21
- VALUES (
22
- $id,
23
- Untag(Knn::ToBinaryStringFloat($vec), "FloatVector"),
24
- $payload
25
- );
28
+ SELECT
29
+ point_id,
30
+ Untag(Knn::ToBinaryStringFloat(vec), "FloatVector") AS embedding,
31
+ payload
32
+ FROM AS_TABLE($rows);
26
33
  `;
34
+ const rowType = Types.struct({
35
+ point_id: Types.UTF8,
36
+ vec: Types.list(Types.FLOAT),
37
+ payload: Types.JSON_DOCUMENT,
38
+ });
39
+ const rowsValue = TypedValues.list(rowType, batch.map((p) => ({
40
+ point_id: String(p.id),
41
+ vec: p.vector,
42
+ payload: JSON.stringify(p.payload ?? {}),
43
+ })));
27
44
  const params = {
28
- $id: TypedValues.utf8(id),
29
- $vec: buildVectorParam(p.vector),
30
- $payload: buildJsonOrEmpty(p.payload),
45
+ $rows: rowsValue,
31
46
  };
32
47
  await withRetry(() => s.executeQuery(ddl, params), {
33
48
  isTransient: isTransientYdbError,
34
- context: { tableName, id },
49
+ context: { tableName, batchSize: batch.length },
35
50
  });
36
- upserted += 1;
51
+ upserted += batch.length;
37
52
  }
38
53
  });
39
54
  notifyUpsert(tableName, upserted);
@@ -1,10 +1,11 @@
1
1
  import type { DistanceKind } from "../types";
2
+ import { SearchMode } from "../config/env.js";
2
3
  export declare function upsertPointsOneTable(tableName: string, points: Array<{
3
4
  id: string | number;
4
5
  vector: number[];
5
6
  payload?: Record<string, unknown>;
6
7
  }>, dimension: number, uid: string): Promise<number>;
7
- export declare function searchPointsOneTable(tableName: string, queryVector: number[], top: number, withPayload: boolean | undefined, distance: DistanceKind, dimension: number, uid: string): Promise<Array<{
8
+ export declare function searchPointsOneTable(tableName: string, queryVector: number[], top: number, withPayload: boolean | undefined, distance: DistanceKind, dimension: number, uid: string, mode: SearchMode | undefined, overfetchMultiplier: number): Promise<Array<{
8
9
  id: string;
9
10
  score: number;
10
11
  payload?: Record<string, unknown>;
@@ -1,56 +1,345 @@
1
1
  import { TypedValues, Types, withSession } from "../ydb/client.js";
2
- import { buildJsonOrEmpty, buildVectorParam } from "../ydb/helpers.js";
2
+ import { buildVectorParam, buildVectorBinaryParams } from "../ydb/helpers.js";
3
3
  import { notifyUpsert } from "../indexing/IndexScheduler.js";
4
4
  import { mapDistanceToKnnFn, mapDistanceToBitKnnFn, } from "../utils/distance.js";
5
5
  import { withRetry, isTransientYdbError } from "../utils/retry.js";
6
+ import { UPSERT_BATCH_SIZE } from "../ydb/schema.js";
7
+ import { CLIENT_SIDE_SERIALIZATION_ENABLED, SearchMode, } from "../config/env.js";
8
+ import { logger } from "../logging/logger.js";
6
9
  export async function upsertPointsOneTable(tableName, points, dimension, uid) {
10
+ for (const p of points) {
11
+ const id = String(p.id);
12
+ if (p.vector.length !== dimension) {
13
+ const previewLength = Math.min(16, p.vector.length);
14
+ const vectorPreview = previewLength > 0 ? p.vector.slice(0, previewLength) : [];
15
+ logger.warn({
16
+ tableName,
17
+ uid,
18
+ pointId: id,
19
+ vectorLen: p.vector.length,
20
+ expectedDimension: dimension,
21
+ vectorPreview,
22
+ }, "upsertPointsOneTable: vector dimension mismatch");
23
+ throw new Error(`Vector dimension mismatch for id=${id}: got ${p.vector.length}, expected ${dimension}`);
24
+ }
25
+ }
7
26
  let upserted = 0;
8
27
  await withSession(async (s) => {
9
- for (const p of points) {
10
- const id = String(p.id);
11
- if (p.vector.length !== dimension) {
12
- throw new Error(`Vector dimension mismatch for id=${id}: got ${p.vector.length}, expected ${dimension}`);
28
+ for (let i = 0; i < points.length; i += UPSERT_BATCH_SIZE) {
29
+ const batch = points.slice(i, i + UPSERT_BATCH_SIZE);
30
+ let ddl;
31
+ let params;
32
+ if (CLIENT_SIDE_SERIALIZATION_ENABLED) {
33
+ ddl = `
34
+ DECLARE $rows AS List<Struct<
35
+ uid: Utf8,
36
+ point_id: Utf8,
37
+ embedding: String,
38
+ embedding_quantized: String,
39
+ payload: JsonDocument
40
+ >>;
41
+
42
+ UPSERT INTO ${tableName} (uid, point_id, embedding, embedding_quantized, payload)
43
+ SELECT
44
+ uid,
45
+ point_id,
46
+ embedding,
47
+ embedding_quantized,
48
+ payload
49
+ FROM AS_TABLE($rows);
50
+ `;
51
+ const rowType = Types.struct({
52
+ uid: Types.UTF8,
53
+ point_id: Types.UTF8,
54
+ embedding: Types.BYTES,
55
+ embedding_quantized: Types.BYTES,
56
+ payload: Types.JSON_DOCUMENT,
57
+ });
58
+ const rowsValue = TypedValues.list(rowType, batch.map((p) => {
59
+ const binaries = buildVectorBinaryParams(p.vector);
60
+ return {
61
+ uid,
62
+ point_id: String(p.id),
63
+ embedding: binaries.float,
64
+ embedding_quantized: binaries.bit,
65
+ payload: JSON.stringify(p.payload ?? {}),
66
+ };
67
+ }));
68
+ params = {
69
+ $rows: rowsValue,
70
+ };
13
71
  }
14
- const ddl = `
15
- DECLARE $uid AS Utf8;
16
- DECLARE $id AS Utf8;
17
- DECLARE $vec AS List<Float>;
18
- DECLARE $payload AS JsonDocument;
19
- UPSERT INTO ${tableName} (uid, point_id, embedding, embedding_bit, payload)
20
- VALUES (
21
- $uid,
22
- $id,
23
- Untag(Knn::ToBinaryStringFloat($vec), "FloatVector"),
24
- Untag(Knn::ToBinaryStringBit($vec), "BitVector"),
25
- $payload
26
- );
27
- `;
28
- const params = {
29
- $uid: TypedValues.utf8(uid),
30
- $id: TypedValues.utf8(id),
31
- $vec: buildVectorParam(p.vector),
32
- $payload: buildJsonOrEmpty(p.payload),
33
- };
72
+ else {
73
+ ddl = `
74
+ DECLARE $rows AS List<Struct<
75
+ uid: Utf8,
76
+ point_id: Utf8,
77
+ vec: List<Float>,
78
+ payload: JsonDocument
79
+ >>;
80
+
81
+ UPSERT INTO ${tableName} (uid, point_id, embedding, embedding_quantized, payload)
82
+ SELECT
83
+ uid,
84
+ point_id,
85
+ Untag(Knn::ToBinaryStringFloat(vec), "FloatVector") AS embedding,
86
+ Untag(Knn::ToBinaryStringBit(vec), "BitVector") AS embedding_quantized,
87
+ payload
88
+ FROM AS_TABLE($rows);
89
+ `;
90
+ const rowType = Types.struct({
91
+ uid: Types.UTF8,
92
+ point_id: Types.UTF8,
93
+ vec: Types.list(Types.FLOAT),
94
+ payload: Types.JSON_DOCUMENT,
95
+ });
96
+ const rowsValue = TypedValues.list(rowType, batch.map((p) => ({
97
+ uid,
98
+ point_id: String(p.id),
99
+ vec: p.vector,
100
+ payload: JSON.stringify(p.payload ?? {}),
101
+ })));
102
+ params = {
103
+ $rows: rowsValue,
104
+ };
105
+ }
106
+ logger.debug({
107
+ tableName,
108
+ mode: CLIENT_SIDE_SERIALIZATION_ENABLED
109
+ ? "one_table_upsert_client_side_serialization"
110
+ : "one_table_upsert_server_side_knn",
111
+ batchSize: batch.length,
112
+ yql: ddl,
113
+ params: {
114
+ rows: batch.map((p) => ({
115
+ uid,
116
+ point_id: String(p.id),
117
+ vectorLength: p.vector.length,
118
+ vectorPreview: p.vector.slice(0, 3),
119
+ payload: p.payload ?? {},
120
+ })),
121
+ },
122
+ }, "one_table upsert: executing YQL");
34
123
  await withRetry(() => s.executeQuery(ddl, params), {
35
124
  isTransient: isTransientYdbError,
36
- context: { tableName, id },
125
+ context: { tableName, batchSize: batch.length },
37
126
  });
38
- upserted += 1;
127
+ upserted += batch.length;
39
128
  }
40
129
  });
41
130
  notifyUpsert(tableName, upserted);
42
131
  return upserted;
43
132
  }
44
- export async function searchPointsOneTable(tableName, queryVector, top, withPayload, distance, dimension, uid) {
133
+ async function searchPointsOneTableExact(tableName, queryVector, top, withPayload, distance, dimension, uid) {
134
+ if (queryVector.length !== dimension) {
135
+ throw new Error(`Vector dimension mismatch: got ${queryVector.length}, expected ${dimension}`);
136
+ }
137
+ const { fn, order } = mapDistanceToKnnFn(distance);
138
+ const results = await withSession(async (s) => {
139
+ let yql;
140
+ let params;
141
+ if (CLIENT_SIDE_SERIALIZATION_ENABLED) {
142
+ const binaries = buildVectorBinaryParams(queryVector);
143
+ yql = `
144
+ DECLARE $qbinf AS String;
145
+ DECLARE $k AS Uint32;
146
+ DECLARE $uid AS Utf8;
147
+ SELECT point_id, ${withPayload ? "payload, " : ""}${fn}(embedding, $qbinf) AS score
148
+ FROM ${tableName}
149
+ WHERE uid = $uid
150
+ ORDER BY score ${order}
151
+ LIMIT $k;
152
+ `;
153
+ params = {
154
+ $qbinf: typeof TypedValues.bytes === "function"
155
+ ? TypedValues.bytes(binaries.float)
156
+ : binaries.float,
157
+ $k: TypedValues.uint32(top),
158
+ $uid: TypedValues.utf8(uid),
159
+ };
160
+ }
161
+ else {
162
+ const qf = buildVectorParam(queryVector);
163
+ yql = `
164
+ DECLARE $qf AS List<Float>;
165
+ DECLARE $k AS Uint32;
166
+ DECLARE $uid AS Utf8;
167
+ $qbinf = Knn::ToBinaryStringFloat($qf);
168
+ SELECT point_id, ${withPayload ? "payload, " : ""}${fn}(embedding, $qbinf) AS score
169
+ FROM ${tableName}
170
+ WHERE uid = $uid
171
+ ORDER BY score ${order}
172
+ LIMIT $k;
173
+ `;
174
+ params = {
175
+ $qf: qf,
176
+ $k: TypedValues.uint32(top),
177
+ $uid: TypedValues.utf8(uid),
178
+ };
179
+ }
180
+ logger.debug({
181
+ tableName,
182
+ distance,
183
+ top,
184
+ withPayload,
185
+ mode: CLIENT_SIDE_SERIALIZATION_ENABLED
186
+ ? "one_table_exact_client_side_serialization"
187
+ : "one_table_exact",
188
+ yql,
189
+ params: {
190
+ uid,
191
+ top,
192
+ vectorLength: queryVector.length,
193
+ vectorPreview: queryVector.slice(0, 3),
194
+ },
195
+ }, "one_table search (exact): executing YQL");
196
+ const rs = await s.executeQuery(yql, params);
197
+ const rowset = rs.resultSets?.[0];
198
+ const rows = (rowset?.rows ?? []);
199
+ return rows.map((row) => {
200
+ const id = row.items?.[0]?.textValue;
201
+ if (typeof id !== "string") {
202
+ throw new Error("point_id is missing in YDB search result");
203
+ }
204
+ let payload;
205
+ let scoreIdx = 1;
206
+ if (withPayload) {
207
+ const payloadText = row.items?.[1]?.textValue;
208
+ if (payloadText) {
209
+ try {
210
+ payload = JSON.parse(payloadText);
211
+ }
212
+ catch {
213
+ payload = undefined;
214
+ }
215
+ }
216
+ scoreIdx = 2;
217
+ }
218
+ const score = Number(row.items?.[scoreIdx]?.floatValue ?? row.items?.[scoreIdx]?.textValue);
219
+ return { id, score, ...(payload ? { payload } : {}) };
220
+ });
221
+ });
222
+ return results;
223
+ }
224
+ async function searchPointsOneTableApproximate(tableName, queryVector, top, withPayload, distance, dimension, uid, overfetchMultiplier) {
45
225
  if (queryVector.length !== dimension) {
46
226
  throw new Error(`Vector dimension mismatch: got ${queryVector.length}, expected ${dimension}`);
47
227
  }
48
228
  const { fn, order } = mapDistanceToKnnFn(distance);
49
229
  const { fn: bitFn, order: bitOrder } = mapDistanceToBitKnnFn(distance);
50
- const qf = buildVectorParam(queryVector);
51
- const candidateLimit = top * 10;
230
+ const safeTop = top > 0 ? top : 1;
231
+ const rawCandidateLimit = safeTop * overfetchMultiplier;
232
+ const candidateLimit = Math.max(safeTop, rawCandidateLimit);
52
233
  const results = await withSession(async (s) => {
53
- // Phase 1: approximate candidate selection using embedding_bit
234
+ if (CLIENT_SIDE_SERIALIZATION_ENABLED) {
235
+ const binaries = buildVectorBinaryParams(queryVector);
236
+ // Phase 1: approximate candidate selection using embedding_quantized
237
+ const phase1Query = `
238
+ DECLARE $qbin_bit AS String;
239
+ DECLARE $k AS Uint32;
240
+ DECLARE $uid AS Utf8;
241
+ SELECT point_id
242
+ FROM ${tableName}
243
+ WHERE uid = $uid AND embedding_quantized IS NOT NULL
244
+ ORDER BY ${bitFn}(embedding_quantized, $qbin_bit) ${bitOrder}
245
+ LIMIT $k;
246
+ `;
247
+ logger.debug({
248
+ tableName,
249
+ distance,
250
+ top,
251
+ safeTop,
252
+ candidateLimit,
253
+ mode: "one_table_approximate_phase1_client_side_serialization",
254
+ yql: phase1Query,
255
+ params: {
256
+ uid,
257
+ top: candidateLimit,
258
+ vectorLength: queryVector.length,
259
+ vectorPreview: queryVector.slice(0, 3),
260
+ },
261
+ }, "one_table search (approximate, phase 1): executing YQL");
262
+ const phase1Params = {
263
+ $qbin_bit: typeof TypedValues.bytes === "function"
264
+ ? TypedValues.bytes(binaries.bit)
265
+ : binaries.bit,
266
+ $k: TypedValues.uint32(candidateLimit),
267
+ $uid: TypedValues.utf8(uid),
268
+ };
269
+ const rs1 = await s.executeQuery(phase1Query, phase1Params);
270
+ const rowset1 = rs1.resultSets?.[0];
271
+ const rows1 = (rowset1?.rows ?? []);
272
+ const candidateIds = rows1
273
+ .map((row) => row.items?.[0]?.textValue)
274
+ .filter((id) => typeof id === "string");
275
+ if (candidateIds.length === 0) {
276
+ return [];
277
+ }
278
+ // Phase 2: exact re-ranking on full-precision embedding for candidates only
279
+ const phase2Query = `
280
+ DECLARE $qbinf AS String;
281
+ DECLARE $k AS Uint32;
282
+ DECLARE $uid AS Utf8;
283
+ DECLARE $ids AS List<Utf8>;
284
+ SELECT point_id, ${withPayload ? "payload, " : ""}${fn}(embedding, $qbinf) AS score
285
+ FROM ${tableName}
286
+ WHERE uid = $uid AND point_id IN $ids
287
+ ORDER BY score ${order}
288
+ LIMIT $k;
289
+ `;
290
+ logger.debug({
291
+ tableName,
292
+ distance,
293
+ top,
294
+ safeTop,
295
+ candidateCount: candidateIds.length,
296
+ mode: "one_table_approximate_phase2_client_side_serialization",
297
+ yql: phase2Query,
298
+ params: {
299
+ uid,
300
+ top: safeTop,
301
+ vectorLength: queryVector.length,
302
+ vectorPreview: queryVector.slice(0, 3),
303
+ ids: candidateIds,
304
+ },
305
+ }, "one_table search (approximate, phase 2): executing YQL");
306
+ const idsParam = TypedValues.list(Types.UTF8, candidateIds);
307
+ const phase2Params = {
308
+ $qbinf: typeof TypedValues.bytes === "function"
309
+ ? TypedValues.bytes(binaries.float)
310
+ : binaries.float,
311
+ $k: TypedValues.uint32(safeTop),
312
+ $uid: TypedValues.utf8(uid),
313
+ $ids: idsParam,
314
+ };
315
+ const rs2 = await s.executeQuery(phase2Query, phase2Params);
316
+ const rowset2 = rs2.resultSets?.[0];
317
+ const rows2 = (rowset2?.rows ?? []);
318
+ return rows2.map((row) => {
319
+ const id = row.items?.[0]?.textValue;
320
+ if (typeof id !== "string") {
321
+ throw new Error("point_id is missing in YDB search result");
322
+ }
323
+ let payload;
324
+ let scoreIdx = 1;
325
+ if (withPayload) {
326
+ const payloadText = row.items?.[1]?.textValue;
327
+ if (payloadText) {
328
+ try {
329
+ payload = JSON.parse(payloadText);
330
+ }
331
+ catch {
332
+ payload = undefined;
333
+ }
334
+ }
335
+ scoreIdx = 2;
336
+ }
337
+ const score = Number(row.items?.[scoreIdx]?.floatValue ?? row.items?.[scoreIdx]?.textValue);
338
+ return { id, score, ...(payload ? { payload } : {}) };
339
+ });
340
+ }
341
+ const qf = buildVectorParam(queryVector);
342
+ // Phase 1: approximate candidate selection using embedding_quantized
54
343
  const phase1Query = `
55
344
  DECLARE $qf AS List<Float>;
56
345
  DECLARE $k AS Uint32;
@@ -58,10 +347,25 @@ export async function searchPointsOneTable(tableName, queryVector, top, withPayl
58
347
  $qbin_bit = Knn::ToBinaryStringBit($qf);
59
348
  SELECT point_id
60
349
  FROM ${tableName}
61
- WHERE uid = $uid AND embedding_bit IS NOT NULL
62
- ORDER BY ${bitFn}(embedding_bit, $qbin_bit) ${bitOrder}
350
+ WHERE uid = $uid AND embedding_quantized IS NOT NULL
351
+ ORDER BY ${bitFn}(embedding_quantized, $qbin_bit) ${bitOrder}
63
352
  LIMIT $k;
64
353
  `;
354
+ logger.debug({
355
+ tableName,
356
+ distance,
357
+ top,
358
+ safeTop,
359
+ candidateLimit,
360
+ mode: "one_table_approximate_phase1",
361
+ yql: phase1Query,
362
+ params: {
363
+ uid,
364
+ top: candidateLimit,
365
+ vectorLength: queryVector.length,
366
+ vectorPreview: queryVector.slice(0, 3),
367
+ },
368
+ }, "one_table search (approximate, phase 1): executing YQL");
65
369
  const phase1Params = {
66
370
  $qf: qf,
67
371
  $k: TypedValues.uint32(candidateLimit),
@@ -89,10 +393,26 @@ export async function searchPointsOneTable(tableName, queryVector, top, withPayl
89
393
  ORDER BY score ${order}
90
394
  LIMIT $k;
91
395
  `;
396
+ logger.debug({
397
+ tableName,
398
+ distance,
399
+ top,
400
+ safeTop,
401
+ candidateCount: candidateIds.length,
402
+ mode: "one_table_approximate_phase2",
403
+ yql: phase2Query,
404
+ params: {
405
+ uid,
406
+ top: safeTop,
407
+ vectorLength: queryVector.length,
408
+ vectorPreview: queryVector.slice(0, 3),
409
+ ids: candidateIds,
410
+ },
411
+ }, "one_table search (approximate, phase 2): executing YQL");
92
412
  const idsParam = TypedValues.list(Types.UTF8, candidateIds);
93
413
  const phase2Params = {
94
414
  $qf: qf,
95
- $k: TypedValues.uint32(top),
415
+ $k: TypedValues.uint32(safeTop),
96
416
  $uid: TypedValues.utf8(uid),
97
417
  $ids: idsParam,
98
418
  };
@@ -124,6 +444,12 @@ export async function searchPointsOneTable(tableName, queryVector, top, withPayl
124
444
  });
125
445
  return results;
126
446
  }
447
+ export async function searchPointsOneTable(tableName, queryVector, top, withPayload, distance, dimension, uid, mode, overfetchMultiplier) {
448
+ if (mode === SearchMode.Exact) {
449
+ return await searchPointsOneTableExact(tableName, queryVector, top, withPayload, distance, dimension, uid);
450
+ }
451
+ return await searchPointsOneTableApproximate(tableName, queryVector, top, withPayload, distance, dimension, uid, overfetchMultiplier);
452
+ }
127
453
  export async function deletePointsOneTable(tableName, ids, uid) {
128
454
  let deleted = 0;
129
455
  await withSession(async (s) => {
@@ -9,6 +9,7 @@ collectionsRouter.put("/:collection/index", async (req, res) => {
9
9
  tenant: req.header("X-Tenant-Id") ?? undefined,
10
10
  collection: String(req.params.collection),
11
11
  apiKey: req.header("api-key") ?? undefined,
12
+ userAgent: req.header("User-Agent") ?? undefined,
12
13
  });
13
14
  res.json({ status: "ok", result });
14
15
  }
@@ -27,6 +28,7 @@ collectionsRouter.put("/:collection", async (req, res) => {
27
28
  tenant: req.header("X-Tenant-Id") ?? undefined,
28
29
  collection: String(req.params.collection),
29
30
  apiKey: req.header("api-key") ?? undefined,
31
+ userAgent: req.header("User-Agent") ?? undefined,
30
32
  }, req.body);
31
33
  res.json({ status: "ok", result });
32
34
  }
@@ -45,6 +47,7 @@ collectionsRouter.get("/:collection", async (req, res) => {
45
47
  tenant: req.header("X-Tenant-Id") ?? undefined,
46
48
  collection: String(req.params.collection),
47
49
  apiKey: req.header("api-key") ?? undefined,
50
+ userAgent: req.header("User-Agent") ?? undefined,
48
51
  });
49
52
  res.json({ status: "ok", result });
50
53
  }
@@ -63,6 +66,7 @@ collectionsRouter.delete("/:collection", async (req, res) => {
63
66
  tenant: req.header("X-Tenant-Id") ?? undefined,
64
67
  collection: String(req.params.collection),
65
68
  apiKey: req.header("api-key") ?? undefined,
69
+ userAgent: req.header("User-Agent") ?? undefined,
66
70
  });
67
71
  res.json({ status: "ok", result });
68
72
  }
@@ -10,6 +10,7 @@ pointsRouter.put("/:collection/points", async (req, res) => {
10
10
  tenant: req.header("X-Tenant-Id") ?? undefined,
11
11
  collection: String(req.params.collection),
12
12
  apiKey: req.header("api-key") ?? undefined,
13
+ userAgent: req.header("User-Agent") ?? undefined,
13
14
  }, req.body);
14
15
  res.json({ status: "ok", result });
15
16
  }
@@ -28,6 +29,7 @@ pointsRouter.post("/:collection/points/upsert", async (req, res) => {
28
29
  tenant: req.header("X-Tenant-Id") ?? undefined,
29
30
  collection: String(req.params.collection),
30
31
  apiKey: req.header("api-key") ?? undefined,
32
+ userAgent: req.header("User-Agent") ?? undefined,
31
33
  }, req.body);
32
34
  res.json({ status: "ok", result });
33
35
  }
@@ -46,6 +48,7 @@ pointsRouter.post("/:collection/points/search", async (req, res) => {
46
48
  tenant: req.header("X-Tenant-Id") ?? undefined,
47
49
  collection: String(req.params.collection),
48
50
  apiKey: req.header("api-key") ?? undefined,
51
+ userAgent: req.header("User-Agent") ?? undefined,
49
52
  }, req.body);
50
53
  res.json({ status: "ok", result });
51
54
  }
@@ -65,6 +68,7 @@ pointsRouter.post("/:collection/points/query", async (req, res) => {
65
68
  tenant: req.header("X-Tenant-Id") ?? undefined,
66
69
  collection: String(req.params.collection),
67
70
  apiKey: req.header("api-key") ?? undefined,
71
+ userAgent: req.header("User-Agent") ?? undefined,
68
72
  }, req.body);
69
73
  res.json({ status: "ok", result });
70
74
  }
@@ -83,6 +87,7 @@ pointsRouter.post("/:collection/points/delete", async (req, res) => {
83
87
  tenant: req.header("X-Tenant-Id") ?? undefined,
84
88
  collection: String(req.params.collection),
85
89
  apiKey: req.header("api-key") ?? undefined,
90
+ userAgent: req.header("User-Agent") ?? undefined,
86
91
  }, req.body);
87
92
  res.json({ status: "ok", result });
88
93
  }
@@ -3,6 +3,7 @@ export interface CollectionContextInput {
3
3
  tenant: string | undefined;
4
4
  collection: string;
5
5
  apiKey?: string;
6
+ userAgent?: string;
6
7
  }
7
8
  export interface NormalizedCollectionContext {
8
9
  tenant: string;
@@ -5,7 +5,7 @@ import { QdrantServiceError } from "./errors.js";
5
5
  import { normalizeCollectionContextShared, tableNameFor, } from "./CollectionService.shared.js";
6
6
  import { resolvePointsTableAndUidOneTable } from "./CollectionService.one-table.js";
7
7
  export function normalizeCollectionContext(input) {
8
- return normalizeCollectionContextShared(input.tenant, input.collection, input.apiKey);
8
+ return normalizeCollectionContextShared(input.tenant, input.collection, input.apiKey, input.userAgent);
9
9
  }
10
10
  export async function resolvePointsTableAndUid(ctx, meta) {
11
11
  if (meta?.table === GLOBAL_POINTS_TABLE) {
@@ -4,7 +4,7 @@ export interface NormalizedCollectionContextLike {
4
4
  }
5
5
  export declare function tableNameFor(tenantId: string, collection: string): string;
6
6
  export declare function uidFor(tenantId: string, collection: string): string;
7
- export declare function normalizeCollectionContextShared(tenant: string | undefined, collection: string, apiKey?: string): {
7
+ export declare function normalizeCollectionContextShared(tenant: string | undefined, collection: string, apiKey?: string, userAgent?: string): {
8
8
  tenant: string;
9
9
  collection: string;
10
10
  metaKey: string;
@@ -1,14 +1,15 @@
1
- import { sanitizeCollectionName, sanitizeTenantId, metaKeyFor, tableNameFor as tableNameForInternal, uidFor as uidForInternal, hashApiKey, } from "../utils/tenant.js";
1
+ import { sanitizeCollectionName, sanitizeTenantId, metaKeyFor, tableNameFor as tableNameForInternal, uidFor as uidForInternal, hashApiKey, hashUserAgent, } from "../utils/tenant.js";
2
2
  export function tableNameFor(tenantId, collection) {
3
3
  return tableNameForInternal(tenantId, collection);
4
4
  }
5
5
  export function uidFor(tenantId, collection) {
6
6
  return uidForInternal(tenantId, collection);
7
7
  }
8
- export function normalizeCollectionContextShared(tenant, collection, apiKey) {
8
+ export function normalizeCollectionContextShared(tenant, collection, apiKey, userAgent) {
9
9
  const normalizedTenant = sanitizeTenantId(tenant);
10
10
  const apiKeyHash = hashApiKey(apiKey);
11
- const normalizedCollection = sanitizeCollectionName(collection, apiKeyHash);
11
+ const userAgentHash = hashUserAgent(userAgent);
12
+ const normalizedCollection = sanitizeCollectionName(collection, apiKeyHash, userAgentHash);
12
13
  const metaKey = metaKeyFor(normalizedTenant, normalizedCollection);
13
14
  return {
14
15
  tenant: normalizedTenant,
@@ -1,5 +1,6 @@
1
1
  export declare function hashApiKey(apiKey: string | undefined): string | undefined;
2
- export declare function sanitizeCollectionName(name: string, apiKeyHash?: string): string;
2
+ export declare function hashUserAgent(userAgent: string | undefined): string | undefined;
3
+ export declare function sanitizeCollectionName(name: string, apiKeyHash?: string, userAgentHash?: string): string;
3
4
  export declare function sanitizeTenantId(tenantId: string | undefined): string;
4
5
  export declare function tableNameFor(sanitizedTenant: string, sanitizedCollection: string): string;
5
6
  export declare function metaKeyFor(sanitizedTenant: string, sanitizedCollection: string): string;
@@ -5,12 +5,28 @@ export function hashApiKey(apiKey) {
5
5
  const hash = createHash("sha256").update(apiKey).digest("hex");
6
6
  return hash.slice(0, 8);
7
7
  }
8
- export function sanitizeCollectionName(name, apiKeyHash) {
8
+ export function hashUserAgent(userAgent) {
9
+ if (!userAgent || userAgent.trim() === "")
10
+ return undefined;
11
+ const hash = createHash("sha256").update(userAgent).digest("hex");
12
+ return hash.slice(0, 8);
13
+ }
14
+ export function sanitizeCollectionName(name, apiKeyHash, userAgentHash) {
9
15
  const cleaned = name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_");
10
16
  const lowered = cleaned.toLowerCase().replace(/^_+/, "");
11
17
  const base = lowered.length > 0 ? lowered : "collection";
12
- const hasHash = apiKeyHash !== undefined && apiKeyHash.trim().length > 0;
13
- return hasHash ? `${base}_${apiKeyHash}` : base;
18
+ const hasApiKey = apiKeyHash !== undefined && apiKeyHash.trim().length > 0;
19
+ const hasUserAgent = userAgentHash !== undefined && userAgentHash.trim().length > 0;
20
+ if (hasApiKey && hasUserAgent) {
21
+ return `${base}_${apiKeyHash}_${userAgentHash}`;
22
+ }
23
+ else if (hasApiKey) {
24
+ return `${base}_${apiKeyHash}`;
25
+ }
26
+ else if (hasUserAgent) {
27
+ return `${base}_${userAgentHash}`;
28
+ }
29
+ return base;
14
30
  }
15
31
  export function sanitizeTenantId(tenantId) {
16
32
  const raw = (tenantId ?? "default").toString();
@@ -0,0 +1,2 @@
1
+ export declare function vectorToFloatBinary(vector: number[]): Buffer;
2
+ export declare function vectorToBitBinary(vector: number[]): Buffer;
@@ -0,0 +1,35 @@
1
+ export function vectorToFloatBinary(vector) {
2
+ if (vector.length === 0) {
3
+ return Buffer.from([1]);
4
+ }
5
+ const buffer = Buffer.alloc(vector.length * 4 + 1);
6
+ for (let i = 0; i < vector.length; i += 1) {
7
+ const value = vector[i];
8
+ if (!Number.isFinite(value)) {
9
+ throw new Error(`Non-finite value in vector at index ${i}: ${value}`);
10
+ }
11
+ buffer.writeFloatLE(value, i * 4);
12
+ }
13
+ buffer.writeUInt8(1, vector.length * 4);
14
+ return buffer;
15
+ }
16
+ export function vectorToBitBinary(vector) {
17
+ if (vector.length === 0) {
18
+ return Buffer.from([10]);
19
+ }
20
+ const byteCount = Math.ceil(vector.length / 8);
21
+ const buffer = Buffer.alloc(byteCount + 1);
22
+ for (let i = 0; i < vector.length; i += 1) {
23
+ const value = vector[i];
24
+ if (!Number.isFinite(value)) {
25
+ throw new Error(`Non-finite value in vector at index ${i}: ${value}`);
26
+ }
27
+ if (value > 0) {
28
+ const byteIndex = Math.floor(i / 8);
29
+ const bitIndex = i % 8;
30
+ buffer[byteIndex] |= 1 << bitIndex;
31
+ }
32
+ }
33
+ buffer.writeUInt8(10, byteCount);
34
+ return buffer;
35
+ }
@@ -1,2 +1,6 @@
1
1
  export declare function buildVectorParam(vector: number[]): import("ydb-sdk-proto").Ydb.ITypedValue;
2
2
  export declare function buildJsonOrEmpty(payload?: Record<string, unknown>): import("ydb-sdk-proto").Ydb.ITypedValue;
3
+ export declare function buildVectorBinaryParams(vector: number[]): {
4
+ float: Buffer<ArrayBufferLike>;
5
+ bit: Buffer<ArrayBufferLike>;
6
+ };
@@ -1,7 +1,14 @@
1
1
  import { Types, TypedValues } from "./client.js";
2
+ import { vectorToFloatBinary, vectorToBitBinary, } from "../utils/vectorBinary.js";
2
3
  export function buildVectorParam(vector) {
3
4
  return TypedValues.list(Types.FLOAT, vector);
4
5
  }
5
6
  export function buildJsonOrEmpty(payload) {
6
7
  return TypedValues.jsonDocument(JSON.stringify(payload ?? {}));
7
8
  }
9
+ export function buildVectorBinaryParams(vector) {
10
+ return {
11
+ float: vectorToFloatBinary(vector),
12
+ bit: vectorToBitBinary(vector),
13
+ };
14
+ }
@@ -1,3 +1,4 @@
1
1
  export declare const GLOBAL_POINTS_TABLE = "qdrant_all_points";
2
+ export { UPSERT_BATCH_SIZE } from "../config/env.js";
2
3
  export declare function ensureMetaTable(): Promise<void>;
3
4
  export declare function ensureGlobalPointsTable(): Promise<void>;
@@ -2,6 +2,8 @@ import { withSession, TableDescription, Column, Types } from "./client.js";
2
2
  import { logger } from "../logging/logger.js";
3
3
  import { GLOBAL_POINTS_AUTOMIGRATE_ENABLED } from "../config/env.js";
4
4
  export const GLOBAL_POINTS_TABLE = "qdrant_all_points";
5
+ // Shared YDB-related constants for repositories.
6
+ export { UPSERT_BATCH_SIZE } from "../config/env.js";
5
7
  let globalPointsTableReady = false;
6
8
  function throwMigrationRequired(message) {
7
9
  logger.error(message);
@@ -39,59 +41,34 @@ export async function ensureGlobalPointsTable() {
39
41
  tableDescription = await s.describeTable(GLOBAL_POINTS_TABLE);
40
42
  }
41
43
  catch {
42
- // Table doesn't exist, create it with all columns
44
+ // Table doesn't exist, create it with all columns using the new schema.
43
45
  const desc = new TableDescription()
44
- .withColumns(new Column("uid", Types.UTF8), new Column("point_id", Types.UTF8), new Column("embedding", Types.BYTES), new Column("embedding_bit", Types.BYTES), new Column("payload", Types.JSON_DOCUMENT))
46
+ .withColumns(new Column("uid", Types.UTF8), new Column("point_id", Types.UTF8), new Column("embedding", Types.BYTES), new Column("embedding_quantized", Types.BYTES), new Column("payload", Types.JSON_DOCUMENT))
45
47
  .withPrimaryKeys("uid", "point_id");
46
48
  await s.createTable(GLOBAL_POINTS_TABLE, desc);
47
49
  globalPointsTableReady = true;
48
50
  logger.info(`created global points table ${GLOBAL_POINTS_TABLE}`);
49
51
  return;
50
52
  }
51
- // Table exists, check if embedding_bit column is present
53
+ // Table exists, require the new embedding_quantized column.
52
54
  const columns = tableDescription.columns ?? [];
53
- const hasEmbeddingBit = columns.some((col) => col.name === "embedding_bit");
54
- let needsBackfill = false;
55
- if (!hasEmbeddingBit) {
55
+ const hasEmbeddingQuantized = columns.some((col) => col.name === "embedding_quantized");
56
+ if (!hasEmbeddingQuantized) {
56
57
  if (!GLOBAL_POINTS_AUTOMIGRATE_ENABLED) {
57
- throwMigrationRequired(`Global points table ${GLOBAL_POINTS_TABLE} is missing required column embedding_bit; set YDB_QDRANT_GLOBAL_POINTS_AUTOMIGRATE=true after backup to apply the migration manually.`);
58
+ throwMigrationRequired(`Global points table ${GLOBAL_POINTS_TABLE} is missing required column embedding_quantized; apply the migration (e.g., ALTER TABLE ${GLOBAL_POINTS_TABLE} RENAME COLUMN embedding_bit TO embedding_quantized) or set YDB_QDRANT_GLOBAL_POINTS_AUTOMIGRATE=true after backup to allow automatic migration.`);
58
59
  }
59
60
  const alterDdl = `
60
61
  ALTER TABLE ${GLOBAL_POINTS_TABLE}
61
- ADD COLUMN embedding_bit String;
62
+ ADD COLUMN embedding_quantized String;
62
63
  `;
63
64
  const rawSession = s;
64
65
  await rawSession.api.executeSchemeQuery({
65
66
  sessionId: rawSession.sessionId,
66
67
  yqlText: alterDdl,
67
68
  });
68
- logger.info(`added embedding_bit column to existing table ${GLOBAL_POINTS_TABLE}`);
69
- needsBackfill = true;
69
+ logger.info(`added embedding_quantized column to existing table ${GLOBAL_POINTS_TABLE}`);
70
70
  }
71
- else {
72
- const checkNullsDdl = `
73
- SELECT 1 AS has_null
74
- FROM ${GLOBAL_POINTS_TABLE}
75
- WHERE embedding_bit IS NULL
76
- LIMIT 1;
77
- `;
78
- const checkRes = await s.executeQuery(checkNullsDdl);
79
- const rows = checkRes?.resultSets?.[0]?.rows ?? [];
80
- needsBackfill = rows.length > 0;
81
- }
82
- if (needsBackfill) {
83
- if (!GLOBAL_POINTS_AUTOMIGRATE_ENABLED) {
84
- throwMigrationRequired(`Global points table ${GLOBAL_POINTS_TABLE} requires backfill for embedding_bit; set YDB_QDRANT_GLOBAL_POINTS_AUTOMIGRATE=true after backup to apply the migration manually.`);
85
- }
86
- const backfillDdl = `
87
- UPDATE ${GLOBAL_POINTS_TABLE}
88
- SET embedding_bit = Untag(Knn::ToBinaryStringBit(Knn::FloatFromBinaryString(embedding)), "BitVector")
89
- WHERE embedding_bit IS NULL;
90
- `;
91
- await s.executeQuery(backfillDdl);
92
- logger.info(`backfilled embedding_bit column from embedding in ${GLOBAL_POINTS_TABLE}`);
93
- }
94
- // Mark table ready only after schema (and any required backfill) succeed
71
+ // Mark table ready after schema checks/migrations succeed.
95
72
  globalPointsTableReady = true;
96
73
  });
97
74
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ydb-qdrant",
3
- "version": "4.5.1",
3
+ "version": "4.7.0",
4
4
  "main": "dist/package/api.js",
5
5
  "types": "dist/package/api.d.ts",
6
6
  "exports": {
@@ -16,8 +16,10 @@
16
16
  "scripts": {
17
17
  "test": "vitest run --exclude \"test/integration/**\"",
18
18
  "test:coverage": "vitest run --coverage --exclude \"test/integration/**\"",
19
- "test:integration": "VECTOR_INDEX_BUILD_ENABLED=false vitest run test/integration/YdbRealIntegration.index-disabled.test.ts && VECTOR_INDEX_BUILD_ENABLED=true vitest run test/integration/YdbRealIntegration.test.ts test/integration/YdbRecallIntegration.test.ts && YDB_QDRANT_COLLECTION_STORAGE_MODE=one_table vitest run test/integration/YdbRealIntegration.one-table.test.ts",
20
- "test:recall": "VECTOR_INDEX_BUILD_ENABLED=true vitest run test/integration/YdbRecallIntegration.test.ts && YDB_QDRANT_COLLECTION_STORAGE_MODE=one_table vitest run test/integration/YdbRealIntegration.one-table.test.ts",
19
+ "test:integration": "VECTOR_INDEX_BUILD_ENABLED=false vitest run test/integration/YdbRealIntegration.index-disabled.test.ts && VECTOR_INDEX_BUILD_ENABLED=true vitest run test/integration/YdbRealIntegration.test.ts test/integration/YdbRecallIntegration.test.ts && YDB_QDRANT_COLLECTION_STORAGE_MODE=one_table YDB_QDRANT_SEARCH_MODE=approximate vitest run test/integration/YdbRealIntegration.one-table.test.ts && YDB_QDRANT_COLLECTION_STORAGE_MODE=one_table YDB_QDRANT_SEARCH_MODE=exact vitest run test/integration/YdbRealIntegration.one-table.test.ts",
20
+ "test:recall": "VECTOR_INDEX_BUILD_ENABLED=true vitest run test/integration/YdbRecallIntegration.test.ts && YDB_QDRANT_COLLECTION_STORAGE_MODE=one_table YDB_QDRANT_SEARCH_MODE=approximate vitest run test/integration/YdbRealIntegration.one-table.test.ts && YDB_QDRANT_COLLECTION_STORAGE_MODE=one_table YDB_QDRANT_SEARCH_MODE=exact vitest run test/integration/YdbRealIntegration.one-table.test.ts",
21
+ "load:soak": "k6 run loadtest/soak-test.js",
22
+ "load:stress": "k6 run loadtest/stress-test.js",
21
23
  "build": "tsc -p tsconfig.json",
22
24
  "typecheck": "tsc -p tsconfig.json --noEmit",
23
25
  "dev": "tsx watch src/index.ts",
@@ -81,4 +83,4 @@
81
83
  "typescript-eslint": "^8.47.0",
82
84
  "vitest": "^4.0.12"
83
85
  }
84
- }
86
+ }