ydb-qdrant 4.1.0 → 4.1.2

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
@@ -401,12 +401,13 @@ curl -X POST http://localhost:8080/collections/mycol/points/delete \
401
401
 
402
402
  ## Notes
403
403
  - Storage layout:
404
- - **multi_table** (default): one YDB table per collection; metadata is tracked in `qdr__collections`.
405
- - **one_table**: a single global table `qdrant_all_points` with `(uid, point_id)` PK, where `uid` encodes tenant+collection.
404
+ - **multi_table** (default): one YDB table per collection; metadata is tracked in `qdr__collections`.
405
+ - **one_table**: a single global table `qdrant_all_points` with `(uid, point_id)` PK, where `uid` encodes tenant+collection. Columns: `uid Utf8`, `point_id Utf8`, `embedding String` (binary float), `embedding_bit String` (bit‑quantized), `payload JsonDocument`.
406
+ - **Schema migrations** (one_table mode): automatic schema/backfill steps for `qdrant_all_points` are disabled by default. To opt in, set `YDB_QDRANT_GLOBAL_POINTS_AUTOMIGRATE=true` after backing up data; otherwise the service will error if the `embedding_bit` column is missing or needs backfill.
406
407
  - Per‑collection table schema (multi_table): `point_id Utf8` (PK), `embedding String` (binary), `payload JsonDocument`.
407
408
  - Vectors are serialized with `Knn::ToBinaryStringFloat`.
408
409
  - Search uses a single-phase top‑k over `embedding` with automatic YDB vector index (`emb_idx`) when available; falls back to table scan if missing.
409
- - **Vector index auto-build** (multi_table mode only): After ≥100 points upserted + 5s quiet window, a `vector_kmeans_tree` index (levels=1, clusters=128) is built automatically. Incremental updates (<100 points) skip index rebuild. In one_table mode, vector indexes are not supported and all searches use table scans.
410
+ - **Vector index auto-build** (multi_table mode only): After ≥100 points upserted + 5s quiet window, a `vector_kmeans_tree` index (levels=1, clusters=128) is built automatically. Incremental updates (<100 points) skip index rebuild. In one_table mode, vector indexes are not supported; searches use a two‑phase approximate+exact flow over `qdrant_all_points` (bit‑quantized candidates via `embedding_bit` using the corresponding distance function, then exact re‑ranking over `embedding`). Note: For Dot metric, Phase 1 uses CosineDistance as a proxy since there is no direct distance equivalent for inner product on bit vectors.
410
411
  - **Concurrency**: During index rebuilds, YDB may return transient `Aborted`/schema metadata errors. Upserts include bounded retries with backoff to handle this automatically.
411
412
  - Filters are not yet modeled; can be added if needed.
412
413
 
@@ -3,6 +3,7 @@ export declare const YDB_ENDPOINT: string;
3
3
  export declare const YDB_DATABASE: string;
4
4
  export declare const PORT: number;
5
5
  export declare const LOG_LEVEL: string;
6
+ export declare const GLOBAL_POINTS_AUTOMIGRATE_ENABLED: boolean;
6
7
  export declare const VECTOR_INDEX_BUILD_ENABLED: boolean;
7
8
  export declare enum CollectionStorageMode {
8
9
  MultiTable = "multi_table",
@@ -3,6 +3,7 @@ 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
+ export const GLOBAL_POINTS_AUTOMIGRATE_ENABLED = parseBooleanEnv(process.env.YDB_QDRANT_GLOBAL_POINTS_AUTOMIGRATE, false);
6
7
  function parseBooleanEnv(value, defaultValue) {
7
8
  if (value === undefined) {
8
9
  return defaultValue;
@@ -1,7 +1,7 @@
1
- import { TypedValues, withSession } from "../ydb/client.js";
1
+ import { TypedValues, Types, withSession } from "../ydb/client.js";
2
2
  import { buildJsonOrEmpty, buildVectorParam } from "../ydb/helpers.js";
3
3
  import { notifyUpsert } from "../indexing/IndexScheduler.js";
4
- import { mapDistanceToKnnFn } from "../utils/distance.js";
4
+ import { mapDistanceToKnnFn, mapDistanceToBitKnnFn, } from "../utils/distance.js";
5
5
  import { withRetry, isTransientYdbError } from "../utils/retry.js";
6
6
  export async function upsertPointsOneTable(tableName, points, dimension, uid) {
7
7
  let upserted = 0;
@@ -16,11 +16,12 @@ export async function upsertPointsOneTable(tableName, points, dimension, uid) {
16
16
  DECLARE $id AS Utf8;
17
17
  DECLARE $vec AS List<Float>;
18
18
  DECLARE $payload AS JsonDocument;
19
- UPSERT INTO ${tableName} (uid, point_id, embedding, payload)
19
+ UPSERT INTO ${tableName} (uid, point_id, embedding, embedding_bit, payload)
20
20
  VALUES (
21
21
  $uid,
22
22
  $id,
23
23
  Untag(Knn::ToBinaryStringFloat($vec), "FloatVector"),
24
+ Untag(Knn::ToBinaryStringBit($vec), "BitVector"),
24
25
  $payload
25
26
  );
26
27
  `;
@@ -45,50 +46,83 @@ export async function searchPointsOneTable(tableName, queryVector, top, withPayl
45
46
  throw new Error(`Vector dimension mismatch: got ${queryVector.length}, expected ${dimension}`);
46
47
  }
47
48
  const { fn, order } = mapDistanceToKnnFn(distance);
49
+ const { fn: bitFn, order: bitOrder } = mapDistanceToBitKnnFn(distance);
48
50
  const qf = buildVectorParam(queryVector);
49
- const params = {
50
- $qf: qf,
51
- $k2: TypedValues.uint32(top),
52
- $uid: TypedValues.utf8(uid),
53
- };
54
- const query = `
55
- DECLARE $qf AS List<Float>;
56
- DECLARE $k2 AS Uint32;
57
- DECLARE $uid AS Utf8;
58
- $qbinf = Knn::ToBinaryStringFloat($qf);
59
- SELECT point_id, ${withPayload ? "payload, " : ""}${fn}(embedding, $qbinf) AS score
60
- FROM ${tableName}
61
- WHERE uid = $uid
62
- ORDER BY score ${order}
63
- LIMIT $k2;
64
- `;
65
- const rs = await withSession(async (s) => {
66
- return await s.executeQuery(query, params);
67
- });
68
- const rowset = rs.resultSets?.[0];
69
- const rows = (rowset?.rows ?? []);
70
- return rows.map((row) => {
71
- const id = row.items?.[0]?.textValue;
72
- if (typeof id !== "string") {
73
- throw new Error("point_id is missing in YDB search result");
51
+ const candidateLimit = top * 10;
52
+ const results = await withSession(async (s) => {
53
+ // Phase 1: approximate candidate selection using embedding_bit
54
+ const phase1Query = `
55
+ DECLARE $qf AS List<Float>;
56
+ DECLARE $k AS Uint32;
57
+ DECLARE $uid AS Utf8;
58
+ $qbin_bit = Knn::ToBinaryStringBit($qf);
59
+ SELECT point_id
60
+ FROM ${tableName}
61
+ WHERE uid = $uid AND embedding_bit IS NOT NULL
62
+ ORDER BY ${bitFn}(embedding_bit, $qbin_bit) ${bitOrder}
63
+ LIMIT $k;
64
+ `;
65
+ const phase1Params = {
66
+ $qf: qf,
67
+ $k: TypedValues.uint32(candidateLimit),
68
+ $uid: TypedValues.utf8(uid),
69
+ };
70
+ const rs1 = await s.executeQuery(phase1Query, phase1Params);
71
+ const rowset1 = rs1.resultSets?.[0];
72
+ const rows1 = (rowset1?.rows ?? []);
73
+ const candidateIds = rows1
74
+ .map((row) => row.items?.[0]?.textValue)
75
+ .filter((id) => typeof id === "string");
76
+ if (candidateIds.length === 0) {
77
+ return [];
74
78
  }
75
- let payload;
76
- let scoreIdx = 1;
77
- if (withPayload) {
78
- const payloadText = row.items?.[1]?.textValue;
79
- if (payloadText) {
80
- try {
81
- payload = JSON.parse(payloadText);
82
- }
83
- catch {
84
- payload = undefined;
79
+ // Phase 2: exact re-ranking on full-precision embedding for candidates only
80
+ const phase2Query = `
81
+ DECLARE $qf AS List<Float>;
82
+ DECLARE $k AS Uint32;
83
+ DECLARE $uid AS Utf8;
84
+ DECLARE $ids AS List<Utf8>;
85
+ $qbinf = Knn::ToBinaryStringFloat($qf);
86
+ SELECT point_id, ${withPayload ? "payload, " : ""}${fn}(embedding, $qbinf) AS score
87
+ FROM ${tableName}
88
+ WHERE uid = $uid AND point_id IN $ids
89
+ ORDER BY score ${order}
90
+ LIMIT $k;
91
+ `;
92
+ const idsParam = TypedValues.list(Types.UTF8, candidateIds);
93
+ const phase2Params = {
94
+ $qf: qf,
95
+ $k: TypedValues.uint32(top),
96
+ $uid: TypedValues.utf8(uid),
97
+ $ids: idsParam,
98
+ };
99
+ const rs2 = await s.executeQuery(phase2Query, phase2Params);
100
+ const rowset2 = rs2.resultSets?.[0];
101
+ const rows2 = (rowset2?.rows ?? []);
102
+ return rows2.map((row) => {
103
+ const id = row.items?.[0]?.textValue;
104
+ if (typeof id !== "string") {
105
+ throw new Error("point_id is missing in YDB search result");
106
+ }
107
+ let payload;
108
+ let scoreIdx = 1;
109
+ if (withPayload) {
110
+ const payloadText = row.items?.[1]?.textValue;
111
+ if (payloadText) {
112
+ try {
113
+ payload = JSON.parse(payloadText);
114
+ }
115
+ catch {
116
+ payload = undefined;
117
+ }
85
118
  }
119
+ scoreIdx = 2;
86
120
  }
87
- scoreIdx = 2;
88
- }
89
- const score = Number(row.items?.[scoreIdx]?.floatValue ?? row.items?.[scoreIdx]?.textValue);
90
- return { id, score, ...(payload ? { payload } : {}) };
121
+ const score = Number(row.items?.[scoreIdx]?.floatValue ?? row.items?.[scoreIdx]?.textValue);
122
+ return { id, score, ...(payload ? { payload } : {}) };
123
+ });
91
124
  });
125
+ return results;
92
126
  }
93
127
  export async function deletePointsOneTable(tableName, ids, uid) {
94
128
  let deleted = 0;
@@ -5,7 +5,7 @@ import { deletePoints as repoDeletePoints, searchPoints as repoSearchPoints, ups
5
5
  import { requestIndexBuild } from "../indexing/IndexScheduler.js";
6
6
  import { logger } from "../logging/logger.js";
7
7
  import { VECTOR_INDEX_BUILD_ENABLED } from "../config/env.js";
8
- import { QdrantServiceError } from "./errors.js";
8
+ import { QdrantServiceError, isVectorDimensionMismatchError, } from "./errors.js";
9
9
  import { normalizeCollectionContext, resolvePointsTableAndUid, } from "./CollectionService.js";
10
10
  import { normalizeSearchBodyForSearch, normalizeSearchBodyForQuery, } from "../utils/normalization.js";
11
11
  let loggedIndexBuildDisabled = false;
@@ -27,7 +27,25 @@ export async function upsertPoints(ctx, body) {
27
27
  });
28
28
  }
29
29
  const { tableName, uid } = await resolvePointsTableAndUid(normalized, meta);
30
- const upserted = await repoUpsertPoints(tableName, parsed.data.points, meta.dimension, uid);
30
+ let upserted;
31
+ try {
32
+ upserted = await repoUpsertPoints(tableName, parsed.data.points, meta.dimension, uid);
33
+ }
34
+ catch (err) {
35
+ if (isVectorDimensionMismatchError(err)) {
36
+ logger.warn({
37
+ tenant: normalized.tenant,
38
+ collection: normalized.collection,
39
+ table: tableName,
40
+ dimension: meta.dimension,
41
+ }, "upsertPoints: vector dimension mismatch");
42
+ throw new QdrantServiceError(400, {
43
+ status: "error",
44
+ error: err.message,
45
+ });
46
+ }
47
+ throw err;
48
+ }
31
49
  if (VECTOR_INDEX_BUILD_ENABLED) {
32
50
  requestIndexBuild(tableName, meta.dimension, meta.distance, meta.vectorType);
33
51
  }
@@ -79,7 +97,26 @@ async function executeSearch(ctx, normalizedSearch, source) {
79
97
  distance: meta.distance,
80
98
  vectorType: meta.vectorType,
81
99
  }, `${source}: executing`);
82
- const hits = await repoSearchPoints(tableName, parsed.data.vector, parsed.data.top, parsed.data.with_payload, meta.distance, meta.dimension, uid);
100
+ let hits;
101
+ try {
102
+ hits = await repoSearchPoints(tableName, parsed.data.vector, parsed.data.top, parsed.data.with_payload, meta.distance, meta.dimension, uid);
103
+ }
104
+ catch (err) {
105
+ if (isVectorDimensionMismatchError(err)) {
106
+ logger.warn({
107
+ tenant: normalized.tenant,
108
+ collection: normalized.collection,
109
+ table: tableName,
110
+ dimension: meta.dimension,
111
+ queryVectorLen: parsed.data.vector.length,
112
+ }, `${source}: vector dimension mismatch`);
113
+ throw new QdrantServiceError(400, {
114
+ status: "error",
115
+ error: err.message,
116
+ });
117
+ }
118
+ throw err;
119
+ }
83
120
  const threshold = normalizedSearch.scoreThreshold;
84
121
  const filtered = threshold === undefined
85
122
  ? hits
@@ -2,6 +2,7 @@ export interface QdrantServiceErrorPayload {
2
2
  status: "error";
3
3
  error: unknown;
4
4
  }
5
+ export declare function isVectorDimensionMismatchError(err: unknown): err is Error;
5
6
  export declare class QdrantServiceError extends Error {
6
7
  readonly statusCode: number;
7
8
  readonly payload: QdrantServiceErrorPayload;
@@ -1,3 +1,6 @@
1
+ export function isVectorDimensionMismatchError(err) {
2
+ return (err instanceof Error && err.message.startsWith("Vector dimension mismatch"));
3
+ }
1
4
  export class QdrantServiceError extends Error {
2
5
  statusCode;
3
6
  payload;
@@ -4,3 +4,14 @@ export declare function mapDistanceToKnnFn(distance: DistanceKind): {
4
4
  order: "ASC" | "DESC";
5
5
  };
6
6
  export declare function mapDistanceToIndexParam(distance: DistanceKind): string;
7
+ /**
8
+ * Maps a user-specified distance metric to a YDB Knn distance function
9
+ * suitable for bit-quantized vectors (Phase 1 approximate candidate selection).
10
+ * Always returns a distance function (lower is better, ASC ordering).
11
+ * For Dot, falls back to CosineDistance as a proxy since there is no
12
+ * direct distance equivalent for inner product.
13
+ */
14
+ export declare function mapDistanceToBitKnnFn(distance: DistanceKind): {
15
+ fn: string;
16
+ order: "ASC";
17
+ };
@@ -26,3 +26,25 @@ export function mapDistanceToIndexParam(distance) {
26
26
  return "cosine";
27
27
  }
28
28
  }
29
+ /**
30
+ * Maps a user-specified distance metric to a YDB Knn distance function
31
+ * suitable for bit-quantized vectors (Phase 1 approximate candidate selection).
32
+ * Always returns a distance function (lower is better, ASC ordering).
33
+ * For Dot, falls back to CosineDistance as a proxy since there is no
34
+ * direct distance equivalent for inner product.
35
+ */
36
+ export function mapDistanceToBitKnnFn(distance) {
37
+ switch (distance) {
38
+ case "Cosine":
39
+ return { fn: "Knn::CosineDistance", order: "ASC" };
40
+ case "Dot":
41
+ // No direct distance equivalent; use Cosine as proxy
42
+ return { fn: "Knn::CosineDistance", order: "ASC" };
43
+ case "Euclid":
44
+ return { fn: "Knn::EuclideanDistance", order: "ASC" };
45
+ case "Manhattan":
46
+ return { fn: "Knn::ManhattanDistance", order: "ASC" };
47
+ default:
48
+ return { fn: "Knn::CosineDistance", order: "ASC" };
49
+ }
50
+ }
@@ -1,7 +1,12 @@
1
1
  import { withSession, TableDescription, Column, Types } from "./client.js";
2
2
  import { logger } from "../logging/logger.js";
3
+ import { GLOBAL_POINTS_AUTOMIGRATE_ENABLED } from "../config/env.js";
3
4
  export const GLOBAL_POINTS_TABLE = "qdrant_all_points";
4
5
  let globalPointsTableReady = false;
6
+ function throwMigrationRequired(message) {
7
+ logger.error(message);
8
+ throw new Error(message);
9
+ }
5
10
  export async function ensureMetaTable() {
6
11
  try {
7
12
  await withSession(async (s) => {
@@ -28,24 +33,61 @@ export async function ensureGlobalPointsTable() {
28
33
  if (globalPointsTableReady) {
29
34
  return;
30
35
  }
31
- try {
32
- await withSession(async (s) => {
33
- try {
34
- await s.describeTable(GLOBAL_POINTS_TABLE);
35
- globalPointsTableReady = true;
36
- return;
36
+ await withSession(async (s) => {
37
+ let tableDescription = null;
38
+ try {
39
+ tableDescription = await s.describeTable(GLOBAL_POINTS_TABLE);
40
+ }
41
+ catch {
42
+ // Table doesn't exist, create it with all columns
43
+ 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))
45
+ .withPrimaryKeys("uid", "point_id");
46
+ await s.createTable(GLOBAL_POINTS_TABLE, desc);
47
+ globalPointsTableReady = true;
48
+ logger.info(`created global points table ${GLOBAL_POINTS_TABLE}`);
49
+ return;
50
+ }
51
+ // Table exists, check if embedding_bit column is present
52
+ const columns = tableDescription.columns ?? [];
53
+ const hasEmbeddingBit = columns.some((col) => col.name === "embedding_bit");
54
+ let needsBackfill = false;
55
+ if (!hasEmbeddingBit) {
56
+ 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.`);
37
58
  }
38
- catch {
39
- const desc = new TableDescription()
40
- .withColumns(new Column("uid", Types.UTF8), new Column("point_id", Types.UTF8), new Column("embedding", Types.BYTES), new Column("payload", Types.JSON_DOCUMENT))
41
- .withPrimaryKeys("uid", "point_id");
42
- await s.createTable(GLOBAL_POINTS_TABLE, desc);
43
- globalPointsTableReady = true;
44
- logger.info(`created global points table ${GLOBAL_POINTS_TABLE}`);
59
+ const alterDdl = `
60
+ ALTER TABLE ${GLOBAL_POINTS_TABLE}
61
+ ADD COLUMN embedding_bit String;
62
+ `;
63
+ await s.executeQuery(alterDdl);
64
+ logger.info(`added embedding_bit column to existing table ${GLOBAL_POINTS_TABLE}`);
65
+ needsBackfill = true;
66
+ }
67
+ else {
68
+ const checkNullsDdl = `
69
+ SELECT 1 AS has_null
70
+ FROM ${GLOBAL_POINTS_TABLE}
71
+ WHERE embedding_bit IS NULL
72
+ LIMIT 1;
73
+ `;
74
+ const checkRes = await s.executeQuery(checkNullsDdl);
75
+ const rows = checkRes?.resultSets?.[0]?.rows ?? [];
76
+ needsBackfill = rows.length > 0;
77
+ }
78
+ if (needsBackfill) {
79
+ if (!GLOBAL_POINTS_AUTOMIGRATE_ENABLED) {
80
+ 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.`);
45
81
  }
46
- });
47
- }
48
- catch (err) {
49
- logger.debug({ err }, "ensureGlobalPointsTable: ignored");
50
- }
82
+ const backfillDdl = `
83
+ UPDATE ${GLOBAL_POINTS_TABLE}
84
+ SET embedding_bit = Untag(Knn::ToBinaryStringBit(Knn::FloatFromBinaryString(embedding)), "BitVector")
85
+ WHERE embedding_bit IS NULL;
86
+ `;
87
+ await s.executeQuery(backfillDdl);
88
+ logger.info(`backfilled embedding_bit column from embedding in ${GLOBAL_POINTS_TABLE}`);
89
+ }
90
+ // Mark table ready only after schema (and any required backfill) succeed
91
+ globalPointsTableReady = true;
92
+ });
51
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ydb-qdrant",
3
- "version": "4.1.0",
3
+ "version": "4.1.2",
4
4
  "main": "dist/package/api.js",
5
5
  "types": "dist/package/api.d.ts",
6
6
  "exports": {