@mhalder/qdrant-mcp-server 1.1.1 → 1.2.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/CHANGELOG.md +5 -0
- package/README.md +2 -0
- package/biome.json +34 -0
- package/build/embeddings/sparse.d.ts +40 -0
- package/build/embeddings/sparse.d.ts.map +1 -0
- package/build/embeddings/sparse.js +105 -0
- package/build/embeddings/sparse.js.map +1 -0
- package/build/embeddings/sparse.test.d.ts +2 -0
- package/build/embeddings/sparse.test.d.ts.map +1 -0
- package/build/embeddings/sparse.test.js +69 -0
- package/build/embeddings/sparse.test.js.map +1 -0
- package/build/index.js +130 -30
- package/build/index.js.map +1 -1
- package/build/qdrant/client.d.ts +21 -2
- package/build/qdrant/client.d.ts.map +1 -1
- package/build/qdrant/client.js +131 -17
- package/build/qdrant/client.js.map +1 -1
- package/build/qdrant/client.test.js +429 -21
- package/build/qdrant/client.test.js.map +1 -1
- package/examples/README.md +16 -1
- package/examples/basic/README.md +1 -0
- package/examples/hybrid-search/README.md +199 -0
- package/package.json +1 -1
- package/src/embeddings/sparse.test.ts +87 -0
- package/src/embeddings/sparse.ts +127 -0
- package/src/index.ts +161 -57
- package/src/qdrant/client.test.ts +544 -56
- package/src/qdrant/client.ts +162 -22
- package/vitest.config.ts +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { QdrantManager } from "./client.js";
|
|
3
1
|
import { QdrantClient } from "@qdrant/js-client-rest";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { QdrantManager } from "./client.js";
|
|
4
4
|
|
|
5
5
|
vi.mock("@qdrant/js-client-rest", () => ({
|
|
6
6
|
QdrantClient: vi.fn(),
|
|
@@ -31,29 +31,41 @@ describe("QdrantManager", () => {
|
|
|
31
31
|
it("should create a collection with default distance metric", async () => {
|
|
32
32
|
await manager.createCollection("test-collection", 1536);
|
|
33
33
|
|
|
34
|
-
expect(mockClient.createCollection).toHaveBeenCalledWith(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
size: 1536,
|
|
39
|
-
distance: "Cosine",
|
|
40
|
-
},
|
|
34
|
+
expect(mockClient.createCollection).toHaveBeenCalledWith("test-collection", {
|
|
35
|
+
vectors: {
|
|
36
|
+
size: 1536,
|
|
37
|
+
distance: "Cosine",
|
|
41
38
|
},
|
|
42
|
-
);
|
|
39
|
+
});
|
|
43
40
|
});
|
|
44
41
|
|
|
45
42
|
it("should create a collection with custom distance metric", async () => {
|
|
46
43
|
await manager.createCollection("test-collection", 1536, "Euclid");
|
|
47
44
|
|
|
48
|
-
expect(mockClient.createCollection).toHaveBeenCalledWith(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
expect(mockClient.createCollection).toHaveBeenCalledWith("test-collection", {
|
|
46
|
+
vectors: {
|
|
47
|
+
size: 1536,
|
|
48
|
+
distance: "Euclid",
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should create a hybrid collection with sparse vectors enabled", async () => {
|
|
54
|
+
await manager.createCollection("test-collection", 1536, "Cosine", true);
|
|
55
|
+
|
|
56
|
+
expect(mockClient.createCollection).toHaveBeenCalledWith("test-collection", {
|
|
57
|
+
vectors: {
|
|
58
|
+
dense: {
|
|
52
59
|
size: 1536,
|
|
53
|
-
distance: "
|
|
60
|
+
distance: "Cosine",
|
|
54
61
|
},
|
|
55
62
|
},
|
|
56
|
-
|
|
63
|
+
sparse_vectors: {
|
|
64
|
+
text: {
|
|
65
|
+
modifier: "idf",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
57
69
|
});
|
|
58
70
|
});
|
|
59
71
|
|
|
@@ -79,20 +91,12 @@ describe("QdrantManager", () => {
|
|
|
79
91
|
describe("listCollections", () => {
|
|
80
92
|
it("should return list of collection names", async () => {
|
|
81
93
|
mockClient.getCollections.mockResolvedValue({
|
|
82
|
-
collections: [
|
|
83
|
-
{ name: "collection1" },
|
|
84
|
-
{ name: "collection2" },
|
|
85
|
-
{ name: "collection3" },
|
|
86
|
-
],
|
|
94
|
+
collections: [{ name: "collection1" }, { name: "collection2" }, { name: "collection3" }],
|
|
87
95
|
});
|
|
88
96
|
|
|
89
97
|
const collections = await manager.listCollections();
|
|
90
98
|
|
|
91
|
-
expect(collections).toEqual([
|
|
92
|
-
"collection1",
|
|
93
|
-
"collection2",
|
|
94
|
-
"collection3",
|
|
95
|
-
]);
|
|
99
|
+
expect(collections).toEqual(["collection1", "collection2", "collection3"]);
|
|
96
100
|
});
|
|
97
101
|
|
|
98
102
|
it("should return empty array when no collections exist", async () => {
|
|
@@ -128,6 +132,7 @@ describe("QdrantManager", () => {
|
|
|
128
132
|
vectorSize: 1536,
|
|
129
133
|
pointsCount: 100,
|
|
130
134
|
distance: "Cosine",
|
|
135
|
+
hybridEnabled: false,
|
|
131
136
|
});
|
|
132
137
|
});
|
|
133
138
|
|
|
@@ -148,15 +153,45 @@ describe("QdrantManager", () => {
|
|
|
148
153
|
|
|
149
154
|
expect(info.pointsCount).toBe(0);
|
|
150
155
|
});
|
|
156
|
+
|
|
157
|
+
it("should return hybrid collection info with named vectors", async () => {
|
|
158
|
+
mockClient.getCollection.mockResolvedValue({
|
|
159
|
+
collection_name: "hybrid-collection",
|
|
160
|
+
points_count: 50,
|
|
161
|
+
config: {
|
|
162
|
+
params: {
|
|
163
|
+
vectors: {
|
|
164
|
+
dense: {
|
|
165
|
+
size: 768,
|
|
166
|
+
distance: "Cosine",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
sparse_vectors: {
|
|
170
|
+
text: {
|
|
171
|
+
modifier: "idf",
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const info = await manager.getCollectionInfo("hybrid-collection");
|
|
179
|
+
|
|
180
|
+
expect(info).toEqual({
|
|
181
|
+
name: "hybrid-collection",
|
|
182
|
+
vectorSize: 768,
|
|
183
|
+
pointsCount: 50,
|
|
184
|
+
distance: "Cosine",
|
|
185
|
+
hybridEnabled: true,
|
|
186
|
+
});
|
|
187
|
+
});
|
|
151
188
|
});
|
|
152
189
|
|
|
153
190
|
describe("deleteCollection", () => {
|
|
154
191
|
it("should delete a collection", async () => {
|
|
155
192
|
await manager.deleteCollection("test-collection");
|
|
156
193
|
|
|
157
|
-
expect(mockClient.deleteCollection).toHaveBeenCalledWith(
|
|
158
|
-
"test-collection",
|
|
159
|
-
);
|
|
194
|
+
expect(mockClient.deleteCollection).toHaveBeenCalledWith("test-collection");
|
|
160
195
|
});
|
|
161
196
|
});
|
|
162
197
|
|
|
@@ -205,7 +240,7 @@ describe("QdrantManager", () => {
|
|
|
205
240
|
const normalizedId = calls[0][1].points[0].id;
|
|
206
241
|
// Check that it's a valid UUID format
|
|
207
242
|
expect(normalizedId).toMatch(
|
|
208
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
243
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
209
244
|
);
|
|
210
245
|
// Ensure it's not the original ID
|
|
211
246
|
expect(normalizedId).not.toBe("my-custom-id");
|
|
@@ -213,9 +248,7 @@ describe("QdrantManager", () => {
|
|
|
213
248
|
|
|
214
249
|
it("should preserve UUID format IDs without modification", async () => {
|
|
215
250
|
const uuidId = "123e4567-e89b-12d3-a456-426614174000";
|
|
216
|
-
const points = [
|
|
217
|
-
{ id: uuidId, vector: [0.1, 0.2, 0.3], payload: { text: "test" } },
|
|
218
|
-
];
|
|
251
|
+
const points = [{ id: uuidId, vector: [0.1, 0.2, 0.3], payload: { text: "test" } }];
|
|
219
252
|
|
|
220
253
|
await manager.addPoints("test-collection", points);
|
|
221
254
|
|
|
@@ -243,10 +276,8 @@ describe("QdrantManager", () => {
|
|
|
243
276
|
},
|
|
244
277
|
});
|
|
245
278
|
|
|
246
|
-
await expect(
|
|
247
|
-
|
|
248
|
-
).rejects.toThrow(
|
|
249
|
-
'Failed to add points to collection "test-collection": Vector dimension mismatch',
|
|
279
|
+
await expect(manager.addPoints("test-collection", points)).rejects.toThrow(
|
|
280
|
+
'Failed to add points to collection "test-collection": Vector dimension mismatch'
|
|
250
281
|
);
|
|
251
282
|
});
|
|
252
283
|
|
|
@@ -255,10 +286,8 @@ describe("QdrantManager", () => {
|
|
|
255
286
|
|
|
256
287
|
mockClient.upsert.mockRejectedValue(new Error("Network error"));
|
|
257
288
|
|
|
258
|
-
await expect(
|
|
259
|
-
|
|
260
|
-
).rejects.toThrow(
|
|
261
|
-
'Failed to add points to collection "test-collection": Network error',
|
|
289
|
+
await expect(manager.addPoints("test-collection", points)).rejects.toThrow(
|
|
290
|
+
'Failed to add points to collection "test-collection": Network error'
|
|
262
291
|
);
|
|
263
292
|
});
|
|
264
293
|
|
|
@@ -267,26 +296,36 @@ describe("QdrantManager", () => {
|
|
|
267
296
|
|
|
268
297
|
mockClient.upsert.mockRejectedValue("Unknown error");
|
|
269
298
|
|
|
270
|
-
await expect(
|
|
271
|
-
|
|
272
|
-
).rejects.toThrow(
|
|
273
|
-
'Failed to add points to collection "test-collection": Unknown error',
|
|
299
|
+
await expect(manager.addPoints("test-collection", points)).rejects.toThrow(
|
|
300
|
+
'Failed to add points to collection "test-collection": Unknown error'
|
|
274
301
|
);
|
|
275
302
|
});
|
|
276
303
|
});
|
|
277
304
|
|
|
278
305
|
describe("search", () => {
|
|
306
|
+
beforeEach(() => {
|
|
307
|
+
// Mock getCollection for standard collection by default
|
|
308
|
+
mockClient.getCollection.mockResolvedValue({
|
|
309
|
+
collection_name: "test-collection",
|
|
310
|
+
points_count: 100,
|
|
311
|
+
config: {
|
|
312
|
+
params: {
|
|
313
|
+
vectors: {
|
|
314
|
+
size: 768,
|
|
315
|
+
distance: "Cosine",
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
279
322
|
it("should search for similar vectors", async () => {
|
|
280
323
|
mockClient.search.mockResolvedValue([
|
|
281
324
|
{ id: 1, score: 0.95, payload: { text: "result1" } },
|
|
282
325
|
{ id: 2, score: 0.85, payload: { text: "result2" } },
|
|
283
326
|
]);
|
|
284
327
|
|
|
285
|
-
const results = await manager.search(
|
|
286
|
-
"test-collection",
|
|
287
|
-
[0.1, 0.2, 0.3],
|
|
288
|
-
5,
|
|
289
|
-
);
|
|
328
|
+
const results = await manager.search("test-collection", [0.1, 0.2, 0.3], 5);
|
|
290
329
|
|
|
291
330
|
expect(results).toEqual([
|
|
292
331
|
{ id: 1, score: 0.95, payload: { text: "result1" } },
|
|
@@ -385,21 +424,78 @@ describe("QdrantManager", () => {
|
|
|
385
424
|
});
|
|
386
425
|
|
|
387
426
|
it("should handle null payload in results", async () => {
|
|
388
|
-
mockClient.search.mockResolvedValue([
|
|
389
|
-
{ id: 1, score: 0.95, payload: null },
|
|
390
|
-
]);
|
|
427
|
+
mockClient.search.mockResolvedValue([{ id: 1, score: 0.95, payload: null }]);
|
|
391
428
|
|
|
392
429
|
const results = await manager.search("test-collection", [0.1, 0.2, 0.3]);
|
|
393
430
|
|
|
394
431
|
expect(results).toEqual([{ id: 1, score: 0.95, payload: undefined }]);
|
|
395
432
|
});
|
|
433
|
+
|
|
434
|
+
it("should use named vector for hybrid-enabled collections", async () => {
|
|
435
|
+
// Mock getCollectionInfo to return hybrid enabled collection
|
|
436
|
+
mockClient.getCollection.mockResolvedValue({
|
|
437
|
+
collection_name: "hybrid-collection",
|
|
438
|
+
points_count: 10,
|
|
439
|
+
config: {
|
|
440
|
+
params: {
|
|
441
|
+
vectors: {
|
|
442
|
+
dense: {
|
|
443
|
+
size: 768,
|
|
444
|
+
distance: "Cosine",
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
sparse_vectors: {
|
|
448
|
+
text: {
|
|
449
|
+
modifier: "idf",
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
mockClient.search.mockResolvedValue([{ id: 1, score: 0.95, payload: { text: "result1" } }]);
|
|
457
|
+
|
|
458
|
+
const results = await manager.search("hybrid-collection", [0.1, 0.2, 0.3], 5);
|
|
459
|
+
|
|
460
|
+
expect(results).toEqual([{ id: 1, score: 0.95, payload: { text: "result1" } }]);
|
|
461
|
+
expect(mockClient.search).toHaveBeenCalledWith("hybrid-collection", {
|
|
462
|
+
vector: { name: "dense", vector: [0.1, 0.2, 0.3] },
|
|
463
|
+
limit: 5,
|
|
464
|
+
filter: undefined,
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("should use unnamed vector for standard collections", async () => {
|
|
469
|
+
// Mock getCollectionInfo to return standard collection (no sparse vectors)
|
|
470
|
+
mockClient.getCollection.mockResolvedValue({
|
|
471
|
+
collection_name: "standard-collection",
|
|
472
|
+
points_count: 10,
|
|
473
|
+
config: {
|
|
474
|
+
params: {
|
|
475
|
+
vectors: {
|
|
476
|
+
size: 768,
|
|
477
|
+
distance: "Cosine",
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
mockClient.search.mockResolvedValue([{ id: 1, score: 0.95, payload: { text: "result1" } }]);
|
|
484
|
+
|
|
485
|
+
const results = await manager.search("standard-collection", [0.1, 0.2, 0.3], 5);
|
|
486
|
+
|
|
487
|
+
expect(results).toEqual([{ id: 1, score: 0.95, payload: { text: "result1" } }]);
|
|
488
|
+
expect(mockClient.search).toHaveBeenCalledWith("standard-collection", {
|
|
489
|
+
vector: [0.1, 0.2, 0.3],
|
|
490
|
+
limit: 5,
|
|
491
|
+
filter: undefined,
|
|
492
|
+
});
|
|
493
|
+
});
|
|
396
494
|
});
|
|
397
495
|
|
|
398
496
|
describe("getPoint", () => {
|
|
399
497
|
it("should retrieve a point by id", async () => {
|
|
400
|
-
mockClient.retrieve.mockResolvedValue([
|
|
401
|
-
{ id: 1, payload: { text: "test" } },
|
|
402
|
-
]);
|
|
498
|
+
mockClient.retrieve.mockResolvedValue([{ id: 1, payload: { text: "test" } }]);
|
|
403
499
|
|
|
404
500
|
const point = await manager.getPoint("test-collection", 1);
|
|
405
501
|
|
|
@@ -453,4 +549,396 @@ describe("QdrantManager", () => {
|
|
|
453
549
|
});
|
|
454
550
|
});
|
|
455
551
|
});
|
|
552
|
+
|
|
553
|
+
describe("hybridSearch", () => {
|
|
554
|
+
beforeEach(() => {
|
|
555
|
+
mockClient.query = vi.fn();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("should perform hybrid search with dense and sparse vectors", async () => {
|
|
559
|
+
const denseVector = [0.1, 0.2, 0.3];
|
|
560
|
+
const sparseVector = { indices: [1, 5, 10], values: [0.5, 0.3, 0.2] };
|
|
561
|
+
|
|
562
|
+
mockClient.query.mockResolvedValue({
|
|
563
|
+
points: [
|
|
564
|
+
{ id: 1, score: 0.95, payload: { text: "result1" } },
|
|
565
|
+
{ id: 2, score: 0.85, payload: { text: "result2" } },
|
|
566
|
+
],
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const results = await manager.hybridSearch("test-collection", denseVector, sparseVector);
|
|
570
|
+
|
|
571
|
+
expect(results).toEqual([
|
|
572
|
+
{ id: 1, score: 0.95, payload: { text: "result1" } },
|
|
573
|
+
{ id: 2, score: 0.85, payload: { text: "result2" } },
|
|
574
|
+
]);
|
|
575
|
+
|
|
576
|
+
expect(mockClient.query).toHaveBeenCalledWith("test-collection", {
|
|
577
|
+
prefetch: [
|
|
578
|
+
{
|
|
579
|
+
query: denseVector,
|
|
580
|
+
using: "dense",
|
|
581
|
+
limit: 20,
|
|
582
|
+
filter: undefined,
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
query: sparseVector,
|
|
586
|
+
using: "text",
|
|
587
|
+
limit: 20,
|
|
588
|
+
filter: undefined,
|
|
589
|
+
},
|
|
590
|
+
],
|
|
591
|
+
query: {
|
|
592
|
+
fusion: "rrf",
|
|
593
|
+
},
|
|
594
|
+
limit: 5,
|
|
595
|
+
with_payload: true,
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("should use custom limit with appropriate prefetch limit", async () => {
|
|
600
|
+
const denseVector = [0.1, 0.2, 0.3];
|
|
601
|
+
const sparseVector = { indices: [1, 5], values: [0.5, 0.3] };
|
|
602
|
+
|
|
603
|
+
mockClient.query.mockResolvedValue({ points: [] });
|
|
604
|
+
|
|
605
|
+
await manager.hybridSearch("test-collection", denseVector, sparseVector, 10);
|
|
606
|
+
|
|
607
|
+
expect(mockClient.query).toHaveBeenCalledWith(
|
|
608
|
+
"test-collection",
|
|
609
|
+
expect.objectContaining({
|
|
610
|
+
prefetch: expect.arrayContaining([
|
|
611
|
+
expect.objectContaining({ limit: 40 }), // 10 * 4
|
|
612
|
+
]),
|
|
613
|
+
limit: 10,
|
|
614
|
+
})
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("should convert simple filter to Qdrant format", async () => {
|
|
619
|
+
const denseVector = [0.1, 0.2, 0.3];
|
|
620
|
+
const sparseVector = { indices: [1], values: [0.5] };
|
|
621
|
+
const filter = { category: "test", type: "doc" };
|
|
622
|
+
|
|
623
|
+
mockClient.query.mockResolvedValue({ points: [] });
|
|
624
|
+
|
|
625
|
+
await manager.hybridSearch("test-collection", denseVector, sparseVector, 5, filter);
|
|
626
|
+
|
|
627
|
+
expect(mockClient.query).toHaveBeenCalledWith("test-collection", {
|
|
628
|
+
prefetch: [
|
|
629
|
+
{
|
|
630
|
+
query: denseVector,
|
|
631
|
+
using: "dense",
|
|
632
|
+
limit: 20,
|
|
633
|
+
filter: {
|
|
634
|
+
must: [
|
|
635
|
+
{ key: "category", match: { value: "test" } },
|
|
636
|
+
{ key: "type", match: { value: "doc" } },
|
|
637
|
+
],
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
query: sparseVector,
|
|
642
|
+
using: "text",
|
|
643
|
+
limit: 20,
|
|
644
|
+
filter: {
|
|
645
|
+
must: [
|
|
646
|
+
{ key: "category", match: { value: "test" } },
|
|
647
|
+
{ key: "type", match: { value: "doc" } },
|
|
648
|
+
],
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
],
|
|
652
|
+
query: {
|
|
653
|
+
fusion: "rrf",
|
|
654
|
+
},
|
|
655
|
+
limit: 5,
|
|
656
|
+
with_payload: true,
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("should handle Qdrant format filter (must)", async () => {
|
|
661
|
+
const denseVector = [0.1, 0.2, 0.3];
|
|
662
|
+
const sparseVector = { indices: [1], values: [0.5] };
|
|
663
|
+
const filter = { must: [{ key: "status", match: { value: "active" } }] };
|
|
664
|
+
|
|
665
|
+
mockClient.query.mockResolvedValue({ points: [] });
|
|
666
|
+
|
|
667
|
+
await manager.hybridSearch("test-collection", denseVector, sparseVector, 5, filter);
|
|
668
|
+
|
|
669
|
+
const call = mockClient.query.mock.calls[0][1];
|
|
670
|
+
expect(call.prefetch[0].filter).toEqual(filter);
|
|
671
|
+
expect(call.prefetch[1].filter).toEqual(filter);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("should handle Qdrant format filter (should)", async () => {
|
|
675
|
+
const denseVector = [0.1, 0.2, 0.3];
|
|
676
|
+
const sparseVector = { indices: [1], values: [0.5] };
|
|
677
|
+
const filter = { should: [{ key: "tag", match: { value: "important" } }] };
|
|
678
|
+
|
|
679
|
+
mockClient.query.mockResolvedValue({ points: [] });
|
|
680
|
+
|
|
681
|
+
await manager.hybridSearch("test-collection", denseVector, sparseVector, 5, filter);
|
|
682
|
+
|
|
683
|
+
const call = mockClient.query.mock.calls[0][1];
|
|
684
|
+
expect(call.prefetch[0].filter).toEqual(filter);
|
|
685
|
+
expect(call.prefetch[1].filter).toEqual(filter);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("should handle Qdrant format filter (must_not)", async () => {
|
|
689
|
+
const denseVector = [0.1, 0.2, 0.3];
|
|
690
|
+
const sparseVector = { indices: [1], values: [0.5] };
|
|
691
|
+
const filter = { must_not: [{ key: "status", match: { value: "deleted" } }] };
|
|
692
|
+
|
|
693
|
+
mockClient.query.mockResolvedValue({ points: [] });
|
|
694
|
+
|
|
695
|
+
await manager.hybridSearch("test-collection", denseVector, sparseVector, 5, filter);
|
|
696
|
+
|
|
697
|
+
const call = mockClient.query.mock.calls[0][1];
|
|
698
|
+
expect(call.prefetch[0].filter).toEqual(filter);
|
|
699
|
+
expect(call.prefetch[1].filter).toEqual(filter);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("should handle empty filter object", async () => {
|
|
703
|
+
const denseVector = [0.1, 0.2, 0.3];
|
|
704
|
+
const sparseVector = { indices: [1], values: [0.5] };
|
|
705
|
+
|
|
706
|
+
mockClient.query.mockResolvedValue({ points: [] });
|
|
707
|
+
|
|
708
|
+
await manager.hybridSearch("test-collection", denseVector, sparseVector, 5, {});
|
|
709
|
+
|
|
710
|
+
const call = mockClient.query.mock.calls[0][1];
|
|
711
|
+
expect(call.prefetch[0].filter).toBeUndefined();
|
|
712
|
+
expect(call.prefetch[1].filter).toBeUndefined();
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("should handle null payload in results", async () => {
|
|
716
|
+
const denseVector = [0.1, 0.2, 0.3];
|
|
717
|
+
const sparseVector = { indices: [1], values: [0.5] };
|
|
718
|
+
|
|
719
|
+
mockClient.query.mockResolvedValue({
|
|
720
|
+
points: [{ id: 1, score: 0.95, payload: null }],
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const results = await manager.hybridSearch("test-collection", denseVector, sparseVector);
|
|
724
|
+
|
|
725
|
+
expect(results).toEqual([{ id: 1, score: 0.95, payload: undefined }]);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("should throw error with error.data.status.error message", async () => {
|
|
729
|
+
const denseVector = [0.1, 0.2, 0.3];
|
|
730
|
+
const sparseVector = { indices: [1], values: [0.5] };
|
|
731
|
+
|
|
732
|
+
mockClient.query.mockRejectedValue({
|
|
733
|
+
data: {
|
|
734
|
+
status: {
|
|
735
|
+
error: "Named vector not found",
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
await expect(
|
|
741
|
+
manager.hybridSearch("test-collection", denseVector, sparseVector)
|
|
742
|
+
).rejects.toThrow(
|
|
743
|
+
'Hybrid search failed on collection "test-collection": Named vector not found'
|
|
744
|
+
);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("should throw error with error.message fallback", async () => {
|
|
748
|
+
const denseVector = [0.1, 0.2, 0.3];
|
|
749
|
+
const sparseVector = { indices: [1], values: [0.5] };
|
|
750
|
+
|
|
751
|
+
mockClient.query.mockRejectedValue(new Error("Network timeout"));
|
|
752
|
+
|
|
753
|
+
await expect(
|
|
754
|
+
manager.hybridSearch("test-collection", denseVector, sparseVector)
|
|
755
|
+
).rejects.toThrow('Hybrid search failed on collection "test-collection": Network timeout');
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("should throw error with String(error) fallback", async () => {
|
|
759
|
+
const denseVector = [0.1, 0.2, 0.3];
|
|
760
|
+
const sparseVector = { indices: [1], values: [0.5] };
|
|
761
|
+
|
|
762
|
+
mockClient.query.mockRejectedValue("Unknown error");
|
|
763
|
+
|
|
764
|
+
await expect(
|
|
765
|
+
manager.hybridSearch("test-collection", denseVector, sparseVector)
|
|
766
|
+
).rejects.toThrow('Hybrid search failed on collection "test-collection": Unknown error');
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
describe("addPointsWithSparse", () => {
|
|
771
|
+
it("should add points with dense and sparse vectors", async () => {
|
|
772
|
+
const points = [
|
|
773
|
+
{
|
|
774
|
+
id: 1,
|
|
775
|
+
vector: [0.1, 0.2, 0.3],
|
|
776
|
+
sparseVector: { indices: [1, 5], values: [0.5, 0.3] },
|
|
777
|
+
payload: { text: "test" },
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
id: 2,
|
|
781
|
+
vector: [0.4, 0.5, 0.6],
|
|
782
|
+
sparseVector: { indices: [2, 8], values: [0.4, 0.6] },
|
|
783
|
+
payload: { text: "test2" },
|
|
784
|
+
},
|
|
785
|
+
];
|
|
786
|
+
|
|
787
|
+
await manager.addPointsWithSparse("test-collection", points);
|
|
788
|
+
|
|
789
|
+
expect(mockClient.upsert).toHaveBeenCalledWith("test-collection", {
|
|
790
|
+
wait: true,
|
|
791
|
+
points: [
|
|
792
|
+
{
|
|
793
|
+
id: 1,
|
|
794
|
+
vector: {
|
|
795
|
+
dense: [0.1, 0.2, 0.3],
|
|
796
|
+
text: { indices: [1, 5], values: [0.5, 0.3] },
|
|
797
|
+
},
|
|
798
|
+
payload: { text: "test" },
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
id: 2,
|
|
802
|
+
vector: {
|
|
803
|
+
dense: [0.4, 0.5, 0.6],
|
|
804
|
+
text: { indices: [2, 8], values: [0.4, 0.6] },
|
|
805
|
+
},
|
|
806
|
+
payload: { text: "test2" },
|
|
807
|
+
},
|
|
808
|
+
],
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("should add points without payload", async () => {
|
|
813
|
+
const points = [
|
|
814
|
+
{
|
|
815
|
+
id: 1,
|
|
816
|
+
vector: [0.1, 0.2, 0.3],
|
|
817
|
+
sparseVector: { indices: [1], values: [0.5] },
|
|
818
|
+
},
|
|
819
|
+
];
|
|
820
|
+
|
|
821
|
+
await manager.addPointsWithSparse("test-collection", points);
|
|
822
|
+
|
|
823
|
+
expect(mockClient.upsert).toHaveBeenCalledWith("test-collection", {
|
|
824
|
+
wait: true,
|
|
825
|
+
points: [
|
|
826
|
+
{
|
|
827
|
+
id: 1,
|
|
828
|
+
vector: {
|
|
829
|
+
dense: [0.1, 0.2, 0.3],
|
|
830
|
+
text: { indices: [1], values: [0.5] },
|
|
831
|
+
},
|
|
832
|
+
payload: undefined,
|
|
833
|
+
},
|
|
834
|
+
],
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("should normalize string IDs to UUID format", async () => {
|
|
839
|
+
const points = [
|
|
840
|
+
{
|
|
841
|
+
id: "my-doc-id",
|
|
842
|
+
vector: [0.1, 0.2, 0.3],
|
|
843
|
+
sparseVector: { indices: [1], values: [0.5] },
|
|
844
|
+
payload: { text: "test" },
|
|
845
|
+
},
|
|
846
|
+
];
|
|
847
|
+
|
|
848
|
+
await manager.addPointsWithSparse("test-collection", points);
|
|
849
|
+
|
|
850
|
+
const calls = mockClient.upsert.mock.calls;
|
|
851
|
+
expect(calls).toHaveLength(1);
|
|
852
|
+
expect(calls[0][0]).toBe("test-collection");
|
|
853
|
+
|
|
854
|
+
const normalizedId = calls[0][1].points[0].id;
|
|
855
|
+
// Check that it's a valid UUID format
|
|
856
|
+
expect(normalizedId).toMatch(
|
|
857
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
858
|
+
);
|
|
859
|
+
expect(normalizedId).not.toBe("my-doc-id");
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it("should preserve UUID format IDs without modification", async () => {
|
|
863
|
+
const uuidId = "123e4567-e89b-12d3-a456-426614174000";
|
|
864
|
+
const points = [
|
|
865
|
+
{
|
|
866
|
+
id: uuidId,
|
|
867
|
+
vector: [0.1, 0.2, 0.3],
|
|
868
|
+
sparseVector: { indices: [1], values: [0.5] },
|
|
869
|
+
payload: { text: "test" },
|
|
870
|
+
},
|
|
871
|
+
];
|
|
872
|
+
|
|
873
|
+
await manager.addPointsWithSparse("test-collection", points);
|
|
874
|
+
|
|
875
|
+
expect(mockClient.upsert).toHaveBeenCalledWith("test-collection", {
|
|
876
|
+
wait: true,
|
|
877
|
+
points: [
|
|
878
|
+
{
|
|
879
|
+
id: uuidId,
|
|
880
|
+
vector: {
|
|
881
|
+
dense: [0.1, 0.2, 0.3],
|
|
882
|
+
text: { indices: [1], values: [0.5] },
|
|
883
|
+
},
|
|
884
|
+
payload: { text: "test" },
|
|
885
|
+
},
|
|
886
|
+
],
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("should throw error with error.data.status.error message", async () => {
|
|
891
|
+
const points = [
|
|
892
|
+
{
|
|
893
|
+
id: 1,
|
|
894
|
+
vector: [0.1, 0.2, 0.3],
|
|
895
|
+
sparseVector: { indices: [1], values: [0.5] },
|
|
896
|
+
},
|
|
897
|
+
];
|
|
898
|
+
|
|
899
|
+
mockClient.upsert.mockRejectedValue({
|
|
900
|
+
data: {
|
|
901
|
+
status: {
|
|
902
|
+
error: "Sparse vector not configured",
|
|
903
|
+
},
|
|
904
|
+
},
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
await expect(manager.addPointsWithSparse("test-collection", points)).rejects.toThrow(
|
|
908
|
+
'Failed to add points with sparse vectors to collection "test-collection": Sparse vector not configured'
|
|
909
|
+
);
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it("should throw error with error.message fallback", async () => {
|
|
913
|
+
const points = [
|
|
914
|
+
{
|
|
915
|
+
id: 1,
|
|
916
|
+
vector: [0.1, 0.2, 0.3],
|
|
917
|
+
sparseVector: { indices: [1], values: [0.5] },
|
|
918
|
+
},
|
|
919
|
+
];
|
|
920
|
+
|
|
921
|
+
mockClient.upsert.mockRejectedValue(new Error("Connection refused"));
|
|
922
|
+
|
|
923
|
+
await expect(manager.addPointsWithSparse("test-collection", points)).rejects.toThrow(
|
|
924
|
+
'Failed to add points with sparse vectors to collection "test-collection": Connection refused'
|
|
925
|
+
);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("should throw error with String(error) fallback", async () => {
|
|
929
|
+
const points = [
|
|
930
|
+
{
|
|
931
|
+
id: 1,
|
|
932
|
+
vector: [0.1, 0.2, 0.3],
|
|
933
|
+
sparseVector: { indices: [1], values: [0.5] },
|
|
934
|
+
},
|
|
935
|
+
];
|
|
936
|
+
|
|
937
|
+
mockClient.upsert.mockRejectedValue("Unexpected error");
|
|
938
|
+
|
|
939
|
+
await expect(manager.addPointsWithSparse("test-collection", points)).rejects.toThrow(
|
|
940
|
+
'Failed to add points with sparse vectors to collection "test-collection": Unexpected error'
|
|
941
|
+
);
|
|
942
|
+
});
|
|
943
|
+
});
|
|
456
944
|
});
|