@functional-systems/lambdadb 0.3.4-dev.0 → 0.4.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 +50 -7
- package/dist/commonjs/compat/qdrant/client.d.ts +210 -0
- package/dist/commonjs/compat/qdrant/client.d.ts.map +1 -0
- package/dist/commonjs/compat/qdrant/client.js +573 -0
- package/dist/commonjs/compat/qdrant/client.js.map +1 -0
- package/dist/commonjs/compat/qdrant/conversions.d.ts +40 -0
- package/dist/commonjs/compat/qdrant/conversions.d.ts.map +1 -0
- package/dist/commonjs/compat/qdrant/conversions.js +386 -0
- package/dist/commonjs/compat/qdrant/conversions.js.map +1 -0
- package/dist/commonjs/compat/qdrant/errors.d.ts +10 -0
- package/dist/commonjs/compat/qdrant/errors.d.ts.map +1 -0
- package/dist/commonjs/compat/qdrant/errors.js +25 -0
- package/dist/commonjs/compat/qdrant/errors.js.map +1 -0
- package/dist/commonjs/compat/qdrant/filters.d.ts +10 -0
- package/dist/commonjs/compat/qdrant/filters.d.ts.map +1 -0
- package/dist/commonjs/compat/qdrant/filters.js +172 -0
- package/dist/commonjs/compat/qdrant/filters.js.map +1 -0
- package/dist/commonjs/compat/qdrant/models.d.ts +198 -0
- package/dist/commonjs/compat/qdrant/models.d.ts.map +1 -0
- package/dist/commonjs/compat/qdrant/models.js +205 -0
- package/dist/commonjs/compat/qdrant/models.js.map +1 -0
- package/dist/commonjs/compat/qdrant.d.ts +4 -0
- package/dist/commonjs/compat/qdrant.d.ts.map +1 -0
- package/dist/commonjs/compat/qdrant.js +45 -0
- package/dist/commonjs/compat/qdrant.js.map +1 -0
- package/dist/commonjs/models/indexconfigsunion.d.ts +2 -2
- package/dist/commonjs/models/indexconfigsunion.d.ts.map +1 -1
- package/dist/commonjs/models/indexconfigsunion.js +4 -4
- package/dist/commonjs/models/indexconfigsunion.js.map +1 -1
- package/dist/esm/compat/qdrant/client.d.ts +210 -0
- package/dist/esm/compat/qdrant/client.d.ts.map +1 -0
- package/dist/esm/compat/qdrant/client.js +536 -0
- package/dist/esm/compat/qdrant/client.js.map +1 -0
- package/dist/esm/compat/qdrant/conversions.d.ts +40 -0
- package/dist/esm/compat/qdrant/conversions.d.ts.map +1 -0
- package/dist/esm/compat/qdrant/conversions.js +336 -0
- package/dist/esm/compat/qdrant/conversions.js.map +1 -0
- package/dist/esm/compat/qdrant/errors.d.ts +10 -0
- package/dist/esm/compat/qdrant/errors.d.ts.map +1 -0
- package/dist/esm/compat/qdrant/errors.js +19 -0
- package/dist/esm/compat/qdrant/errors.js.map +1 -0
- package/dist/esm/compat/qdrant/filters.d.ts +10 -0
- package/dist/esm/compat/qdrant/filters.d.ts.map +1 -0
- package/dist/esm/compat/qdrant/filters.js +169 -0
- package/dist/esm/compat/qdrant/filters.js.map +1 -0
- package/dist/esm/compat/qdrant/models.d.ts +198 -0
- package/dist/esm/compat/qdrant/models.d.ts.map +1 -0
- package/dist/esm/compat/qdrant/models.js +185 -0
- package/dist/esm/compat/qdrant/models.js.map +1 -0
- package/dist/esm/compat/qdrant.d.ts +4 -0
- package/dist/esm/compat/qdrant.d.ts.map +1 -0
- package/dist/esm/compat/qdrant.js +4 -0
- package/dist/esm/compat/qdrant.js.map +1 -0
- package/dist/esm/models/indexconfigsunion.d.ts +2 -2
- package/dist/esm/models/indexconfigsunion.d.ts.map +1 -1
- package/dist/esm/models/indexconfigsunion.js +4 -4
- package/dist/esm/models/indexconfigsunion.js.map +1 -1
- package/jsr.json +3 -1
- package/package.json +32 -1
- package/src/compat/qdrant/client.ts +901 -0
- package/src/compat/qdrant/conversions.ts +422 -0
- package/src/compat/qdrant/errors.ts +20 -0
- package/src/compat/qdrant/filters.ts +213 -0
- package/src/compat/qdrant/models.ts +317 -0
- package/src/compat/qdrant.ts +3 -0
- package/src/models/indexconfigsunion.ts +6 -6
- package/test/integration/qdrant-compat-external.test.mjs +226 -0
- package/test/integration/qdrant-compat-live.test.mjs +122 -0
- package/test/public-api.test.mjs +145 -4
- package/test/qdrant-compat.test.mjs +402 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Distance,
|
|
3
|
+
PointStruct,
|
|
4
|
+
SparseVector,
|
|
5
|
+
VectorParams,
|
|
6
|
+
type DenseVector,
|
|
7
|
+
type PointId,
|
|
8
|
+
} from "./models.js";
|
|
9
|
+
import * as models from "./models.js";
|
|
10
|
+
import {
|
|
11
|
+
QdrantCompatValidationError,
|
|
12
|
+
UnsupportedQdrantFeatureError,
|
|
13
|
+
} from "./errors.js";
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_VECTOR_NAME = "_qdrant_vector";
|
|
16
|
+
export const ID_FIELD = "id";
|
|
17
|
+
export const ORIGINAL_ID_FIELD = "_qdrant_id";
|
|
18
|
+
export const RESERVED_PREFIX = "_qdrant_";
|
|
19
|
+
|
|
20
|
+
type JsonObject = { [key: string]: unknown };
|
|
21
|
+
type IndexConfig = { [key: string]: unknown };
|
|
22
|
+
type IndexConfigs = { [fieldName: string]: IndexConfig };
|
|
23
|
+
|
|
24
|
+
const payloadSchemaTypeAliases: { [schemaType: string]: string } = {
|
|
25
|
+
keyword: "keyword",
|
|
26
|
+
integer: "long",
|
|
27
|
+
int: "long",
|
|
28
|
+
long: "long",
|
|
29
|
+
float: "double",
|
|
30
|
+
double: "double",
|
|
31
|
+
bool: "boolean",
|
|
32
|
+
boolean: "boolean",
|
|
33
|
+
datetime: "datetime",
|
|
34
|
+
text: "text",
|
|
35
|
+
uuid: "keyword",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function vectorFieldName(name?: string | null): string {
|
|
39
|
+
if (name == null || name === "") return DEFAULT_VECTOR_NAME;
|
|
40
|
+
return `${DEFAULT_VECTOR_NAME}_${String(name).replaceAll(".", "_")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function pointFromAny(point: unknown): PointStruct {
|
|
44
|
+
if (point instanceof PointStruct) return point;
|
|
45
|
+
if (isObject(point) && "id" in point && "vector" in point) {
|
|
46
|
+
return new PointStruct(point as {
|
|
47
|
+
id: PointId;
|
|
48
|
+
vector: models.PointVector;
|
|
49
|
+
payload?: JsonObject | null;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
throw new QdrantCompatValidationError(`Unsupported point type: ${typeof point}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function pointsToDocs(points: Iterable<unknown>): JsonObject[] {
|
|
56
|
+
return Array.from(points, pointToDoc);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function pointToDoc(point: unknown): JsonObject {
|
|
60
|
+
const item = pointFromAny(point);
|
|
61
|
+
const payload = { ...(item.payload ?? {}) };
|
|
62
|
+
validatePayload(payload);
|
|
63
|
+
|
|
64
|
+
const doc: JsonObject = {
|
|
65
|
+
[ID_FIELD]: String(item.id),
|
|
66
|
+
[ORIGINAL_ID_FIELD]: item.id,
|
|
67
|
+
...payload,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (isDenseVector(item.vector)) {
|
|
71
|
+
doc[DEFAULT_VECTOR_NAME] = item.vector;
|
|
72
|
+
return doc;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (isObject(item.vector)) {
|
|
76
|
+
for (const [name, vector] of Object.entries(item.vector)) {
|
|
77
|
+
if (isSparseVectorLike(vector)) {
|
|
78
|
+
throw new UnsupportedQdrantFeatureError(
|
|
79
|
+
"Sparse vectors are not supported in v1 Qdrant compatibility upsert",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (!isDenseVector(vector)) {
|
|
83
|
+
throw new QdrantCompatValidationError(
|
|
84
|
+
"Named point vectors must be dense vector arrays",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
doc[vectorFieldName(name)] = vector;
|
|
88
|
+
}
|
|
89
|
+
return doc;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw new QdrantCompatValidationError(
|
|
93
|
+
"Point vector must be a dense vector array or named vector object",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function docToRecord(
|
|
98
|
+
input: unknown,
|
|
99
|
+
options: { withPayload?: boolean; withVectors?: boolean } = {},
|
|
100
|
+
): models.Record {
|
|
101
|
+
const doc = unwrapDoc(input);
|
|
102
|
+
return new models.Record({
|
|
103
|
+
id: docId(doc),
|
|
104
|
+
payload: options.withPayload === false ? undefined : payloadFromDoc(doc),
|
|
105
|
+
vector: vectorFromDoc(doc, options.withVectors === true),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function resultToScoredPoint(
|
|
110
|
+
result: unknown,
|
|
111
|
+
options: { withPayload?: boolean; withVectors?: boolean } = {},
|
|
112
|
+
): models.ScoredPoint {
|
|
113
|
+
const doc = unwrapDoc(result);
|
|
114
|
+
const score = isObject(result) && typeof result["score"] === "number"
|
|
115
|
+
? result["score"]
|
|
116
|
+
: undefined;
|
|
117
|
+
return new models.ScoredPoint({
|
|
118
|
+
id: docId(doc),
|
|
119
|
+
score,
|
|
120
|
+
payload: options.withPayload === false ? undefined : payloadFromDoc(doc),
|
|
121
|
+
vector: vectorFromDoc(doc, options.withVectors === true),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function vectorConfigToIndexConfigs(vectorsConfig: unknown): IndexConfigs {
|
|
126
|
+
if (isVectorParamsLike(vectorsConfig)) {
|
|
127
|
+
const params = vectorParamsFromAny(vectorsConfig);
|
|
128
|
+
return {
|
|
129
|
+
[DEFAULT_VECTOR_NAME]: {
|
|
130
|
+
type: "vector",
|
|
131
|
+
dimensions: params.size,
|
|
132
|
+
similarity: distanceToSimilarity(params.distance),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (isObject(vectorsConfig)) {
|
|
138
|
+
if ("size" in vectorsConfig) {
|
|
139
|
+
return vectorConfigToIndexConfigs(vectorParamsFromAny(vectorsConfig));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const indexConfigs: IndexConfigs = {};
|
|
143
|
+
for (const [name, rawParams] of Object.entries(vectorsConfig)) {
|
|
144
|
+
const params = vectorParamsFromAny(rawParams);
|
|
145
|
+
indexConfigs[vectorFieldName(name)] = {
|
|
146
|
+
type: "vector",
|
|
147
|
+
dimensions: params.size,
|
|
148
|
+
similarity: distanceToSimilarity(params.distance),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return indexConfigs;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
throw new QdrantCompatValidationError(
|
|
155
|
+
"vectorsConfig must be VectorParams or a mapping of vector names",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function payloadSchemaToIndexConfigs(payloadSchema?: unknown): IndexConfigs {
|
|
160
|
+
if (payloadSchema == null) return {};
|
|
161
|
+
if (!isObject(payloadSchema)) {
|
|
162
|
+
throw new QdrantCompatValidationError(
|
|
163
|
+
"payloadSchema must be a mapping of payload field names to schema types",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const indexConfigs: IndexConfigs = {};
|
|
168
|
+
for (const [fieldName, rawSchema] of Object.entries(payloadSchema)) {
|
|
169
|
+
validatePayloadFieldName(fieldName);
|
|
170
|
+
indexConfigs[fieldName] = { type: payloadSchemaType(rawSchema) };
|
|
171
|
+
}
|
|
172
|
+
return indexConfigs;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function mergeIndexConfigs(...configs: IndexConfigs[]): IndexConfigs {
|
|
176
|
+
const merged: IndexConfigs = {};
|
|
177
|
+
for (const config of configs) {
|
|
178
|
+
for (const [fieldName, indexConfig] of Object.entries(config)) {
|
|
179
|
+
if (
|
|
180
|
+
fieldName in merged &&
|
|
181
|
+
JSON.stringify(merged[fieldName]) !== JSON.stringify(indexConfig)
|
|
182
|
+
) {
|
|
183
|
+
throw new QdrantCompatValidationError(
|
|
184
|
+
`Conflicting index config for field ${fieldName}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
merged[fieldName] = { ...indexConfig };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return merged;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function queryVectorAndField(
|
|
194
|
+
query: unknown,
|
|
195
|
+
using?: string,
|
|
196
|
+
): { vector: DenseVector; field: string } {
|
|
197
|
+
if (isDenseVector(query)) {
|
|
198
|
+
return { vector: query, field: vectorFieldName(using) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (Array.isArray(query) && query.length === 2 && typeof query[0] === "string") {
|
|
202
|
+
const vector = query[1];
|
|
203
|
+
if (!isDenseVector(vector)) {
|
|
204
|
+
throw new UnsupportedQdrantFeatureError(
|
|
205
|
+
"Only dense vector arrays are supported in v1",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return { vector, field: vectorFieldName(query[0]) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (isObject(query)) {
|
|
212
|
+
if ("nearest" in query) {
|
|
213
|
+
return queryVectorAndField(query["nearest"], using);
|
|
214
|
+
}
|
|
215
|
+
if ("vector" in query) {
|
|
216
|
+
const rawVector = query["vector"];
|
|
217
|
+
if (isObject(rawVector) && "name" in rawVector && "vector" in rawVector) {
|
|
218
|
+
const namedVector = rawVector["vector"];
|
|
219
|
+
if (!isDenseVector(namedVector)) {
|
|
220
|
+
throw new UnsupportedQdrantFeatureError(
|
|
221
|
+
"Only dense vector arrays are supported in v1",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return { vector: namedVector, field: vectorFieldName(String(rawVector["name"])) };
|
|
225
|
+
}
|
|
226
|
+
if (!isDenseVector(rawVector)) {
|
|
227
|
+
throw new UnsupportedQdrantFeatureError(
|
|
228
|
+
"Only dense vector arrays are supported in v1",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return { vector: rawVector, field: vectorFieldName(using) };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
throw new UnsupportedQdrantFeatureError(
|
|
236
|
+
"Only dense vector queryPoints/search inputs are supported in v1",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function plainIndexConfigs(rawIndexConfigs: unknown): IndexConfigs {
|
|
241
|
+
if (!isObject(rawIndexConfigs)) return {};
|
|
242
|
+
const result: IndexConfigs = {};
|
|
243
|
+
for (const [fieldName, rawIndexConfig] of Object.entries(rawIndexConfigs)) {
|
|
244
|
+
if (!isObject(rawIndexConfig)) continue;
|
|
245
|
+
const normalized: IndexConfig = {};
|
|
246
|
+
for (const [key, value] of Object.entries(rawIndexConfig)) {
|
|
247
|
+
if (value !== undefined && value !== null) normalized[key] = value;
|
|
248
|
+
}
|
|
249
|
+
result[fieldName] = normalized;
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function qdrantVectorsConfig(indexConfigs: IndexConfigs): unknown {
|
|
255
|
+
let unnamedVector: VectorParams | undefined;
|
|
256
|
+
const namedVectors: { [name: string]: VectorParams } = {};
|
|
257
|
+
|
|
258
|
+
for (const [fieldName, indexConfig] of Object.entries(indexConfigs)) {
|
|
259
|
+
if (indexConfig["type"] !== "vector") continue;
|
|
260
|
+
const params = new VectorParams({
|
|
261
|
+
size: Number(indexConfig["dimensions"]),
|
|
262
|
+
distance: similarityToDistance(indexConfig["similarity"]),
|
|
263
|
+
});
|
|
264
|
+
if (fieldName === DEFAULT_VECTOR_NAME) {
|
|
265
|
+
unnamedVector = params;
|
|
266
|
+
} else if (fieldName.startsWith(`${DEFAULT_VECTOR_NAME}_`)) {
|
|
267
|
+
namedVectors[fieldName.slice(DEFAULT_VECTOR_NAME.length + 1)] = params;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (unnamedVector !== undefined && Object.keys(namedVectors).length === 0) {
|
|
272
|
+
return unnamedVector;
|
|
273
|
+
}
|
|
274
|
+
if (unnamedVector !== undefined) {
|
|
275
|
+
namedVectors[""] = unnamedVector;
|
|
276
|
+
}
|
|
277
|
+
return namedVectors;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function validatePayloadFieldName(key: string): void {
|
|
281
|
+
if (key.startsWith(RESERVED_PREFIX)) {
|
|
282
|
+
throw new QdrantCompatValidationError(
|
|
283
|
+
`Payload field ${key} uses reserved prefix ${RESERVED_PREFIX}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (key === ID_FIELD) {
|
|
287
|
+
throw new QdrantCompatValidationError(
|
|
288
|
+
"Payload field 'id' conflicts with the Qdrant point id",
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function distanceToSimilarity(distance: models.Distance | string): string {
|
|
294
|
+
if (distance === Distance.COSINE || distance === "cosine") return "cosine";
|
|
295
|
+
if (distance === Distance.EUCLID || distance === "euclidean") return "euclidean";
|
|
296
|
+
if (distance === Distance.DOT || distance === "dot_product") return "dot_product";
|
|
297
|
+
throw new UnsupportedQdrantFeatureError(
|
|
298
|
+
`Qdrant distance ${String(distance)} is not supported`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function vectorParamsFromAny(value: unknown): VectorParams {
|
|
303
|
+
if (value instanceof VectorParams) return value;
|
|
304
|
+
if (isObject(value) && "size" in value) {
|
|
305
|
+
const init: { size: number; distance?: string | undefined } = {
|
|
306
|
+
size: Number(value["size"]),
|
|
307
|
+
};
|
|
308
|
+
if (typeof value["distance"] === "string") init.distance = value["distance"];
|
|
309
|
+
return new VectorParams(init);
|
|
310
|
+
}
|
|
311
|
+
throw new QdrantCompatValidationError(
|
|
312
|
+
"vectorsConfig values must be VectorParams-like objects",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function similarityToDistance(similarity: unknown): models.Distance {
|
|
317
|
+
const value = similarity ?? "cosine";
|
|
318
|
+
if (value === "cosine") return Distance.COSINE;
|
|
319
|
+
if (value === "euclidean") return Distance.EUCLID;
|
|
320
|
+
if (value === "dot_product") return Distance.DOT;
|
|
321
|
+
throw new UnsupportedQdrantFeatureError(
|
|
322
|
+
`LambdaDB similarity ${String(value)} cannot be represented as a Qdrant distance`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function payloadSchemaType(rawSchema: unknown): string {
|
|
327
|
+
let rawType: string;
|
|
328
|
+
if (typeof rawSchema === "string") {
|
|
329
|
+
rawType = rawSchema;
|
|
330
|
+
} else if (isObject(rawSchema) && "type" in rawSchema) {
|
|
331
|
+
rawType = String(rawSchema["type"]);
|
|
332
|
+
} else {
|
|
333
|
+
throw new QdrantCompatValidationError(
|
|
334
|
+
`Unsupported payload schema value: ${String(rawSchema)}`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const normalized = rawType.toLowerCase();
|
|
339
|
+
if (normalized === "geo") {
|
|
340
|
+
throw new UnsupportedQdrantFeatureError(
|
|
341
|
+
"Geo payload indexes are not supported by LambdaDB Qdrant compatibility",
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const mapped = payloadSchemaTypeAliases[normalized];
|
|
345
|
+
if (mapped === undefined) {
|
|
346
|
+
throw new UnsupportedQdrantFeatureError(
|
|
347
|
+
`Unsupported Qdrant payload schema type: ${rawType}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return mapped;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function validatePayload(payload: JsonObject): void {
|
|
354
|
+
for (const key of Object.keys(payload)) {
|
|
355
|
+
validatePayloadFieldName(key);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function payloadFromDoc(doc: JsonObject): JsonObject {
|
|
360
|
+
const payload: JsonObject = {};
|
|
361
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
362
|
+
if (key !== ID_FIELD && !key.startsWith(RESERVED_PREFIX)) {
|
|
363
|
+
payload[key] = value;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return payload;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function vectorFromDoc(doc: JsonObject, withVectors: boolean): unknown {
|
|
370
|
+
if (!withVectors) return undefined;
|
|
371
|
+
|
|
372
|
+
const named: JsonObject = {};
|
|
373
|
+
let defaultVector: unknown;
|
|
374
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
375
|
+
if (key === DEFAULT_VECTOR_NAME) {
|
|
376
|
+
defaultVector = value;
|
|
377
|
+
} else if (key.startsWith(`${DEFAULT_VECTOR_NAME}_`)) {
|
|
378
|
+
named[key.slice(DEFAULT_VECTOR_NAME.length + 1)] = value;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (Object.keys(named).length > 0) {
|
|
383
|
+
if (defaultVector !== undefined) named[""] = defaultVector;
|
|
384
|
+
return named;
|
|
385
|
+
}
|
|
386
|
+
return defaultVector;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function docId(doc: JsonObject): PointId {
|
|
390
|
+
const originalId = doc[ORIGINAL_ID_FIELD];
|
|
391
|
+
if (typeof originalId === "string" || typeof originalId === "number") {
|
|
392
|
+
return originalId;
|
|
393
|
+
}
|
|
394
|
+
const id = doc[ID_FIELD];
|
|
395
|
+
if (typeof id === "string" || typeof id === "number") return id;
|
|
396
|
+
return "";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function unwrapDoc(input: unknown): JsonObject {
|
|
400
|
+
if (isObject(input) && isObject(input["doc"])) return input["doc"];
|
|
401
|
+
if (isObject(input)) return input;
|
|
402
|
+
throw new QdrantCompatValidationError("LambdaDB result doc must be an object");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function isVectorParamsLike(value: unknown): boolean {
|
|
406
|
+
return value instanceof VectorParams || (isObject(value) && "size" in value);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function isDenseVector(value: unknown): value is DenseVector {
|
|
410
|
+
return Array.isArray(value) && value.every((item) => typeof item === "number");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function isSparseVectorLike(value: unknown): boolean {
|
|
414
|
+
return value instanceof SparseVector ||
|
|
415
|
+
(isObject(value) && Array.isArray(value["indices"]) && Array.isArray(value["values"]));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function isObject(value: unknown): value is JsonObject {
|
|
419
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export type { IndexConfigs };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class QdrantCompatError extends Error {
|
|
2
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
3
|
+
super(message, options);
|
|
4
|
+
this.name = "QdrantCompatError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class UnsupportedQdrantFeatureError extends QdrantCompatError {
|
|
9
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.name = "UnsupportedQdrantFeatureError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class QdrantCompatValidationError extends QdrantCompatError {
|
|
16
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
17
|
+
super(message, options);
|
|
18
|
+
this.name = "QdrantCompatValidationError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FieldCondition,
|
|
3
|
+
Filter,
|
|
4
|
+
HasIdCondition,
|
|
5
|
+
MatchAny,
|
|
6
|
+
MatchExcept,
|
|
7
|
+
MatchText,
|
|
8
|
+
MatchValue,
|
|
9
|
+
Range,
|
|
10
|
+
type Condition,
|
|
11
|
+
} from "./models.js";
|
|
12
|
+
import {
|
|
13
|
+
QdrantCompatValidationError,
|
|
14
|
+
UnsupportedQdrantFeatureError,
|
|
15
|
+
} from "./errors.js";
|
|
16
|
+
|
|
17
|
+
type JsonObject = { [key: string]: unknown };
|
|
18
|
+
type LambdaDBQuery = { [key: string]: unknown };
|
|
19
|
+
|
|
20
|
+
export function filterToLambdaDB(qdrantFilter: Filter | JsonObject | null | undefined): LambdaDBQuery {
|
|
21
|
+
if (qdrantFilter == null) return {};
|
|
22
|
+
const filter = filterFromAny(qdrantFilter);
|
|
23
|
+
|
|
24
|
+
const clauses: LambdaDBQuery[] = [];
|
|
25
|
+
if (filter.must) appendBoolClauses(clauses, filter.must, "filter");
|
|
26
|
+
if (filter.should) appendBoolClauses(clauses, filter.should, "should");
|
|
27
|
+
if (filter.mustNot) appendBoolClauses(clauses, filter.mustNot, "must_not");
|
|
28
|
+
|
|
29
|
+
if (clauses.length === 0) return {};
|
|
30
|
+
if (clauses.length === 1) {
|
|
31
|
+
const [clause] = clauses;
|
|
32
|
+
const singleClause = { ...clause };
|
|
33
|
+
delete singleClause["occur"];
|
|
34
|
+
return singleClause;
|
|
35
|
+
}
|
|
36
|
+
return { bool: clauses };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function appendBoolClauses(
|
|
40
|
+
clauses: LambdaDBQuery[],
|
|
41
|
+
conditions: Condition[],
|
|
42
|
+
occur: "filter" | "should" | "must_not",
|
|
43
|
+
): void {
|
|
44
|
+
for (const condition of conditions) {
|
|
45
|
+
const conditionQueries = conditionQueriesFromAny(condition);
|
|
46
|
+
const rawMatch = rawConditionMatch(condition);
|
|
47
|
+
const negateMatchExcept = isMatchExceptLike(rawMatch);
|
|
48
|
+
const effectiveOccur = negateMatchExcept && occur !== "must_not"
|
|
49
|
+
? "must_not"
|
|
50
|
+
: occur;
|
|
51
|
+
for (const query of conditionQueries) {
|
|
52
|
+
clauses.push({ ...query, occur: effectiveOccur });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function conditionQueriesFromAny(condition: Condition): LambdaDBQuery[] {
|
|
58
|
+
const normalized = conditionFromAny(condition);
|
|
59
|
+
if (normalized instanceof HasIdCondition) {
|
|
60
|
+
return normalized.hasId.map((id) => queryString(`id:${formatScalar(id)}`));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!(normalized instanceof FieldCondition)) {
|
|
64
|
+
throw new UnsupportedQdrantFeatureError(
|
|
65
|
+
`Unsupported Qdrant filter condition: ${JSON.stringify(condition)}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const queries: LambdaDBQuery[] = [];
|
|
70
|
+
if (normalized.match !== undefined) {
|
|
71
|
+
queries.push(...matchQueries(normalized.key, normalized.match));
|
|
72
|
+
}
|
|
73
|
+
if (normalized.range !== undefined) {
|
|
74
|
+
queries.push(queryString(rangeQuery(normalized.key, normalized.range)));
|
|
75
|
+
}
|
|
76
|
+
if (queries.length === 0) {
|
|
77
|
+
throw new QdrantCompatValidationError("FieldCondition must include match or range");
|
|
78
|
+
}
|
|
79
|
+
return queries;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function matchQueries(field: string, rawMatch: unknown): LambdaDBQuery[] {
|
|
83
|
+
const match = matchFromAny(rawMatch);
|
|
84
|
+
|
|
85
|
+
if (match instanceof MatchValue) {
|
|
86
|
+
return [queryString(`${field}:${formatScalar(match.value)}`)];
|
|
87
|
+
}
|
|
88
|
+
if (match instanceof MatchAny) {
|
|
89
|
+
return match.any.map((value) => queryString(`${field}:${formatScalar(value)}`));
|
|
90
|
+
}
|
|
91
|
+
if (match instanceof MatchExcept) {
|
|
92
|
+
return match.except.map((value) => queryString(`${field}:${formatScalar(value)}`));
|
|
93
|
+
}
|
|
94
|
+
if (match instanceof MatchText) {
|
|
95
|
+
throw new UnsupportedQdrantFeatureError(
|
|
96
|
+
"MatchText is not supported in the v1 Qdrant compatibility filter",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw new UnsupportedQdrantFeatureError(
|
|
101
|
+
`Unsupported Qdrant match condition: ${JSON.stringify(rawMatch)}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function rangeQuery(field: string, rawRange: Range | JsonObject): string {
|
|
106
|
+
const range = rawRange instanceof Range
|
|
107
|
+
? rawRange
|
|
108
|
+
: new Range(rawRange);
|
|
109
|
+
let lower = "*";
|
|
110
|
+
let upper = "*";
|
|
111
|
+
let left = "[";
|
|
112
|
+
let right = "]";
|
|
113
|
+
|
|
114
|
+
if (range.gte !== undefined && range.gte !== null) {
|
|
115
|
+
lower = formatScalar(range.gte);
|
|
116
|
+
} else if (range.gt !== undefined && range.gt !== null) {
|
|
117
|
+
lower = formatScalar(range.gt);
|
|
118
|
+
left = "{";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (range.lte !== undefined && range.lte !== null) {
|
|
122
|
+
upper = formatScalar(range.lte);
|
|
123
|
+
} else if (range.lt !== undefined && range.lt !== null) {
|
|
124
|
+
upper = formatScalar(range.lt);
|
|
125
|
+
right = "}";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return `${field}:${left}${lower} TO ${upper}${right}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function filterFromAny(value: Filter | JsonObject): Filter {
|
|
132
|
+
if (value instanceof Filter) return value;
|
|
133
|
+
if (isObject(value)) return new Filter(value);
|
|
134
|
+
throw new QdrantCompatValidationError("queryFilter must be a Filter or object");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function conditionFromAny(condition: Condition): FieldCondition | HasIdCondition {
|
|
138
|
+
if (condition instanceof FieldCondition || condition instanceof HasIdCondition) {
|
|
139
|
+
return condition;
|
|
140
|
+
}
|
|
141
|
+
if (isObject(condition)) {
|
|
142
|
+
if ("key" in condition) {
|
|
143
|
+
return new FieldCondition(condition as {
|
|
144
|
+
key: string;
|
|
145
|
+
match?: JsonObject | null;
|
|
146
|
+
range?: JsonObject | null;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if ("has_id" in condition || "hasId" in condition) {
|
|
150
|
+
return new HasIdCondition(condition as {
|
|
151
|
+
has_id?: Array<string | number>;
|
|
152
|
+
hasId?: Array<string | number>;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
throw new UnsupportedQdrantFeatureError(
|
|
157
|
+
`Unsupported Qdrant filter condition: ${JSON.stringify(condition)}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function matchFromAny(value: unknown): unknown {
|
|
162
|
+
if (
|
|
163
|
+
value instanceof MatchValue ||
|
|
164
|
+
value instanceof MatchAny ||
|
|
165
|
+
value instanceof MatchExcept ||
|
|
166
|
+
value instanceof MatchText
|
|
167
|
+
) {
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
if (isObject(value)) {
|
|
171
|
+
if ("value" in value) return new MatchValue({ value: value["value"] });
|
|
172
|
+
if ("any" in value && Array.isArray(value["any"])) {
|
|
173
|
+
return new MatchAny({ any: value["any"] });
|
|
174
|
+
}
|
|
175
|
+
if ("except" in value && Array.isArray(value["except"])) {
|
|
176
|
+
return new MatchExcept({ except: value["except"] });
|
|
177
|
+
}
|
|
178
|
+
if ("except_" in value && Array.isArray(value["except_"])) {
|
|
179
|
+
return new MatchExcept({ except_: value["except_"] });
|
|
180
|
+
}
|
|
181
|
+
if ("text" in value && typeof value["text"] === "string") {
|
|
182
|
+
return new MatchText({ text: value["text"] });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function rawConditionMatch(condition: Condition): unknown {
|
|
189
|
+
if (condition instanceof FieldCondition) return condition.match;
|
|
190
|
+
if (isObject(condition) && "match" in condition) return condition["match"];
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isMatchExceptLike(value: unknown): boolean {
|
|
195
|
+
return value instanceof MatchExcept ||
|
|
196
|
+
(isObject(value) && ("except" in value || "except_" in value));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function queryString(query: string): LambdaDBQuery {
|
|
200
|
+
return { queryString: { query } };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function formatScalar(value: unknown): string {
|
|
204
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
205
|
+
if (typeof value === "number") return String(value);
|
|
206
|
+
const text = String(value);
|
|
207
|
+
const escaped = text.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
208
|
+
return /[\s:/]/.test(escaped) ? `"${escaped}"` : escaped;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isObject(value: unknown): value is JsonObject {
|
|
212
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
213
|
+
}
|