ydb-qdrant 4.0.0 → 4.1.1

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.
Files changed (39) hide show
  1. package/README.md +7 -3
  2. package/dist/config/env.d.ts +6 -0
  3. package/dist/config/env.js +17 -0
  4. package/dist/index.js +5 -2
  5. package/dist/indexing/IndexScheduler.js +6 -51
  6. package/dist/indexing/IndexScheduler.multi-table.d.ts +12 -0
  7. package/dist/indexing/IndexScheduler.multi-table.js +54 -0
  8. package/dist/indexing/IndexScheduler.one-table.d.ts +1 -0
  9. package/dist/indexing/IndexScheduler.one-table.js +4 -0
  10. package/dist/repositories/collectionsRepo.d.ts +3 -2
  11. package/dist/repositories/collectionsRepo.js +26 -39
  12. package/dist/repositories/collectionsRepo.multi-table.d.ts +3 -0
  13. package/dist/repositories/collectionsRepo.multi-table.js +23 -0
  14. package/dist/repositories/collectionsRepo.one-table.d.ts +3 -0
  15. package/dist/repositories/collectionsRepo.one-table.js +25 -0
  16. package/dist/repositories/collectionsRepo.shared.d.ts +2 -0
  17. package/dist/repositories/collectionsRepo.shared.js +23 -0
  18. package/dist/repositories/pointsRepo.d.ts +3 -3
  19. package/dist/repositories/pointsRepo.js +16 -129
  20. package/dist/repositories/pointsRepo.multi-table.d.ts +12 -0
  21. package/dist/repositories/pointsRepo.multi-table.js +129 -0
  22. package/dist/repositories/pointsRepo.one-table.d.ts +12 -0
  23. package/dist/repositories/pointsRepo.one-table.js +145 -0
  24. package/dist/services/CollectionService.d.ts +6 -0
  25. package/dist/services/CollectionService.js +13 -6
  26. package/dist/services/CollectionService.multi-table.d.ts +5 -0
  27. package/dist/services/CollectionService.multi-table.js +7 -0
  28. package/dist/services/CollectionService.one-table.d.ts +5 -0
  29. package/dist/services/CollectionService.one-table.js +9 -0
  30. package/dist/services/CollectionService.shared.d.ts +11 -0
  31. package/dist/services/CollectionService.shared.js +17 -0
  32. package/dist/services/PointsService.js +9 -6
  33. package/dist/utils/distance.d.ts +11 -0
  34. package/dist/utils/distance.js +22 -0
  35. package/dist/utils/tenant.d.ts +1 -0
  36. package/dist/utils/tenant.js +3 -0
  37. package/dist/ydb/schema.d.ts +2 -0
  38. package/dist/ydb/schema.js +67 -0
  39. package/package.json +1 -1
package/README.md CHANGED
@@ -75,6 +75,8 @@ Optional env:
75
75
  # Server
76
76
  export PORT=8080
77
77
  export LOG_LEVEL=info
78
+ # Collection storage mode (optional; default is multi_table)
79
+ export YDB_QDRANT_COLLECTION_STORAGE_MODE=multi_table # or one_table
78
80
  ```
79
81
 
80
82
  ## Use as a Node.js library (npm package)
@@ -398,11 +400,13 @@ curl -X POST http://localhost:8080/collections/mycol/points/delete \
398
400
  ```
399
401
 
400
402
  ## Notes
401
- - One YDB table is created per collection; metadata is tracked in table `qdr__collections`.
402
- - Each collection table schema: `point_id Utf8` (PK), `embedding String` (binary), `payload JsonDocument`.
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. Columns: `uid Utf8`, `point_id Utf8`, `embedding String` (binary float), `embedding_bit String` (bit‑quantized), `payload JsonDocument`.
406
+ - Per‑collection table schema (multi_table): `point_id Utf8` (PK), `embedding String` (binary), `payload JsonDocument`.
403
407
  - Vectors are serialized with `Knn::ToBinaryStringFloat`.
404
408
  - 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.
405
- - **Vector index auto-build**: 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.
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; 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.
406
410
  - **Concurrency**: During index rebuilds, YDB may return transient `Aborted`/schema metadata errors. Upserts include bounded retries with backoff to handle this automatically.
407
411
  - Filters are not yet modeled; can be added if needed.
408
412
 
@@ -4,3 +4,9 @@ export declare const YDB_DATABASE: string;
4
4
  export declare const PORT: number;
5
5
  export declare const LOG_LEVEL: string;
6
6
  export declare const VECTOR_INDEX_BUILD_ENABLED: boolean;
7
+ export declare enum CollectionStorageMode {
8
+ MultiTable = "multi_table",
9
+ OneTable = "one_table"
10
+ }
11
+ export declare const COLLECTION_STORAGE_MODE: CollectionStorageMode;
12
+ export declare function isOneTableMode(mode: CollectionStorageMode): mode is CollectionStorageMode.OneTable;
@@ -18,3 +18,20 @@ function parseBooleanEnv(value, defaultValue) {
18
18
  return true;
19
19
  }
20
20
  export const VECTOR_INDEX_BUILD_ENABLED = parseBooleanEnv(process.env.VECTOR_INDEX_BUILD_ENABLED, false);
21
+ export var CollectionStorageMode;
22
+ (function (CollectionStorageMode) {
23
+ CollectionStorageMode["MultiTable"] = "multi_table";
24
+ CollectionStorageMode["OneTable"] = "one_table";
25
+ })(CollectionStorageMode || (CollectionStorageMode = {}));
26
+ function resolveCollectionStorageModeEnv() {
27
+ const explicit = process.env.YDB_QDRANT_COLLECTION_STORAGE_MODE ??
28
+ process.env.YDB_QDRANT_TABLE_LAYOUT;
29
+ if (explicit?.trim().toLowerCase() === CollectionStorageMode.OneTable) {
30
+ return CollectionStorageMode.OneTable;
31
+ }
32
+ return CollectionStorageMode.MultiTable;
33
+ }
34
+ export const COLLECTION_STORAGE_MODE = resolveCollectionStorageModeEnv();
35
+ export function isOneTableMode(mode) {
36
+ return mode === CollectionStorageMode.OneTable;
37
+ }
package/dist/index.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import "dotenv/config";
2
2
  import { buildServer } from "./server.js";
3
- import { PORT } from "./config/env.js";
3
+ import { PORT, COLLECTION_STORAGE_MODE, isOneTableMode, } from "./config/env.js";
4
4
  import { logger } from "./logging/logger.js";
5
5
  import { readyOrThrow } from "./ydb/client.js";
6
- import { ensureMetaTable } from "./ydb/schema.js";
6
+ import { ensureMetaTable, ensureGlobalPointsTable } from "./ydb/schema.js";
7
7
  async function start() {
8
8
  try {
9
9
  await readyOrThrow();
10
10
  await ensureMetaTable();
11
+ if (isOneTableMode(COLLECTION_STORAGE_MODE)) {
12
+ await ensureGlobalPointsTable();
13
+ }
11
14
  }
12
15
  catch (err) {
13
16
  logger.error({ err }, "YDB not ready; startup continues, requests may fail until configured.");
@@ -1,8 +1,6 @@
1
- import { buildVectorIndex } from "../repositories/collectionsRepo.js";
2
- import { logger } from "../logging/logger.js";
3
- const QUIET_MS = 10000; // no upserts for 10s => build
4
- const MIN_POINTS_THRESHOLD = 100; // only rebuild if at least this many points upserted
5
- const state = {};
1
+ import { GLOBAL_POINTS_TABLE } from "../ydb/schema.js";
2
+ import { state, requestIndexBuildMultiTable, } from "./IndexScheduler.multi-table.js";
3
+ import { requestIndexBuildOneTable } from "./IndexScheduler.one-table.js";
6
4
  export function notifyUpsert(tableName, count = 1) {
7
5
  const now = Date.now();
8
6
  const s = state[tableName] ?? {
@@ -15,52 +13,9 @@ export function notifyUpsert(tableName, count = 1) {
15
13
  state[tableName] = s;
16
14
  }
17
15
  export function requestIndexBuild(tableName, dimension, distance, vectorType, opts) {
18
- const s = state[tableName] ?? {
19
- lastUpsertMs: 0,
20
- pending: false,
21
- pointsUpserted: 0,
22
- };
23
- state[tableName] = s;
24
- if (opts?.force) {
25
- logger.info({ tableName }, "index build (force) starting");
26
- void buildVectorIndex(tableName, dimension, distance, vectorType)
27
- .then(() => {
28
- logger.info({ tableName }, "index build (force) completed");
29
- s.pointsUpserted = 0;
30
- })
31
- .catch((err) => {
32
- logger.error({ err, tableName }, "index build (force) failed");
33
- });
34
- return;
35
- }
36
- if (s.pending && s.timer) {
37
- // already scheduled; timer will check quiet window
16
+ if (tableName === GLOBAL_POINTS_TABLE) {
17
+ requestIndexBuildOneTable(tableName);
38
18
  return;
39
19
  }
40
- s.pending = true;
41
- s.timer = setTimeout(function tryBuild() {
42
- const since = Date.now() - (state[tableName]?.lastUpsertMs ?? 0);
43
- if (since < QUIET_MS) {
44
- s.timer = setTimeout(tryBuild, QUIET_MS - since);
45
- return;
46
- }
47
- const pointsCount = state[tableName]?.pointsUpserted ?? 0;
48
- if (pointsCount < MIN_POINTS_THRESHOLD) {
49
- logger.info({ tableName, pointsCount, threshold: MIN_POINTS_THRESHOLD }, "index build skipped (below threshold)");
50
- s.pending = false;
51
- s.timer = undefined;
52
- return;
53
- }
54
- logger.info({ tableName, pointsCount }, "index build (scheduled) starting");
55
- void buildVectorIndex(tableName, dimension, distance, vectorType)
56
- .then(() => {
57
- logger.info({ tableName }, "index build (scheduled) completed");
58
- state[tableName].pointsUpserted = 0;
59
- })
60
- .catch((err) => logger.error({ err, tableName }, "index build (scheduled) failed"))
61
- .finally(() => {
62
- s.pending = false;
63
- s.timer = undefined;
64
- });
65
- }, QUIET_MS);
20
+ requestIndexBuildMultiTable(tableName, dimension, distance, vectorType, opts);
66
21
  }
@@ -0,0 +1,12 @@
1
+ import type { DistanceKind, VectorType } from "../types.js";
2
+ type CollectionKey = string;
3
+ export declare const state: Record<CollectionKey, {
4
+ lastUpsertMs: number;
5
+ timer?: NodeJS.Timeout;
6
+ pending: boolean;
7
+ pointsUpserted: number;
8
+ }>;
9
+ export declare function requestIndexBuildMultiTable(tableName: string, dimension: number, distance: DistanceKind, vectorType: VectorType, opts?: {
10
+ force?: boolean;
11
+ }): void;
12
+ export {};
@@ -0,0 +1,54 @@
1
+ import { buildVectorIndex } from "../repositories/collectionsRepo.js";
2
+ import { logger } from "../logging/logger.js";
3
+ const QUIET_MS = 10000;
4
+ const MIN_POINTS_THRESHOLD = 100;
5
+ export const state = {};
6
+ export function requestIndexBuildMultiTable(tableName, dimension, distance, vectorType, opts) {
7
+ const s = state[tableName] ?? {
8
+ lastUpsertMs: 0,
9
+ pending: false,
10
+ pointsUpserted: 0,
11
+ };
12
+ state[tableName] = s;
13
+ if (opts?.force) {
14
+ logger.info({ tableName }, "index build (force) starting");
15
+ void buildVectorIndex(tableName, dimension, distance, vectorType)
16
+ .then(() => {
17
+ logger.info({ tableName }, "index build (force) completed");
18
+ s.pointsUpserted = 0;
19
+ })
20
+ .catch((err) => {
21
+ logger.error({ err, tableName }, "index build (force) failed");
22
+ });
23
+ return;
24
+ }
25
+ if (s.pending && s.timer) {
26
+ return;
27
+ }
28
+ s.pending = true;
29
+ s.timer = setTimeout(function tryBuild() {
30
+ const since = Date.now() - (state[tableName]?.lastUpsertMs ?? 0);
31
+ if (since < QUIET_MS) {
32
+ s.timer = setTimeout(tryBuild, QUIET_MS - since);
33
+ return;
34
+ }
35
+ const pointsCount = state[tableName]?.pointsUpserted ?? 0;
36
+ if (pointsCount < MIN_POINTS_THRESHOLD) {
37
+ logger.info({ tableName, pointsCount, threshold: MIN_POINTS_THRESHOLD }, "index build skipped (below threshold)");
38
+ s.pending = false;
39
+ s.timer = undefined;
40
+ return;
41
+ }
42
+ logger.info({ tableName, pointsCount }, "index build (scheduled) starting");
43
+ void buildVectorIndex(tableName, dimension, distance, vectorType)
44
+ .then(() => {
45
+ logger.info({ tableName }, "index build (scheduled) completed");
46
+ state[tableName].pointsUpserted = 0;
47
+ })
48
+ .catch((err) => logger.error({ err, tableName }, "index build (scheduled) failed"))
49
+ .finally(() => {
50
+ s.pending = false;
51
+ s.timer = undefined;
52
+ });
53
+ }, QUIET_MS);
54
+ }
@@ -0,0 +1 @@
1
+ export declare function requestIndexBuildOneTable(tableName: string): void;
@@ -0,0 +1,4 @@
1
+ import { logger } from "../logging/logger.js";
2
+ export function requestIndexBuildOneTable(tableName) {
3
+ logger.info({ tableName }, "index build skipped (one_table mode: global vector index not supported)");
4
+ }
@@ -1,10 +1,11 @@
1
1
  import type { DistanceKind, VectorType } from "../types";
2
- export declare function createCollection(metaKey: string, dim: number, distance: DistanceKind, vectorType: VectorType, tableName: string): Promise<void>;
2
+ import { type CollectionStorageMode } from "../config/env.js";
3
+ export declare function createCollection(metaKey: string, dim: number, distance: DistanceKind, vectorType: VectorType, tableName: string, layout?: CollectionStorageMode): Promise<void>;
3
4
  export declare function getCollectionMeta(metaKey: string): Promise<{
4
5
  table: string;
5
6
  dimension: number;
6
7
  distance: DistanceKind;
7
8
  vectorType: VectorType;
8
9
  } | null>;
9
- export declare function deleteCollection(metaKey: string): Promise<void>;
10
+ export declare function deleteCollection(metaKey: string, uid?: string): Promise<void>;
10
11
  export declare function buildVectorIndex(tableName: string, dimension: number, distance: DistanceKind, vectorType: VectorType): Promise<void>;
@@ -1,32 +1,16 @@
1
- import { Types, TypedValues, withSession, TableDescription, Column, } from "../ydb/client.js";
1
+ import { TypedValues, withSession } from "../ydb/client.js";
2
2
  import { mapDistanceToIndexParam } from "../utils/distance.js";
3
- export async function createCollection(metaKey, dim, distance, vectorType, tableName) {
4
- await withSession(async (s) => {
5
- const desc = new TableDescription()
6
- .withColumns(new Column("point_id", Types.UTF8), new Column("embedding", Types.BYTES), new Column("payload", Types.JSON_DOCUMENT))
7
- .withPrimaryKey("point_id");
8
- await s.createTable(tableName, desc);
9
- });
10
- const upsertMeta = `
11
- DECLARE $collection AS Utf8;
12
- DECLARE $table AS Utf8;
13
- DECLARE $dim AS Uint32;
14
- DECLARE $distance AS Utf8;
15
- DECLARE $vtype AS Utf8;
16
- DECLARE $created AS Timestamp;
17
- UPSERT INTO qdr__collections (collection, table_name, vector_dimension, distance, vector_type, created_at)
18
- VALUES ($collection, $table, $dim, $distance, $vtype, $created);
19
- `;
20
- await withSession(async (s) => {
21
- await s.executeQuery(upsertMeta, {
22
- $collection: TypedValues.utf8(metaKey),
23
- $table: TypedValues.utf8(tableName),
24
- $dim: TypedValues.uint32(dim),
25
- $distance: TypedValues.utf8(distance),
26
- $vtype: TypedValues.utf8(vectorType),
27
- $created: TypedValues.timestamp(new Date()),
28
- });
29
- });
3
+ import { COLLECTION_STORAGE_MODE, isOneTableMode, } from "../config/env.js";
4
+ import { GLOBAL_POINTS_TABLE } from "../ydb/schema.js";
5
+ import { uidFor } from "../utils/tenant.js";
6
+ import { createCollectionMultiTable, deleteCollectionMultiTable, } from "./collectionsRepo.multi-table.js";
7
+ import { createCollectionOneTable, deleteCollectionOneTable, } from "./collectionsRepo.one-table.js";
8
+ export async function createCollection(metaKey, dim, distance, vectorType, tableName, layout = COLLECTION_STORAGE_MODE) {
9
+ if (isOneTableMode(layout)) {
10
+ await createCollectionOneTable(metaKey, dim, distance, vectorType);
11
+ return;
12
+ }
13
+ await createCollectionMultiTable(metaKey, dim, distance, vectorType, tableName);
30
14
  }
31
15
  export async function getCollectionMeta(metaKey) {
32
16
  const qry = `
@@ -50,20 +34,23 @@ export async function getCollectionMeta(metaKey) {
50
34
  const vectorType = row.items?.[3]?.textValue ?? "float";
51
35
  return { table, dimension, distance, vectorType };
52
36
  }
53
- export async function deleteCollection(metaKey) {
37
+ export async function deleteCollection(metaKey, uid) {
54
38
  const meta = await getCollectionMeta(metaKey);
55
39
  if (!meta)
56
40
  return;
57
- await withSession(async (s) => {
58
- await s.dropTable(meta.table);
59
- });
60
- const delMeta = `
61
- DECLARE $collection AS Utf8;
62
- DELETE FROM qdr__collections WHERE collection = $collection;
63
- `;
64
- await withSession(async (s) => {
65
- await s.executeQuery(delMeta, { $collection: TypedValues.utf8(metaKey) });
66
- });
41
+ if (meta.table === GLOBAL_POINTS_TABLE) {
42
+ const effectiveUid = uid ??
43
+ (() => {
44
+ const [tenant, collection] = metaKey.split("/", 2);
45
+ if (!tenant || !collection) {
46
+ throw new Error(`deleteCollection: cannot derive uid from malformed metaKey=${metaKey}`);
47
+ }
48
+ return uidFor(tenant, collection);
49
+ })();
50
+ await deleteCollectionOneTable(metaKey, effectiveUid);
51
+ return;
52
+ }
53
+ await deleteCollectionMultiTable(metaKey, meta.table);
67
54
  }
68
55
  export async function buildVectorIndex(tableName, dimension, distance, vectorType) {
69
56
  const distParam = mapDistanceToIndexParam(distance);
@@ -0,0 +1,3 @@
1
+ import type { DistanceKind, VectorType } from "../types";
2
+ export declare function createCollectionMultiTable(metaKey: string, dim: number, distance: DistanceKind, vectorType: VectorType, tableName: string): Promise<void>;
3
+ export declare function deleteCollectionMultiTable(metaKey: string, tableName: string): Promise<void>;
@@ -0,0 +1,23 @@
1
+ import { Types, TypedValues, withSession, TableDescription, Column, } from "../ydb/client.js";
2
+ import { upsertCollectionMeta } from "./collectionsRepo.shared.js";
3
+ export async function createCollectionMultiTable(metaKey, dim, distance, vectorType, tableName) {
4
+ await withSession(async (s) => {
5
+ const desc = new TableDescription()
6
+ .withColumns(new Column("point_id", Types.UTF8), new Column("embedding", Types.BYTES), new Column("payload", Types.JSON_DOCUMENT))
7
+ .withPrimaryKey("point_id");
8
+ await s.createTable(tableName, desc);
9
+ });
10
+ await upsertCollectionMeta(metaKey, dim, distance, vectorType, tableName);
11
+ }
12
+ export async function deleteCollectionMultiTable(metaKey, tableName) {
13
+ await withSession(async (s) => {
14
+ await s.dropTable(tableName);
15
+ });
16
+ const delMeta = `
17
+ DECLARE $collection AS Utf8;
18
+ DELETE FROM qdr__collections WHERE collection = $collection;
19
+ `;
20
+ await withSession(async (s) => {
21
+ await s.executeQuery(delMeta, { $collection: TypedValues.utf8(metaKey) });
22
+ });
23
+ }
@@ -0,0 +1,3 @@
1
+ import type { DistanceKind, VectorType } from "../types";
2
+ export declare function createCollectionOneTable(metaKey: string, dim: number, distance: DistanceKind, vectorType: VectorType): Promise<void>;
3
+ export declare function deleteCollectionOneTable(metaKey: string, uid: string): Promise<void>;
@@ -0,0 +1,25 @@
1
+ import { TypedValues, withSession } from "../ydb/client.js";
2
+ import { GLOBAL_POINTS_TABLE, ensureGlobalPointsTable } from "../ydb/schema.js";
3
+ import { upsertCollectionMeta } from "./collectionsRepo.shared.js";
4
+ export async function createCollectionOneTable(metaKey, dim, distance, vectorType) {
5
+ await upsertCollectionMeta(metaKey, dim, distance, vectorType, GLOBAL_POINTS_TABLE);
6
+ }
7
+ export async function deleteCollectionOneTable(metaKey, uid) {
8
+ await ensureGlobalPointsTable();
9
+ const deletePointsYql = `
10
+ DECLARE $uid AS Utf8;
11
+ DELETE FROM ${GLOBAL_POINTS_TABLE} WHERE uid = $uid;
12
+ `;
13
+ await withSession(async (s) => {
14
+ await s.executeQuery(deletePointsYql, {
15
+ $uid: TypedValues.utf8(uid),
16
+ });
17
+ });
18
+ const delMeta = `
19
+ DECLARE $collection AS Utf8;
20
+ DELETE FROM qdr__collections WHERE collection = $collection;
21
+ `;
22
+ await withSession(async (s) => {
23
+ await s.executeQuery(delMeta, { $collection: TypedValues.utf8(metaKey) });
24
+ });
25
+ }
@@ -0,0 +1,2 @@
1
+ import type { DistanceKind, VectorType } from "../types";
2
+ export declare function upsertCollectionMeta(metaKey: string, dim: number, distance: DistanceKind, vectorType: VectorType, tableName: string): Promise<void>;
@@ -0,0 +1,23 @@
1
+ import { TypedValues, withSession } from "../ydb/client.js";
2
+ export async function upsertCollectionMeta(metaKey, dim, distance, vectorType, tableName) {
3
+ const upsertMeta = `
4
+ DECLARE $collection AS Utf8;
5
+ DECLARE $table AS Utf8;
6
+ DECLARE $dim AS Uint32;
7
+ DECLARE $distance AS Utf8;
8
+ DECLARE $vtype AS Utf8;
9
+ DECLARE $created AS Timestamp;
10
+ UPSERT INTO qdr__collections (collection, table_name, vector_dimension, distance, vector_type, created_at)
11
+ VALUES ($collection, $table, $dim, $distance, $vtype, $created);
12
+ `;
13
+ await withSession(async (s) => {
14
+ await s.executeQuery(upsertMeta, {
15
+ $collection: TypedValues.utf8(metaKey),
16
+ $table: TypedValues.utf8(tableName),
17
+ $dim: TypedValues.uint32(dim),
18
+ $distance: TypedValues.utf8(distance),
19
+ $vtype: TypedValues.utf8(vectorType),
20
+ $created: TypedValues.timestamp(new Date()),
21
+ });
22
+ });
23
+ }
@@ -3,10 +3,10 @@ export declare function upsertPoints(tableName: string, points: Array<{
3
3
  id: string | number;
4
4
  vector: number[];
5
5
  payload?: Record<string, unknown>;
6
- }>, dimension: number): Promise<number>;
7
- export declare function searchPoints(tableName: string, queryVector: number[], top: number, withPayload: boolean | undefined, distance: DistanceKind, dimension: number): Promise<Array<{
6
+ }>, dimension: number, uid?: string): Promise<number>;
7
+ export declare function searchPoints(tableName: string, queryVector: number[], top: number, withPayload: boolean | undefined, distance: DistanceKind, dimension: number, uid?: string): Promise<Array<{
8
8
  id: string;
9
9
  score: number;
10
10
  payload?: Record<string, unknown>;
11
11
  }>>;
12
- export declare function deletePoints(tableName: string, ids: Array<string | number>): Promise<number>;
12
+ export declare function deletePoints(tableName: string, ids: Array<string | number>, uid?: string): Promise<number>;
@@ -1,133 +1,20 @@
1
- import { TypedValues, withSession } from "../ydb/client.js";
2
- import { buildJsonOrEmpty, buildVectorParam } from "../ydb/helpers.js";
3
- import { logger } from "../logging/logger.js";
4
- import { notifyUpsert } from "../indexing/IndexScheduler.js";
5
- import { VECTOR_INDEX_BUILD_ENABLED } from "../config/env.js";
6
- import { mapDistanceToKnnFn } from "../utils/distance.js";
7
- import { withRetry, isTransientYdbError } from "../utils/retry.js";
8
- export async function upsertPoints(tableName, points, dimension) {
9
- let upserted = 0;
10
- 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
- }
16
- const ddl = `
17
- DECLARE $id AS Utf8;
18
- DECLARE $vec AS List<Float>;
19
- DECLARE $payload AS JsonDocument;
20
- UPSERT INTO ${tableName} (point_id, embedding, payload)
21
- VALUES (
22
- $id,
23
- Untag(Knn::ToBinaryStringFloat($vec), "FloatVector"),
24
- $payload
25
- );
26
- `;
27
- const params = {
28
- $id: TypedValues.utf8(id),
29
- $vec: buildVectorParam(p.vector),
30
- $payload: buildJsonOrEmpty(p.payload),
31
- };
32
- await withRetry(() => s.executeQuery(ddl, params), {
33
- isTransient: isTransientYdbError,
34
- context: { tableName, id },
35
- });
36
- upserted += 1;
37
- }
38
- });
39
- notifyUpsert(tableName, upserted);
40
- return upserted;
41
- }
42
- // Removed legacy index backfill helper
43
- export async function searchPoints(tableName, queryVector, top, withPayload, distance, dimension) {
44
- if (queryVector.length !== dimension) {
45
- throw new Error(`Vector dimension mismatch: got ${queryVector.length}, expected ${dimension}`);
46
- }
47
- const { fn, order } = mapDistanceToKnnFn(distance);
48
- // Single-phase search over embedding using vector index if present
49
- const qf = buildVectorParam(queryVector);
50
- const params = {
51
- $qf: qf,
52
- $k2: TypedValues.uint32(top),
53
- };
54
- const buildQuery = (useIndex) => `
55
- DECLARE $qf AS List<Float>;
56
- DECLARE $k2 AS Uint32;
57
- $qbinf = Knn::ToBinaryStringFloat($qf);
58
- SELECT point_id, ${withPayload ? "payload, " : ""}${fn}(embedding, $qbinf) AS score
59
- FROM ${tableName}${useIndex ? " VIEW emb_idx" : ""}
60
- ORDER BY score ${order}
61
- LIMIT $k2;
62
- `;
63
- let rs;
64
- if (VECTOR_INDEX_BUILD_ENABLED) {
65
- try {
66
- // Try with vector index first
67
- rs = await withSession(async (s) => {
68
- return await s.executeQuery(buildQuery(true), params);
69
- });
70
- logger.info({ tableName }, "vector index found; using index for search");
71
- }
72
- catch (e) {
73
- const msg = e instanceof Error ? e.message : String(e);
74
- const indexUnavailable = /not found|does not exist|no such index|no global index|is not ready to use/i.test(msg);
75
- if (indexUnavailable) {
76
- logger.info({ tableName }, "vector index not available (missing or building); falling back to table scan");
77
- rs = await withSession(async (s) => {
78
- return await s.executeQuery(buildQuery(false), params);
79
- });
80
- }
81
- else {
82
- throw e;
83
- }
84
- }
1
+ import { upsertPointsMultiTable, searchPointsMultiTable, deletePointsMultiTable, } from "./pointsRepo.multi-table.js";
2
+ import { upsertPointsOneTable, searchPointsOneTable, deletePointsOneTable, } from "./pointsRepo.one-table.js";
3
+ export async function upsertPoints(tableName, points, dimension, uid) {
4
+ if (uid) {
5
+ return await upsertPointsOneTable(tableName, points, dimension, uid);
85
6
  }
86
- else {
87
- // Vector index usage disabled: always use table scan
88
- rs = await withSession(async (s) => {
89
- return await s.executeQuery(buildQuery(false), params);
90
- });
7
+ return await upsertPointsMultiTable(tableName, points, dimension);
8
+ }
9
+ export async function searchPoints(tableName, queryVector, top, withPayload, distance, dimension, uid) {
10
+ if (uid) {
11
+ return await searchPointsOneTable(tableName, queryVector, top, withPayload, distance, dimension, uid);
91
12
  }
92
- const rowset = rs.resultSets?.[0];
93
- const rows = (rowset?.rows ?? []);
94
- return rows.map((row) => {
95
- const id = row.items?.[0]?.textValue;
96
- if (typeof id !== "string") {
97
- throw new Error("point_id is missing in YDB search result");
98
- }
99
- let payload;
100
- let scoreIdx = 1;
101
- if (withPayload) {
102
- const payloadText = row.items?.[1]?.textValue;
103
- if (payloadText) {
104
- try {
105
- payload = JSON.parse(payloadText);
106
- }
107
- catch {
108
- payload = undefined;
109
- }
110
- }
111
- scoreIdx = 2;
112
- }
113
- const score = Number(row.items?.[scoreIdx]?.floatValue ?? row.items?.[scoreIdx]?.textValue);
114
- return { id, score, ...(payload ? { payload } : {}) };
115
- });
13
+ return await searchPointsMultiTable(tableName, queryVector, top, withPayload, distance, dimension);
116
14
  }
117
- export async function deletePoints(tableName, ids) {
118
- let deleted = 0;
119
- await withSession(async (s) => {
120
- for (const id of ids) {
121
- const yql = `
122
- DECLARE $id AS Utf8;
123
- DELETE FROM ${tableName} WHERE point_id = $id;
124
- `;
125
- const params = {
126
- $id: TypedValues.utf8(String(id)),
127
- };
128
- await s.executeQuery(yql, params);
129
- deleted += 1;
130
- }
131
- });
132
- return deleted;
15
+ export async function deletePoints(tableName, ids, uid) {
16
+ if (uid) {
17
+ return await deletePointsOneTable(tableName, ids, uid);
18
+ }
19
+ return await deletePointsMultiTable(tableName, ids);
133
20
  }
@@ -0,0 +1,12 @@
1
+ import type { DistanceKind } from "../types";
2
+ export declare function upsertPointsMultiTable(tableName: string, points: Array<{
3
+ id: string | number;
4
+ vector: number[];
5
+ payload?: Record<string, unknown>;
6
+ }>, dimension: number): Promise<number>;
7
+ export declare function searchPointsMultiTable(tableName: string, queryVector: number[], top: number, withPayload: boolean | undefined, distance: DistanceKind, dimension: number): Promise<Array<{
8
+ id: string;
9
+ score: number;
10
+ payload?: Record<string, unknown>;
11
+ }>>;
12
+ export declare function deletePointsMultiTable(tableName: string, ids: Array<string | number>): Promise<number>;
@@ -0,0 +1,129 @@
1
+ import { TypedValues, withSession } from "../ydb/client.js";
2
+ import { buildJsonOrEmpty, buildVectorParam } from "../ydb/helpers.js";
3
+ import { logger } from "../logging/logger.js";
4
+ import { notifyUpsert } from "../indexing/IndexScheduler.js";
5
+ import { VECTOR_INDEX_BUILD_ENABLED } from "../config/env.js";
6
+ import { mapDistanceToKnnFn } from "../utils/distance.js";
7
+ import { withRetry, isTransientYdbError } from "../utils/retry.js";
8
+ export async function upsertPointsMultiTable(tableName, points, dimension) {
9
+ let upserted = 0;
10
+ 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
+ }
16
+ const ddl = `
17
+ DECLARE $id AS Utf8;
18
+ DECLARE $vec AS List<Float>;
19
+ DECLARE $payload AS JsonDocument;
20
+ UPSERT INTO ${tableName} (point_id, embedding, payload)
21
+ VALUES (
22
+ $id,
23
+ Untag(Knn::ToBinaryStringFloat($vec), "FloatVector"),
24
+ $payload
25
+ );
26
+ `;
27
+ const params = {
28
+ $id: TypedValues.utf8(id),
29
+ $vec: buildVectorParam(p.vector),
30
+ $payload: buildJsonOrEmpty(p.payload),
31
+ };
32
+ await withRetry(() => s.executeQuery(ddl, params), {
33
+ isTransient: isTransientYdbError,
34
+ context: { tableName, id },
35
+ });
36
+ upserted += 1;
37
+ }
38
+ });
39
+ notifyUpsert(tableName, upserted);
40
+ return upserted;
41
+ }
42
+ export async function searchPointsMultiTable(tableName, queryVector, top, withPayload, distance, dimension) {
43
+ if (queryVector.length !== dimension) {
44
+ throw new Error(`Vector dimension mismatch: got ${queryVector.length}, expected ${dimension}`);
45
+ }
46
+ const { fn, order } = mapDistanceToKnnFn(distance);
47
+ const qf = buildVectorParam(queryVector);
48
+ const params = {
49
+ $qf: qf,
50
+ $k2: TypedValues.uint32(top),
51
+ };
52
+ const buildQuery = (useIndex) => `
53
+ DECLARE $qf AS List<Float>;
54
+ DECLARE $k2 AS Uint32;
55
+ $qbinf = Knn::ToBinaryStringFloat($qf);
56
+ SELECT point_id, ${withPayload ? "payload, " : ""}${fn}(embedding, $qbinf) AS score
57
+ FROM ${tableName}${useIndex ? " VIEW emb_idx" : ""}
58
+ ORDER BY score ${order}
59
+ LIMIT $k2;
60
+ `;
61
+ let rs;
62
+ if (VECTOR_INDEX_BUILD_ENABLED) {
63
+ try {
64
+ rs = await withSession(async (s) => {
65
+ return await s.executeQuery(buildQuery(true), params);
66
+ });
67
+ logger.info({ tableName }, "vector index found; using index for search");
68
+ }
69
+ catch (e) {
70
+ const msg = e instanceof Error ? e.message : String(e);
71
+ const indexUnavailable = /not found|does not exist|no such index|no global index|is not ready to use/i.test(msg);
72
+ if (indexUnavailable) {
73
+ logger.info({ tableName }, "vector index not available (missing or building); falling back to table scan");
74
+ rs = await withSession(async (s) => {
75
+ return await s.executeQuery(buildQuery(false), params);
76
+ });
77
+ }
78
+ else {
79
+ throw e;
80
+ }
81
+ }
82
+ }
83
+ else {
84
+ rs = await withSession(async (s) => {
85
+ return await s.executeQuery(buildQuery(false), params);
86
+ });
87
+ }
88
+ const rowset = rs.resultSets?.[0];
89
+ const rows = (rowset?.rows ?? []);
90
+ return rows.map((row) => {
91
+ const id = row.items?.[0]?.textValue;
92
+ if (typeof id !== "string") {
93
+ throw new Error("point_id is missing in YDB search result");
94
+ }
95
+ let payload;
96
+ let scoreIdx = 1;
97
+ if (withPayload) {
98
+ const payloadText = row.items?.[1]?.textValue;
99
+ if (payloadText) {
100
+ try {
101
+ payload = JSON.parse(payloadText);
102
+ }
103
+ catch {
104
+ payload = undefined;
105
+ }
106
+ }
107
+ scoreIdx = 2;
108
+ }
109
+ const score = Number(row.items?.[scoreIdx]?.floatValue ?? row.items?.[scoreIdx]?.textValue);
110
+ return { id, score, ...(payload ? { payload } : {}) };
111
+ });
112
+ }
113
+ export async function deletePointsMultiTable(tableName, ids) {
114
+ let deleted = 0;
115
+ await withSession(async (s) => {
116
+ for (const id of ids) {
117
+ const yql = `
118
+ DECLARE $id AS Utf8;
119
+ DELETE FROM ${tableName} WHERE point_id = $id;
120
+ `;
121
+ const params = {
122
+ $id: TypedValues.utf8(String(id)),
123
+ };
124
+ await s.executeQuery(yql, params);
125
+ deleted += 1;
126
+ }
127
+ });
128
+ return deleted;
129
+ }
@@ -0,0 +1,12 @@
1
+ import type { DistanceKind } from "../types";
2
+ export declare function upsertPointsOneTable(tableName: string, points: Array<{
3
+ id: string | number;
4
+ vector: number[];
5
+ payload?: Record<string, unknown>;
6
+ }>, 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
+ id: string;
9
+ score: number;
10
+ payload?: Record<string, unknown>;
11
+ }>>;
12
+ export declare function deletePointsOneTable(tableName: string, ids: Array<string | number>, uid: string): Promise<number>;
@@ -0,0 +1,145 @@
1
+ import { TypedValues, Types, withSession } from "../ydb/client.js";
2
+ import { buildJsonOrEmpty, buildVectorParam } from "../ydb/helpers.js";
3
+ import { notifyUpsert } from "../indexing/IndexScheduler.js";
4
+ import { mapDistanceToKnnFn, mapDistanceToBitKnnFn, } from "../utils/distance.js";
5
+ import { withRetry, isTransientYdbError } from "../utils/retry.js";
6
+ export async function upsertPointsOneTable(tableName, points, dimension, uid) {
7
+ let upserted = 0;
8
+ 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}`);
13
+ }
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
+ };
34
+ await withRetry(() => s.executeQuery(ddl, params), {
35
+ isTransient: isTransientYdbError,
36
+ context: { tableName, id },
37
+ });
38
+ upserted += 1;
39
+ }
40
+ });
41
+ notifyUpsert(tableName, upserted);
42
+ return upserted;
43
+ }
44
+ export async function searchPointsOneTable(tableName, queryVector, top, withPayload, distance, dimension, uid) {
45
+ if (queryVector.length !== dimension) {
46
+ throw new Error(`Vector dimension mismatch: got ${queryVector.length}, expected ${dimension}`);
47
+ }
48
+ const { fn, order } = mapDistanceToKnnFn(distance);
49
+ const { fn: bitFn, order: bitOrder } = mapDistanceToBitKnnFn(distance);
50
+ const qf = buildVectorParam(queryVector);
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 [];
78
+ }
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
+ }
118
+ }
119
+ scoreIdx = 2;
120
+ }
121
+ const score = Number(row.items?.[scoreIdx]?.floatValue ?? row.items?.[scoreIdx]?.textValue);
122
+ return { id, score, ...(payload ? { payload } : {}) };
123
+ });
124
+ });
125
+ return results;
126
+ }
127
+ export async function deletePointsOneTable(tableName, ids, uid) {
128
+ let deleted = 0;
129
+ await withSession(async (s) => {
130
+ for (const id of ids) {
131
+ const yql = `
132
+ DECLARE $uid AS Utf8;
133
+ DECLARE $id AS Utf8;
134
+ DELETE FROM ${tableName} WHERE uid = $uid AND point_id = $id;
135
+ `;
136
+ const params = {
137
+ $uid: TypedValues.utf8(uid),
138
+ $id: TypedValues.utf8(String(id)),
139
+ };
140
+ await s.executeQuery(yql, params);
141
+ deleted += 1;
142
+ }
143
+ });
144
+ return deleted;
145
+ }
@@ -9,6 +9,12 @@ export interface NormalizedCollectionContext {
9
9
  metaKey: string;
10
10
  }
11
11
  export declare function normalizeCollectionContext(input: CollectionContextInput): NormalizedCollectionContext;
12
+ export declare function resolvePointsTableAndUid(ctx: NormalizedCollectionContext, meta: {
13
+ table: string;
14
+ }): Promise<{
15
+ tableName: string;
16
+ uid: string | undefined;
17
+ }>;
12
18
  export declare function putCollectionIndex(ctx: CollectionContextInput): Promise<{
13
19
  acknowledged: boolean;
14
20
  }>;
@@ -1,13 +1,20 @@
1
- import { sanitizeCollectionName, sanitizeTenantId, metaKeyFor, tableNameFor, } from "../utils/tenant.js";
2
1
  import { CreateCollectionReq } from "../types.js";
3
- import { ensureMetaTable } from "../ydb/schema.js";
2
+ import { ensureMetaTable, GLOBAL_POINTS_TABLE } from "../ydb/schema.js";
4
3
  import { createCollection as repoCreateCollection, deleteCollection as repoDeleteCollection, getCollectionMeta, } from "../repositories/collectionsRepo.js";
5
4
  import { QdrantServiceError } from "./errors.js";
5
+ import { normalizeCollectionContextShared, tableNameFor, } from "./CollectionService.shared.js";
6
+ import { resolvePointsTableAndUidOneTable } from "./CollectionService.one-table.js";
6
7
  export function normalizeCollectionContext(input) {
7
- const tenant = sanitizeTenantId(input.tenant);
8
- const collection = sanitizeCollectionName(input.collection);
9
- const metaKey = metaKeyFor(tenant, collection);
10
- return { tenant, collection, metaKey };
8
+ return normalizeCollectionContextShared(input.tenant, input.collection);
9
+ }
10
+ export async function resolvePointsTableAndUid(ctx, meta) {
11
+ if (meta?.table === GLOBAL_POINTS_TABLE) {
12
+ return await resolvePointsTableAndUidOneTable(ctx);
13
+ }
14
+ return {
15
+ tableName: meta.table,
16
+ uid: undefined,
17
+ };
11
18
  }
12
19
  export async function putCollectionIndex(ctx) {
13
20
  await ensureMetaTable();
@@ -0,0 +1,5 @@
1
+ import { type NormalizedCollectionContextLike } from "./CollectionService.shared.js";
2
+ export declare function resolvePointsTableAndUidMultiTable(ctx: NormalizedCollectionContextLike): {
3
+ tableName: string;
4
+ uid: string | undefined;
5
+ };
@@ -0,0 +1,7 @@
1
+ import { tableNameFor, } from "./CollectionService.shared.js";
2
+ export function resolvePointsTableAndUidMultiTable(ctx) {
3
+ return {
4
+ tableName: tableNameFor(ctx.tenant, ctx.collection),
5
+ uid: undefined,
6
+ };
7
+ }
@@ -0,0 +1,5 @@
1
+ import { type NormalizedCollectionContextLike } from "./CollectionService.shared.js";
2
+ export declare function resolvePointsTableAndUidOneTable(ctx: NormalizedCollectionContextLike): Promise<{
3
+ tableName: string;
4
+ uid: string | undefined;
5
+ }>;
@@ -0,0 +1,9 @@
1
+ import { uidFor, } from "./CollectionService.shared.js";
2
+ import { GLOBAL_POINTS_TABLE, ensureGlobalPointsTable } from "../ydb/schema.js";
3
+ export async function resolvePointsTableAndUidOneTable(ctx) {
4
+ await ensureGlobalPointsTable();
5
+ return {
6
+ tableName: GLOBAL_POINTS_TABLE,
7
+ uid: uidFor(ctx.tenant, ctx.collection),
8
+ };
9
+ }
@@ -0,0 +1,11 @@
1
+ export interface NormalizedCollectionContextLike {
2
+ tenant: string;
3
+ collection: string;
4
+ }
5
+ export declare function tableNameFor(tenantId: string, collection: string): string;
6
+ export declare function uidFor(tenantId: string, collection: string): string;
7
+ export declare function normalizeCollectionContextShared(tenant: string | undefined, collection: string): {
8
+ tenant: string;
9
+ collection: string;
10
+ metaKey: string;
11
+ };
@@ -0,0 +1,17 @@
1
+ import { sanitizeCollectionName, sanitizeTenantId, metaKeyFor, tableNameFor as tableNameForInternal, uidFor as uidForInternal, } from "../utils/tenant.js";
2
+ export function tableNameFor(tenantId, collection) {
3
+ return tableNameForInternal(tenantId, collection);
4
+ }
5
+ export function uidFor(tenantId, collection) {
6
+ return uidForInternal(tenantId, collection);
7
+ }
8
+ export function normalizeCollectionContextShared(tenant, collection) {
9
+ const normalizedTenant = sanitizeTenantId(tenant);
10
+ const normalizedCollection = sanitizeCollectionName(collection);
11
+ const metaKey = metaKeyFor(normalizedTenant, normalizedCollection);
12
+ return {
13
+ tenant: normalizedTenant,
14
+ collection: normalizedCollection,
15
+ metaKey,
16
+ };
17
+ }
@@ -6,7 +6,7 @@ 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
8
  import { QdrantServiceError } from "./errors.js";
9
- import { normalizeCollectionContext, } from "./CollectionService.js";
9
+ import { normalizeCollectionContext, resolvePointsTableAndUid, } from "./CollectionService.js";
10
10
  import { normalizeSearchBodyForSearch, normalizeSearchBodyForQuery, } from "../utils/normalization.js";
11
11
  let loggedIndexBuildDisabled = false;
12
12
  export async function upsertPoints(ctx, body) {
@@ -26,12 +26,13 @@ export async function upsertPoints(ctx, body) {
26
26
  error: parsed.error.flatten(),
27
27
  });
28
28
  }
29
- const upserted = await repoUpsertPoints(meta.table, parsed.data.points, meta.dimension);
29
+ const { tableName, uid } = await resolvePointsTableAndUid(normalized, meta);
30
+ const upserted = await repoUpsertPoints(tableName, parsed.data.points, meta.dimension, uid);
30
31
  if (VECTOR_INDEX_BUILD_ENABLED) {
31
- requestIndexBuild(meta.table, meta.dimension, meta.distance, meta.vectorType);
32
+ requestIndexBuild(tableName, meta.dimension, meta.distance, meta.vectorType);
32
33
  }
33
34
  else if (!loggedIndexBuildDisabled) {
34
- logger.info({ table: meta.table }, "vector index building disabled by env; skipping automatic emb_idx rebuilds");
35
+ logger.info({ table: tableName }, "vector index building disabled by env; skipping automatic emb_idx rebuilds");
35
36
  loggedIndexBuildDisabled = true;
36
37
  }
37
38
  return { upserted };
@@ -68,6 +69,7 @@ async function executeSearch(ctx, normalizedSearch, source) {
68
69
  error: parsed.error.flatten(),
69
70
  });
70
71
  }
72
+ const { tableName, uid } = await resolvePointsTableAndUid(normalized, meta);
71
73
  logger.info({
72
74
  tenant: normalized.tenant,
73
75
  collection: normalized.collection,
@@ -77,7 +79,7 @@ async function executeSearch(ctx, normalizedSearch, source) {
77
79
  distance: meta.distance,
78
80
  vectorType: meta.vectorType,
79
81
  }, `${source}: executing`);
80
- const hits = await repoSearchPoints(meta.table, parsed.data.vector, parsed.data.top, parsed.data.with_payload, meta.distance, meta.dimension);
82
+ const hits = await repoSearchPoints(tableName, parsed.data.vector, parsed.data.top, parsed.data.with_payload, meta.distance, meta.dimension, uid);
81
83
  const threshold = normalizedSearch.scoreThreshold;
82
84
  const filtered = threshold === undefined
83
85
  ? hits
@@ -120,6 +122,7 @@ export async function deletePoints(ctx, body) {
120
122
  error: parsed.error.flatten(),
121
123
  });
122
124
  }
123
- const deleted = await repoDeletePoints(meta.table, parsed.data.points);
125
+ const { tableName, uid } = await resolvePointsTableAndUid(normalized, meta);
126
+ const deleted = await repoDeletePoints(tableName, parsed.data.points, uid);
124
127
  return { deleted };
125
128
  }
@@ -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
+ }
@@ -2,3 +2,4 @@ export declare function sanitizeCollectionName(name: string): string;
2
2
  export declare function sanitizeTenantId(tenantId: string | undefined): string;
3
3
  export declare function tableNameFor(tenantId: string, collection: string): string;
4
4
  export declare function metaKeyFor(tenantId: string, collection: string): string;
5
+ export declare function uidFor(tenantId: string, collectionName: string): string;
@@ -15,3 +15,6 @@ export function tableNameFor(tenantId, collection) {
15
15
  export function metaKeyFor(tenantId, collection) {
16
16
  return `${sanitizeTenantId(tenantId)}/${sanitizeCollectionName(collection)}`;
17
17
  }
18
+ export function uidFor(tenantId, collectionName) {
19
+ return tableNameFor(tenantId, collectionName);
20
+ }
@@ -1 +1,3 @@
1
+ export declare const GLOBAL_POINTS_TABLE = "qdrant_all_points";
1
2
  export declare function ensureMetaTable(): Promise<void>;
3
+ export declare function ensureGlobalPointsTable(): Promise<void>;
@@ -1,5 +1,7 @@
1
1
  import { withSession, TableDescription, Column, Types } from "./client.js";
2
2
  import { logger } from "../logging/logger.js";
3
+ export const GLOBAL_POINTS_TABLE = "qdrant_all_points";
4
+ let globalPointsTableReady = false;
3
5
  export async function ensureMetaTable() {
4
6
  try {
5
7
  await withSession(async (s) => {
@@ -22,3 +24,68 @@ export async function ensureMetaTable() {
22
24
  logger.debug({ err }, "ensureMetaTable: ignored");
23
25
  }
24
26
  }
27
+ export async function ensureGlobalPointsTable() {
28
+ if (globalPointsTableReady) {
29
+ return;
30
+ }
31
+ try {
32
+ await withSession(async (s) => {
33
+ let tableDescription = null;
34
+ try {
35
+ tableDescription = await s.describeTable(GLOBAL_POINTS_TABLE);
36
+ }
37
+ catch {
38
+ // Table doesn't exist, create it with all columns
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("embedding_bit", 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}`);
45
+ return;
46
+ }
47
+ // Table exists, check if embedding_bit column is present
48
+ const columns = tableDescription.columns ?? [];
49
+ const hasEmbeddingBit = columns.some((col) => col.name === "embedding_bit");
50
+ let needsBackfill = false;
51
+ if (!hasEmbeddingBit) {
52
+ // Add the missing embedding_bit column
53
+ const alterDdl = `
54
+ ALTER TABLE ${GLOBAL_POINTS_TABLE}
55
+ ADD COLUMN embedding_bit String;
56
+ `;
57
+ await s.executeQuery(alterDdl);
58
+ logger.info(`added embedding_bit column to existing table ${GLOBAL_POINTS_TABLE}`);
59
+ needsBackfill = true;
60
+ }
61
+ else {
62
+ // Column exists; check if any legacy rows still have NULL embedding_bit
63
+ const checkNullsDdl = `
64
+ SELECT 1 AS has_null
65
+ FROM ${GLOBAL_POINTS_TABLE}
66
+ WHERE embedding_bit IS NULL
67
+ LIMIT 1;
68
+ `;
69
+ const checkRes = await s.executeQuery(checkNullsDdl);
70
+ const hasNullRows = checkRes.resultSets?.[0]?.rows &&
71
+ checkRes.resultSets[0].rows.length > 0;
72
+ needsBackfill = Boolean(hasNullRows);
73
+ }
74
+ if (needsBackfill) {
75
+ // Backfill existing rows: convert embedding to bit representation
76
+ const backfillDdl = `
77
+ UPDATE ${GLOBAL_POINTS_TABLE}
78
+ SET embedding_bit = Untag(Knn::ToBinaryStringBit(Knn::FloatFromBinaryString(embedding)), "BitVector")
79
+ WHERE embedding_bit IS NULL;
80
+ `;
81
+ await s.executeQuery(backfillDdl);
82
+ logger.info(`backfilled embedding_bit column from embedding in ${GLOBAL_POINTS_TABLE}`);
83
+ }
84
+ // Mark table ready only after schema (and any required backfill) succeed
85
+ globalPointsTableReady = true;
86
+ });
87
+ }
88
+ catch (err) {
89
+ logger.debug({ err }, "ensureGlobalPointsTable: ignored");
90
+ }
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ydb-qdrant",
3
- "version": "4.0.0",
3
+ "version": "4.1.1",
4
4
  "main": "dist/package/api.js",
5
5
  "types": "dist/package/api.d.ts",
6
6
  "exports": {