@kernl-sdk/turbopuffer 0.1.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/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-check-types.log +60 -0
- package/CHANGELOG.md +33 -0
- package/LICENSE +201 -0
- package/README.md +60 -0
- package/dist/__tests__/convert.test.d.ts +2 -0
- package/dist/__tests__/convert.test.d.ts.map +1 -0
- package/dist/__tests__/convert.test.js +346 -0
- package/dist/__tests__/filter.test.d.ts +8 -0
- package/dist/__tests__/filter.test.d.ts.map +1 -0
- package/dist/__tests__/filter.test.js +649 -0
- package/dist/__tests__/filters.integration.test.d.ts +8 -0
- package/dist/__tests__/filters.integration.test.d.ts.map +1 -0
- package/dist/__tests__/filters.integration.test.js +502 -0
- package/dist/__tests__/integration/filters.integration.test.d.ts +8 -0
- package/dist/__tests__/integration/filters.integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration/filters.integration.test.js +475 -0
- package/dist/__tests__/integration/integration.test.d.ts +2 -0
- package/dist/__tests__/integration/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration/integration.test.js +329 -0
- package/dist/__tests__/integration/lifecycle.integration.test.d.ts +8 -0
- package/dist/__tests__/integration/lifecycle.integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration/lifecycle.integration.test.js +370 -0
- package/dist/__tests__/integration/memory.integration.test.d.ts +2 -0
- package/dist/__tests__/integration/memory.integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration/memory.integration.test.js +287 -0
- package/dist/__tests__/integration/query.integration.test.d.ts +8 -0
- package/dist/__tests__/integration/query.integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration/query.integration.test.js +385 -0
- package/dist/__tests__/integration.test.d.ts +2 -0
- package/dist/__tests__/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration.test.js +343 -0
- package/dist/__tests__/lifecycle.integration.test.d.ts +8 -0
- package/dist/__tests__/lifecycle.integration.test.d.ts.map +1 -0
- package/dist/__tests__/lifecycle.integration.test.js +385 -0
- package/dist/__tests__/query.integration.test.d.ts +8 -0
- package/dist/__tests__/query.integration.test.d.ts.map +1 -0
- package/dist/__tests__/query.integration.test.js +423 -0
- package/dist/__tests__/query.test.d.ts +8 -0
- package/dist/__tests__/query.test.d.ts.map +1 -0
- package/dist/__tests__/query.test.js +472 -0
- package/dist/convert/document.d.ts +20 -0
- package/dist/convert/document.d.ts.map +1 -0
- package/dist/convert/document.js +72 -0
- package/dist/convert/filter.d.ts +15 -0
- package/dist/convert/filter.d.ts.map +1 -0
- package/dist/convert/filter.js +109 -0
- package/dist/convert/index.d.ts +8 -0
- package/dist/convert/index.d.ts.map +1 -0
- package/dist/convert/index.js +7 -0
- package/dist/convert/query.d.ts +22 -0
- package/dist/convert/query.d.ts.map +1 -0
- package/dist/convert/query.js +111 -0
- package/dist/convert/schema.d.ts +39 -0
- package/dist/convert/schema.d.ts.map +1 -0
- package/dist/convert/schema.js +124 -0
- package/dist/convert.d.ts +68 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +333 -0
- package/dist/handle.d.ts +34 -0
- package/dist/handle.d.ts.map +1 -0
- package/dist/handle.js +72 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/search.d.ts +85 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +167 -0
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +57 -0
- package/src/__tests__/convert.test.ts +425 -0
- package/src/__tests__/filter.test.ts +730 -0
- package/src/__tests__/integration/filters.integration.test.ts +558 -0
- package/src/__tests__/integration/integration.test.ts +399 -0
- package/src/__tests__/integration/lifecycle.integration.test.ts +464 -0
- package/src/__tests__/integration/memory.integration.test.ts +353 -0
- package/src/__tests__/integration/query.integration.test.ts +471 -0
- package/src/__tests__/query.test.ts +636 -0
- package/src/convert/document.ts +95 -0
- package/src/convert/filter.ts +123 -0
- package/src/convert/index.ts +8 -0
- package/src/convert/query.ts +151 -0
- package/src/convert/schema.ts +163 -0
- package/src/handle.ts +104 -0
- package/src/index.ts +31 -0
- package/src/search.ts +207 -0
- package/src/types.ts +14 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comprehensive unit tests for query conversion.
|
|
3
|
+
*
|
|
4
|
+
* Tests vector, text, hybrid queries, fusion modes, query options,
|
|
5
|
+
* and SearchHit decoding.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
|
|
10
|
+
import { QUERY, SEARCH_HIT } from "../convert/query";
|
|
11
|
+
|
|
12
|
+
describe("QUERY", () => {
|
|
13
|
+
// ============================================================
|
|
14
|
+
// VECTOR-ONLY QUERIES
|
|
15
|
+
// ============================================================
|
|
16
|
+
|
|
17
|
+
describe("vector-only queries", () => {
|
|
18
|
+
it("encodes single vector signal", () => {
|
|
19
|
+
const result = QUERY.encode({
|
|
20
|
+
query: [{ vector: [0.1, 0.2, 0.3] }],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(result.rank_by).toEqual(["vector", "ANN", [0.1, 0.2, 0.3]]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("encodes vector with many dimensions", () => {
|
|
27
|
+
const vec = new Array(384).fill(0.5);
|
|
28
|
+
const result = QUERY.encode({
|
|
29
|
+
query: [{ vector: vec }],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(result.rank_by).toEqual(["vector", "ANN", vec]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("encodes vector with zeros", () => {
|
|
36
|
+
const result = QUERY.encode({
|
|
37
|
+
query: [{ vector: [0, 0, 0] }],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(result.rank_by).toEqual(["vector", "ANN", [0, 0, 0]]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("encodes vector with negative values", () => {
|
|
44
|
+
const result = QUERY.encode({
|
|
45
|
+
query: [{ vector: [-0.5, 0.5, -1.0] }],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(result.rank_by).toEqual(["vector", "ANN", [-0.5, 0.5, -1.0]]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("throws error for multiple vector signals", () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
QUERY.encode({
|
|
54
|
+
query: [{ vector: [0.1, 0.2] }, { vector: [0.3, 0.4] }],
|
|
55
|
+
}),
|
|
56
|
+
).toThrow(/does not support multi-vector/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("encodes vector query with max fusion", () => {
|
|
60
|
+
const result = QUERY.encode({
|
|
61
|
+
max: [{ vector: [0.1, 0.2] }],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Single signal, no fusion wrapper needed
|
|
65
|
+
expect(result.rank_by).toEqual(["vector", "ANN", [0.1, 0.2]]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("throws error for multiple vector signals with max fusion", () => {
|
|
69
|
+
expect(() =>
|
|
70
|
+
QUERY.encode({
|
|
71
|
+
max: [{ vector: [0.1, 0.2] }, { vector: [0.3, 0.4] }],
|
|
72
|
+
}),
|
|
73
|
+
).toThrow(/does not support multi-vector/);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ============================================================
|
|
78
|
+
// TEXT-ONLY QUERIES (BM25)
|
|
79
|
+
// ============================================================
|
|
80
|
+
|
|
81
|
+
describe("text-only queries (BM25)", () => {
|
|
82
|
+
it("encodes single text field query", () => {
|
|
83
|
+
const result = QUERY.encode({
|
|
84
|
+
query: [{ content: "hello world" }],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(result.rank_by).toEqual(["content", "BM25", "hello world"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("encodes text query on different field", () => {
|
|
91
|
+
const result = QUERY.encode({
|
|
92
|
+
query: [{ title: "search terms" }],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(result.rank_by).toEqual(["title", "BM25", "search terms"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("encodes empty string text query", () => {
|
|
99
|
+
const result = QUERY.encode({
|
|
100
|
+
query: [{ content: "" }],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result.rank_by).toEqual(["content", "BM25", ""]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("encodes text query with special characters", () => {
|
|
107
|
+
const result = QUERY.encode({
|
|
108
|
+
query: [{ content: "hello \"world\" & foo | bar" }],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.rank_by).toEqual([
|
|
112
|
+
"content",
|
|
113
|
+
"BM25",
|
|
114
|
+
"hello \"world\" & foo | bar",
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("encodes multi-field text query as Sum fusion", () => {
|
|
119
|
+
const result = QUERY.encode({
|
|
120
|
+
query: [{ title: "search" }, { body: "search" }],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result.rank_by).toEqual([
|
|
124
|
+
"Sum",
|
|
125
|
+
[
|
|
126
|
+
["title", "BM25", "search"],
|
|
127
|
+
["body", "BM25", "search"],
|
|
128
|
+
],
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("encodes three-field text query as Sum fusion", () => {
|
|
133
|
+
const result = QUERY.encode({
|
|
134
|
+
query: [
|
|
135
|
+
{ title: "query" },
|
|
136
|
+
{ description: "query" },
|
|
137
|
+
{ content: "query" },
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(result.rank_by).toEqual([
|
|
142
|
+
"Sum",
|
|
143
|
+
[
|
|
144
|
+
["title", "BM25", "query"],
|
|
145
|
+
["description", "BM25", "query"],
|
|
146
|
+
["content", "BM25", "query"],
|
|
147
|
+
],
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("encodes multi-field text query with max fusion", () => {
|
|
152
|
+
const result = QUERY.encode({
|
|
153
|
+
max: [{ title: "search" }, { body: "search" }],
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(result.rank_by).toEqual([
|
|
157
|
+
"Max",
|
|
158
|
+
[
|
|
159
|
+
["title", "BM25", "search"],
|
|
160
|
+
["body", "BM25", "search"],
|
|
161
|
+
],
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ============================================================
|
|
167
|
+
// HYBRID QUERIES (VECTOR + TEXT) - NOT SUPPORTED
|
|
168
|
+
// ============================================================
|
|
169
|
+
|
|
170
|
+
describe("hybrid queries (vector + text)", () => {
|
|
171
|
+
it("throws error for vector + text fusion", () => {
|
|
172
|
+
expect(() =>
|
|
173
|
+
QUERY.encode({
|
|
174
|
+
query: [{ vector: [0.1, 0.2, 0.3] }, { content: "search terms" }],
|
|
175
|
+
}),
|
|
176
|
+
).toThrow(/does not support hybrid/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("throws error for vector + text max fusion", () => {
|
|
180
|
+
expect(() =>
|
|
181
|
+
QUERY.encode({
|
|
182
|
+
max: [{ vector: [0.1, 0.2, 0.3] }, { content: "search terms" }],
|
|
183
|
+
}),
|
|
184
|
+
).toThrow(/does not support hybrid/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("throws error for vector + multi-field text", () => {
|
|
188
|
+
expect(() =>
|
|
189
|
+
QUERY.encode({
|
|
190
|
+
query: [
|
|
191
|
+
{ vector: [0.1, 0.2] },
|
|
192
|
+
{ title: "query" },
|
|
193
|
+
{ body: "query" },
|
|
194
|
+
],
|
|
195
|
+
}),
|
|
196
|
+
).toThrow(/does not support hybrid/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("throws error for multi-vector fusion", () => {
|
|
200
|
+
expect(() =>
|
|
201
|
+
QUERY.encode({
|
|
202
|
+
query: [{ vector: [0.1, 0.2] }, { vector: [0.3, 0.4] }],
|
|
203
|
+
}),
|
|
204
|
+
).toThrow(/does not support multi-vector/);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ============================================================
|
|
209
|
+
// QUERY OPTIONS
|
|
210
|
+
// ============================================================
|
|
211
|
+
|
|
212
|
+
describe("query options", () => {
|
|
213
|
+
describe("topK", () => {
|
|
214
|
+
it("encodes topK as top_k", () => {
|
|
215
|
+
const result = QUERY.encode({
|
|
216
|
+
query: [{ vector: [0.1] }],
|
|
217
|
+
topK: 10,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(result.top_k).toBe(10);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("encodes topK of 1", () => {
|
|
224
|
+
const result = QUERY.encode({
|
|
225
|
+
query: [{ vector: [0.1] }],
|
|
226
|
+
topK: 1,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(result.top_k).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("encodes large topK", () => {
|
|
233
|
+
const result = QUERY.encode({
|
|
234
|
+
query: [{ vector: [0.1] }],
|
|
235
|
+
topK: 10000,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(result.top_k).toBe(10000);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("does not include top_k when undefined", () => {
|
|
242
|
+
const result = QUERY.encode({
|
|
243
|
+
query: [{ vector: [0.1] }],
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(result.top_k).toBeUndefined();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("filter", () => {
|
|
251
|
+
it("encodes simple filter", () => {
|
|
252
|
+
const result = QUERY.encode({
|
|
253
|
+
query: [{ vector: [0.1] }],
|
|
254
|
+
filter: { status: "active" },
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(result.filters).toEqual(["status", "Eq", "active"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("encodes complex filter", () => {
|
|
261
|
+
const result = QUERY.encode({
|
|
262
|
+
query: [{ vector: [0.1] }],
|
|
263
|
+
filter: {
|
|
264
|
+
$and: [{ status: "active" }, { views: { $gte: 100 } }],
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(result.filters).toEqual([
|
|
269
|
+
"And",
|
|
270
|
+
[
|
|
271
|
+
["status", "Eq", "active"],
|
|
272
|
+
["views", "Gte", 100],
|
|
273
|
+
],
|
|
274
|
+
]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("does not include filters when undefined", () => {
|
|
278
|
+
const result = QUERY.encode({
|
|
279
|
+
query: [{ vector: [0.1] }],
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(result.filters).toBeUndefined();
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("include", () => {
|
|
287
|
+
it("encodes include as boolean true", () => {
|
|
288
|
+
const result = QUERY.encode({
|
|
289
|
+
query: [{ vector: [0.1] }],
|
|
290
|
+
include: true,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(result.include_attributes).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("encodes include as boolean false", () => {
|
|
297
|
+
const result = QUERY.encode({
|
|
298
|
+
query: [{ vector: [0.1] }],
|
|
299
|
+
include: false,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
expect(result.include_attributes).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("encodes include as string array", () => {
|
|
306
|
+
const result = QUERY.encode({
|
|
307
|
+
query: [{ vector: [0.1] }],
|
|
308
|
+
include: ["title", "content"],
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(result.include_attributes).toEqual(["title", "content"]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("encodes include as single-element array", () => {
|
|
315
|
+
const result = QUERY.encode({
|
|
316
|
+
query: [{ vector: [0.1] }],
|
|
317
|
+
include: ["id"],
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(result.include_attributes).toEqual(["id"]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("encodes include as empty array", () => {
|
|
324
|
+
const result = QUERY.encode({
|
|
325
|
+
query: [{ vector: [0.1] }],
|
|
326
|
+
include: [],
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(result.include_attributes).toEqual([]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("does not include include_attributes when undefined", () => {
|
|
333
|
+
const result = QUERY.encode({
|
|
334
|
+
query: [{ vector: [0.1] }],
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
expect(result.include_attributes).toBeUndefined();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("combined options", () => {
|
|
342
|
+
it("encodes all options together", () => {
|
|
343
|
+
const result = QUERY.encode({
|
|
344
|
+
query: [{ vector: [0.1, 0.2] }],
|
|
345
|
+
topK: 20,
|
|
346
|
+
filter: { category: "news" },
|
|
347
|
+
include: ["title", "summary"],
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(result.rank_by).toEqual(["vector", "ANN", [0.1, 0.2]]);
|
|
351
|
+
expect(result.top_k).toBe(20);
|
|
352
|
+
expect(result.filters).toEqual(["category", "Eq", "news"]);
|
|
353
|
+
expect(result.include_attributes).toEqual(["title", "summary"]);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// ============================================================
|
|
359
|
+
// FUSION MODE SELECTION
|
|
360
|
+
// ============================================================
|
|
361
|
+
|
|
362
|
+
describe("fusion mode selection", () => {
|
|
363
|
+
it("throws error when query has both vector and text", () => {
|
|
364
|
+
expect(() =>
|
|
365
|
+
QUERY.encode({
|
|
366
|
+
query: [{ vector: [0.1] }, { content: "search" }],
|
|
367
|
+
}),
|
|
368
|
+
).toThrow(/does not support hybrid/);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("throws error when max has both vector and text", () => {
|
|
372
|
+
expect(() =>
|
|
373
|
+
QUERY.encode({
|
|
374
|
+
max: [{ vector: [0.1] }, { content: "search" }],
|
|
375
|
+
}),
|
|
376
|
+
).toThrow(/does not support hybrid/);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("prefers query over max when both present", () => {
|
|
380
|
+
const result = QUERY.encode({
|
|
381
|
+
query: [{ vector: [0.1] }],
|
|
382
|
+
max: [{ content: "search" }],
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// query takes precedence
|
|
386
|
+
expect(result.rank_by).toEqual(["vector", "ANN", [0.1]]);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ============================================================
|
|
391
|
+
// ERROR CASES
|
|
392
|
+
// ============================================================
|
|
393
|
+
|
|
394
|
+
describe("error cases", () => {
|
|
395
|
+
it("throws when query array has only undefined values", () => {
|
|
396
|
+
expect(() =>
|
|
397
|
+
QUERY.encode({
|
|
398
|
+
query: [{ field: undefined }],
|
|
399
|
+
}),
|
|
400
|
+
).toThrow("No ranking signals provided");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("returns params without rank_by when query is empty array", () => {
|
|
404
|
+
// Note: In practice, empty arrays are caught during normalization.
|
|
405
|
+
// Direct codec call with empty array returns params without rank_by.
|
|
406
|
+
const result = QUERY.encode({
|
|
407
|
+
query: [],
|
|
408
|
+
topK: 10,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(result.rank_by).toBeUndefined();
|
|
412
|
+
expect(result.top_k).toBe(10);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("returns params without rank_by when no query or max", () => {
|
|
416
|
+
const result = QUERY.encode({
|
|
417
|
+
topK: 10,
|
|
418
|
+
filter: { status: "active" },
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
expect(result.rank_by).toBeUndefined();
|
|
422
|
+
expect(result.top_k).toBe(10);
|
|
423
|
+
expect(result.filters).toEqual(["status", "Eq", "active"]);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// ============================================================
|
|
428
|
+
// EDGE CASES
|
|
429
|
+
// ============================================================
|
|
430
|
+
|
|
431
|
+
describe("edge cases", () => {
|
|
432
|
+
it("handles signal with multiple fields in one object", () => {
|
|
433
|
+
// A signal object could technically have multiple fields
|
|
434
|
+
const result = QUERY.encode({
|
|
435
|
+
query: [{ title: "hello", body: "world" }],
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// Both should be extracted as separate BM25 signals
|
|
439
|
+
expect(result.rank_by).toEqual([
|
|
440
|
+
"Sum",
|
|
441
|
+
[
|
|
442
|
+
["title", "BM25", "hello"],
|
|
443
|
+
["body", "BM25", "world"],
|
|
444
|
+
],
|
|
445
|
+
]);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("throws error for mixed signal types in one object", () => {
|
|
449
|
+
// Vector and text in same signal object
|
|
450
|
+
expect(() =>
|
|
451
|
+
QUERY.encode({
|
|
452
|
+
query: [{ vector: [0.1], content: "search" }],
|
|
453
|
+
}),
|
|
454
|
+
).toThrow(/does not support hybrid/);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// ============================================================
|
|
460
|
+
// SEARCH_HIT CODEC
|
|
461
|
+
// ============================================================
|
|
462
|
+
|
|
463
|
+
describe("SEARCH_HIT", () => {
|
|
464
|
+
describe("decode", () => {
|
|
465
|
+
it("decodes basic row with id and score", () => {
|
|
466
|
+
const result = SEARCH_HIT.decode(
|
|
467
|
+
{ id: "doc-1", $dist: 0.5 },
|
|
468
|
+
"my-index",
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
expect(result).toEqual({
|
|
472
|
+
id: "doc-1",
|
|
473
|
+
index: "my-index",
|
|
474
|
+
score: -0.5, // negated: distance → similarity
|
|
475
|
+
document: { id: "doc-1" },
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("decodes row with additional attributes", () => {
|
|
480
|
+
const result = SEARCH_HIT.decode(
|
|
481
|
+
{
|
|
482
|
+
id: "doc-1",
|
|
483
|
+
$dist: 0.25,
|
|
484
|
+
title: "Hello World",
|
|
485
|
+
category: "greeting",
|
|
486
|
+
},
|
|
487
|
+
"my-index",
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
expect(result).toEqual({
|
|
491
|
+
id: "doc-1",
|
|
492
|
+
index: "my-index",
|
|
493
|
+
score: -0.25, // negated: distance → similarity
|
|
494
|
+
document: {
|
|
495
|
+
id: "doc-1",
|
|
496
|
+
title: "Hello World",
|
|
497
|
+
category: "greeting",
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("decodes row with vector attribute", () => {
|
|
503
|
+
const result = SEARCH_HIT.decode(
|
|
504
|
+
{
|
|
505
|
+
id: "doc-1",
|
|
506
|
+
$dist: 0.1,
|
|
507
|
+
vector: [0.1, 0.2, 0.3],
|
|
508
|
+
},
|
|
509
|
+
"my-index",
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
expect(result.document?.id).toBe("doc-1");
|
|
513
|
+
expect(result.document?.vector).toEqual([0.1, 0.2, 0.3]);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("converts numeric id to string", () => {
|
|
517
|
+
const result = SEARCH_HIT.decode(
|
|
518
|
+
{ id: 12345, $dist: 0.5 },
|
|
519
|
+
"my-index",
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
expect(result.id).toBe("12345");
|
|
523
|
+
expect(typeof result.id).toBe("string");
|
|
524
|
+
expect(result.document?.id).toBe(12345); // document keeps original type
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("handles missing $dist (defaults to 0)", () => {
|
|
528
|
+
const result = SEARCH_HIT.decode({ id: "doc-1" }, "my-index");
|
|
529
|
+
|
|
530
|
+
expect(result.score).toBe(0);
|
|
531
|
+
expect(result.document).toEqual({ id: "doc-1" });
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("handles $dist of 0", () => {
|
|
535
|
+
const result = SEARCH_HIT.decode(
|
|
536
|
+
{ id: "doc-1", $dist: 0 },
|
|
537
|
+
"my-index",
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
expect(result.score).toBe(0);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("handles negative $dist", () => {
|
|
544
|
+
const result = SEARCH_HIT.decode(
|
|
545
|
+
{ id: "doc-1", $dist: -0.5 },
|
|
546
|
+
"my-index",
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
expect(result.score).toBe(0.5); // negated: distance → similarity
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("handles large $dist values", () => {
|
|
553
|
+
const result = SEARCH_HIT.decode(
|
|
554
|
+
{ id: "doc-1", $dist: 999999.99 },
|
|
555
|
+
"my-index",
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
expect(result.score).toBe(-999999.99); // negated: distance → similarity
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("includes id in document even when no extra attributes", () => {
|
|
562
|
+
const result = SEARCH_HIT.decode(
|
|
563
|
+
{ id: "doc-1", $dist: 0.5 },
|
|
564
|
+
"my-index",
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
expect(result.document).toEqual({ id: "doc-1" });
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("preserves attribute types", () => {
|
|
571
|
+
const result = SEARCH_HIT.decode(
|
|
572
|
+
{
|
|
573
|
+
id: "doc-1",
|
|
574
|
+
$dist: 0.5,
|
|
575
|
+
count: 42,
|
|
576
|
+
enabled: true,
|
|
577
|
+
rating: 4.5,
|
|
578
|
+
tags: ["a", "b"],
|
|
579
|
+
meta: null,
|
|
580
|
+
},
|
|
581
|
+
"my-index",
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
expect(result.document).toEqual({
|
|
585
|
+
id: "doc-1",
|
|
586
|
+
count: 42,
|
|
587
|
+
enabled: true,
|
|
588
|
+
rating: 4.5,
|
|
589
|
+
tags: ["a", "b"],
|
|
590
|
+
meta: null,
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("handles empty string id", () => {
|
|
595
|
+
const result = SEARCH_HIT.decode({ id: "", $dist: 0.5 }, "my-index");
|
|
596
|
+
|
|
597
|
+
expect(result.id).toBe("");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("handles special characters in attribute values", () => {
|
|
601
|
+
const result = SEARCH_HIT.decode(
|
|
602
|
+
{
|
|
603
|
+
id: "doc-1",
|
|
604
|
+
$dist: 0.5,
|
|
605
|
+
content: "Hello \"world\" & <script>",
|
|
606
|
+
},
|
|
607
|
+
"my-index",
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
expect(result.document?.content).toBe("Hello \"world\" & <script>");
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
describe("decode with typed document", () => {
|
|
615
|
+
interface MyDoc {
|
|
616
|
+
title: string;
|
|
617
|
+
views: number;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
it("returns typed document", () => {
|
|
621
|
+
const result = SEARCH_HIT.decode<MyDoc>(
|
|
622
|
+
{
|
|
623
|
+
id: "doc-1",
|
|
624
|
+
$dist: 0.5,
|
|
625
|
+
title: "Test",
|
|
626
|
+
views: 100,
|
|
627
|
+
},
|
|
628
|
+
"my-index",
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
// TypeScript should recognize these as MyDoc fields
|
|
632
|
+
expect(result.document?.title).toBe("Test");
|
|
633
|
+
expect(result.document?.views).toBe(100);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
});
|