@volter/twin-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/LICENSE +202 -0
- package/README.md +145 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +27 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +65 -0
- package/dist/src/key-gate.d.ts +3 -0
- package/dist/src/key-gate.js +39 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +28 -0
- package/dist/src/screens/dashboard.d.ts +11 -0
- package/dist/src/screens/dashboard.js +191 -0
- package/dist/src/semantics/namespaces.d.ts +5 -0
- package/dist/src/semantics/namespaces.js +17 -0
- package/dist/src/turbopuffer-capabilities.d.ts +6 -0
- package/dist/src/turbopuffer-capabilities.js +442 -0
- package/dist/src/turbopuffer-conformance.d.ts +8 -0
- package/dist/src/turbopuffer-conformance.js +102 -0
- package/dist/src/turbopuffer-connector.d.ts +34 -0
- package/dist/src/turbopuffer-connector.js +152 -0
- package/dist/src/turbopuffer-filter.d.ts +57 -0
- package/dist/src/turbopuffer-filter.js +286 -0
- package/dist/src/turbopuffer-server.d.ts +23 -0
- package/dist/src/turbopuffer-server.js +72 -0
- package/dist/src/turbopuffer-stem.d.ts +1 -0
- package/dist/src/turbopuffer-stem.js +133 -0
- package/dist/src/turbopuffer-store.d.ts +80 -0
- package/dist/src/turbopuffer-store.js +1304 -0
- package/dist/src/turbopuffer-text.d.ts +44 -0
- package/dist/src/turbopuffer-text.js +189 -0
- package/dist/src/turbopuffer-twin.d.ts +25 -0
- package/dist/src/turbopuffer-twin.js +406 -0
- package/package.json +56 -0
- package/src/cli.ts +28 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +92 -0
- package/src/key-gate.ts +39 -0
- package/src/manifest.ts +63 -0
- package/src/screens/dashboard.tsx +214 -0
- package/src/semantics/namespaces.ts +30 -0
- package/src/turbopuffer-capabilities.ts +455 -0
- package/src/turbopuffer-conformance.ts +101 -0
- package/src/turbopuffer-connector.ts +153 -0
- package/src/turbopuffer-filter.ts +277 -0
- package/src/turbopuffer-server.ts +81 -0
- package/src/turbopuffer-stem.ts +104 -0
- package/src/turbopuffer-store.ts +1157 -0
- package/src/turbopuffer-text.ts +204 -0
- package/src/turbopuffer-twin.ts +429 -0
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
// THE NAMESPACE CORE — Turbopuffer's namespaces and documents as kernel state, and the pure
|
|
2
|
+
// functions that apply a write and answer a query against them.
|
|
3
|
+
//
|
|
4
|
+
// STATE LIVES IN THE KERNEL TREE. A namespace is one `namespace` subject (its schema, distance
|
|
5
|
+
// metric, vector dimensions and timestamps); every document is one `document` subject keyed
|
|
6
|
+
// `<namespace>/<n|s>:<id>` (the id's JSON type is part of its identity: 7 and "7" are two
|
|
7
|
+
// documents). A write request is ONE kernel action — the namespace subject's fields plus a
|
|
8
|
+
// projection that updates/deletes the documents it touched — decided under the kernel's action lock
|
|
9
|
+
// (`applyTwinWriteAtomic`), because Turbopuffer applies a write request atomically. The action's
|
|
10
|
+
// `input` is the request body as the caller sent it, which is exactly what the perform adapter
|
|
11
|
+
// replays against the real vendor.
|
|
12
|
+
//
|
|
13
|
+
// Everything below this header is PURE: it reads a snapshot and returns the next one. The handler
|
|
14
|
+
// (turbopuffer-twin.ts) owns the wire and the kernel calls.
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { ownFields, type TwinResource } from '@volter/world-core';
|
|
17
|
+
import { assertModelledFts, attributeValue, badRequest, compareScalars, compileFilter, docTokens, TurbopufferError, type AttrConfig, type Doc, type FilterContext } from './turbopuffer-filter.ts';
|
|
18
|
+
import { bm25Score, buildCorpus, DEFAULT_FTS, MODELED_TOKENIZERS, queryTerms, TOKENIZERS, type Corpus, type FtsConfig } from './turbopuffer-text.ts';
|
|
19
|
+
|
|
20
|
+
export { badRequest, TurbopufferError } from './turbopuffer-filter.ts';
|
|
21
|
+
export type { AttrConfig, Doc } from './turbopuffer-filter.ts';
|
|
22
|
+
|
|
23
|
+
export const SERVICE = 'turbopuffer';
|
|
24
|
+
export const TURBOPUFFER_RESOURCE_TYPES = ['namespace', 'document'] as const;
|
|
25
|
+
|
|
26
|
+
/** Namespace names: `[A-Za-z0-9-_.]{1,128}` (turbopuffer.com/docs/write, "namespace"). */
|
|
27
|
+
const NAMESPACE_NAME = /^[A-Za-z0-9._-]{1,128}$/;
|
|
28
|
+
export const DISTANCE_METRICS = ['cosine_distance', 'euclidean_squared'] as const;
|
|
29
|
+
/** The attribute types the SDK's `AttributeSchemaConfig.type` doc enumerates. */
|
|
30
|
+
const SCALAR_TYPES = ['string', 'int', 'uint', 'float', 'uuid', 'datetime', 'bool'] as const;
|
|
31
|
+
|
|
32
|
+
export type NamespaceState = {
|
|
33
|
+
name: string;
|
|
34
|
+
schema: Record<string, AttrConfig>;
|
|
35
|
+
distance_metric: string | null;
|
|
36
|
+
vector_dims: number | null;
|
|
37
|
+
created_at: string;
|
|
38
|
+
updated_at: string;
|
|
39
|
+
/** when its documents last changed (a schema-only write moves `updated_at` alone) */
|
|
40
|
+
last_write_at: string;
|
|
41
|
+
/** the namespace a branch was made from (metadata's `branching.parent`) */
|
|
42
|
+
parent?: string;
|
|
43
|
+
docs: Map<string, Doc>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function assertNamespaceName(name: string): void {
|
|
47
|
+
if (!NAMESPACE_NAME.test(name)) throw badRequest(`invalid namespace name "${name}": must be 1-128 characters of [A-Za-z0-9-_.]`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const utf8 = new TextEncoder();
|
|
51
|
+
function utf8Length(s: string): number {
|
|
52
|
+
return utf8.encode(s).length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function docKey(id: string | number): string {
|
|
56
|
+
return typeof id === 'number' ? `n:${id}` : `s:${id}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function documentSubjectId(ns: string, id: string | number): string {
|
|
60
|
+
return `${ns}/${docKey(id)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── loading the snapshot from the tree ──────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** Fold the tree's `namespace` / `document` subjects into live namespaces. */
|
|
66
|
+
export function loadNamespaces(resources: readonly TwinResource[]): Map<string, NamespaceState> {
|
|
67
|
+
const out = new Map<string, NamespaceState>();
|
|
68
|
+
for (const r of resources) {
|
|
69
|
+
if (r.type !== 'namespace') continue;
|
|
70
|
+
const f = ownFields(r);
|
|
71
|
+
// `deleted` is the kernel's mark for a subject a complete refresh no longer saw; `gone` is deleteAll's.
|
|
72
|
+
if (f.gone === true || f.deleted === true || typeof f.name !== 'string') continue;
|
|
73
|
+
out.set(f.name, {
|
|
74
|
+
name: f.name,
|
|
75
|
+
schema: (f.schema !== null && typeof f.schema === 'object' ? f.schema : {}) as Record<string, AttrConfig>,
|
|
76
|
+
distance_metric: typeof f.distance_metric === 'string' ? f.distance_metric : null,
|
|
77
|
+
vector_dims: typeof f.vector_dims === 'number' ? f.vector_dims : null,
|
|
78
|
+
created_at: typeof f.created_at === 'string' ? f.created_at : r.updatedAt,
|
|
79
|
+
updated_at: typeof f.updated_at === 'string' ? f.updated_at : r.updatedAt,
|
|
80
|
+
last_write_at: typeof f.last_write_at === 'string' ? f.last_write_at : typeof f.updated_at === 'string' ? f.updated_at : r.updatedAt,
|
|
81
|
+
...(typeof f.parent === 'string' ? { parent: f.parent } : {}),
|
|
82
|
+
docs: new Map(),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
for (const r of resources) {
|
|
86
|
+
if (r.type !== 'document') continue;
|
|
87
|
+
const f = ownFields(r);
|
|
88
|
+
if (f.deleted === true || typeof f.namespace !== 'string') continue;
|
|
89
|
+
const ns = out.get(f.namespace);
|
|
90
|
+
if (ns === undefined) continue;
|
|
91
|
+
const id = f.doc_id;
|
|
92
|
+
if (typeof id !== 'string' && typeof id !== 'number') continue;
|
|
93
|
+
ns.docs.set(docKey(id), {
|
|
94
|
+
key: docKey(id),
|
|
95
|
+
id,
|
|
96
|
+
attributes: (f.attributes !== null && typeof f.attributes === 'object' ? f.attributes : {}) as Record<string, unknown>,
|
|
97
|
+
vector: Array.isArray(f.vector) ? (f.vector as number[]) : null,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function namespaceFields(ns: NamespaceState): Record<string, unknown> {
|
|
104
|
+
return {
|
|
105
|
+
name: ns.name,
|
|
106
|
+
schema: ns.schema,
|
|
107
|
+
distance_metric: ns.distance_metric,
|
|
108
|
+
vector_dims: ns.vector_dims,
|
|
109
|
+
created_at: ns.created_at,
|
|
110
|
+
updated_at: ns.updated_at,
|
|
111
|
+
last_write_at: ns.last_write_at,
|
|
112
|
+
parent: ns.parent ?? null,
|
|
113
|
+
gone: false,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function documentFields(ns: string, d: Doc): Record<string, unknown> {
|
|
118
|
+
return { namespace: ns, doc_id: d.id, attributes: d.attributes, vector: d.vector };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── schema ──────────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/** "Vectors are attributes with a vector type (`[N]f32`, `[N]f16`, or `[N]i8` where N is the number of dimensions)"
|
|
124
|
+
* (https://turbopuffer.com/docs/write, Vectors). */
|
|
125
|
+
export function isVectorType(t: string): boolean {
|
|
126
|
+
return /^\[\d+\](f16|f32|i8)$/.test(t);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const dimsOf = (t: string): number => Number(/^\[(\d+)\]/.exec(t)?.[1] ?? NaN);
|
|
130
|
+
|
|
131
|
+
function isKnownType(t: string): boolean {
|
|
132
|
+
if ((SCALAR_TYPES as readonly string[]).includes(t)) return true;
|
|
133
|
+
if (t.startsWith('[]') && (SCALAR_TYPES as readonly string[]).includes(t.slice(2))) return true;
|
|
134
|
+
// "`{}f16`: Sparse vector with string keys and 16-bit floats as weights"; "`bytes` holds arbitrary binary data …
|
|
135
|
+
// passed and returned as base64-encoded strings" (https://turbopuffer.com/docs/write, type)
|
|
136
|
+
return isVectorType(t) || isVectorArrayType(t) || t === '{}f16' || t === 'bytes';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** "`[][N]f32`: Variable-length array of `N` dimensional f32 vectors, used for late-interaction (multi-vector) search"
|
|
140
|
+
* (https://turbopuffer.com/docs/write, type). */
|
|
141
|
+
export function isVectorArrayType(t: string): boolean {
|
|
142
|
+
return /^\[\]\[\d+\]f32$/.test(t);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeFts(raw: unknown, attr: string, type: string): FtsConfig | false {
|
|
146
|
+
if (raw === undefined || raw === false || raw === null) return false;
|
|
147
|
+
if (type !== 'string' && type !== '[]string') throw badRequest(`invalid schema for attribute "${attr}": full_text_search requires the string or []string type`);
|
|
148
|
+
const given = raw === true ? {} : raw;
|
|
149
|
+
if (typeof given !== 'object' || Array.isArray(given)) throw badRequest(`invalid schema for attribute "${attr}": full_text_search must be a boolean or an object`);
|
|
150
|
+
const cfg: FtsConfig = { ...DEFAULT_FTS };
|
|
151
|
+
for (const [k, v] of Object.entries(given as Record<string, unknown>)) {
|
|
152
|
+
const set = FTS_OPTIONS[k];
|
|
153
|
+
if (!set) throw badRequest(`invalid schema for attribute "${attr}": unknown full_text_search option "${k}"`);
|
|
154
|
+
set(cfg, v, attr, k);
|
|
155
|
+
}
|
|
156
|
+
// Refused rather than approximated: an unmodelled tokenizer or a stemmer would silently rank
|
|
157
|
+
// differently from the vendor (see turbopuffer-text.ts's header).
|
|
158
|
+
if (!MODELED_TOKENIZERS.includes(cfg.tokenizer)) throw badRequest(`turbopuffer twin: tokenizer "${cfg.tokenizer}" on attribute "${attr}" is real Turbopuffer surface this twin does not model yet — word_v4, word_v3, word_v2 and pre_tokenized_array are modelled (turbopuffer.fts.tokenizers is the filed gap)`);
|
|
159
|
+
// stemming is modelled for English (./turbopuffer-stem.ts), the one language the twin models
|
|
160
|
+
if (cfg.language !== 'english') throw badRequest(`turbopuffer twin: language "${cfg.language}" on attribute "${attr}" is not modelled (turbopuffer.fts.languages is the filed gap)`);
|
|
161
|
+
return cfg;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function tokenizerOption(cfg: FtsConfig, v: unknown, attr: string): void {
|
|
165
|
+
if (typeof v !== 'string' || !(TOKENIZERS as readonly string[]).includes(v)) throw badRequest(`invalid schema for attribute "${attr}": unknown tokenizer ${JSON.stringify(v)}`);
|
|
166
|
+
cfg.tokenizer = v;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function flagOption(cfg: FtsConfig, v: unknown, attr: string, k: string): void {
|
|
170
|
+
if (typeof v !== 'boolean') throw badRequest(`invalid schema for attribute "${attr}": full_text_search.${k} must be a boolean`);
|
|
171
|
+
cfg[k as 'case_sensitive' | 'remove_stopwords' | 'stemming' | 'ascii_folding'] = v;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function languageOption(cfg: FtsConfig, v: unknown, attr: string): void {
|
|
175
|
+
if (typeof v !== 'string') throw badRequest(`invalid schema for attribute "${attr}": full_text_search.language must be a string`);
|
|
176
|
+
cfg.language = v;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** full_text_search's options (https://turbopuffer.com/docs/write, full_text_search). k3: "Query term frequency
|
|
180
|
+
* saturation parameter for BM25 scoring. Must be greater than zero. Defaults to 8.0"; the twin's queries count each term
|
|
181
|
+
* once, so it is kept, not applied. */
|
|
182
|
+
const FTS_OPTIONS: Record<string, (cfg: FtsConfig, v: unknown, attr: string, k: string) => void> = {
|
|
183
|
+
tokenizer: tokenizerOption, case_sensitive: flagOption, remove_stopwords: flagOption, stemming: flagOption, ascii_folding: flagOption,
|
|
184
|
+
language: languageOption, max_token_length: (cfg, v, attr) => maxTokenLength(cfg, v, attr),
|
|
185
|
+
k1: (cfg, v, attr) => bm25Parameter(cfg, 'k1', v, attr), b: (cfg, v, attr) => bm25Parameter(cfg, 'b', v, attr), k3: (cfg, v, attr) => bm25Parameter(cfg, 'k3', v, attr),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** `max_token_length`: "Maximum length of a token in bytes. … Has to be between `1` and `254` (inclusive). Defaults to
|
|
189
|
+
* `39`" (https://turbopuffer.com/docs/write, full_text_search). */
|
|
190
|
+
function maxTokenLength(cfg: FtsConfig, v: unknown, attr: string): void {
|
|
191
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < 1 || v > 254) throw badRequest(`invalid schema for attribute "${attr}": max_token_length has to be between 1 and 254`);
|
|
192
|
+
cfg.max_token_length = v;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** `k1`, `b` and `k3`, BM25's parameters (https://turbopuffer.com/docs/write, full_text_search; /docs/fts, Advanced tuning). */
|
|
196
|
+
function bm25Parameter(cfg: FtsConfig, k: 'k1' | 'b' | 'k3', v: unknown, attr: string): void {
|
|
197
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) throw badRequest(`invalid schema for attribute "${attr}": full_text_search.${k} must be a non-negative number`);
|
|
198
|
+
cfg[k] = v;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const SCHEMA_OPTIONS = ['type', 'filterable', 'full_text_search', 'glob', 'regex', 'fuzzy', 'ann', 'sparse_knn', 'embed'];
|
|
202
|
+
|
|
203
|
+
/** Normalize one schema entry (`"string"` or a config object) into the stored form. */
|
|
204
|
+
export function normalizeAttrSchema(attr: string, raw: unknown): AttrConfig {
|
|
205
|
+
const given = typeof raw === 'string' ? { type: raw } : raw;
|
|
206
|
+
if (given === null || typeof given !== 'object' || Array.isArray(given)) throw badRequest(`invalid schema for attribute "${attr}": expected a type string or a config object`);
|
|
207
|
+
const g = given as Record<string, unknown>;
|
|
208
|
+
// `embed` is native embedding, applied by mergeSchema (embedSchema)
|
|
209
|
+
for (const k of Object.keys(g)) if (!SCHEMA_OPTIONS.includes(k)) throw badRequest(`invalid schema for attribute "${attr}": unknown option "${k}"`);
|
|
210
|
+
if (typeof g.type !== 'string' || !isKnownType(g.type)) throw badRequest(`invalid schema for attribute "${attr}": unknown type ${JSON.stringify(g.type)}`);
|
|
211
|
+
if (g.ann !== undefined && g.ann !== false && !isVectorType(g.type) && !isVectorArrayType(g.type)) throw badRequest(`invalid schema for attribute "${attr}": ann requires a vector type`);
|
|
212
|
+
// "On a vector array attribute (`[][N]f32`), `ann: {"late_interaction": true}` builds the index used for
|
|
213
|
+
// late-interaction (multi-vector) search. Set `ann: false` to store the per-token vectors without building the index
|
|
214
|
+
// (exact kNN queries still work, but not ANN queries)" (https://turbopuffer.com/docs/write, ann)
|
|
215
|
+
const late = isVectorArrayType(g.type);
|
|
216
|
+
if (late && !(g.ann === false || (g.ann !== null && typeof g.ann === 'object' && (g.ann as Record<string, unknown>).late_interaction === true && Object.keys(g.ann as object).length === 1))) throw badRequest(`invalid schema for attribute "${attr}": a [][N]f32 attribute takes ann: {"late_interaction": true} or ann: false`);
|
|
217
|
+
const annOptions = !late && g.ann !== null && typeof g.ann === 'object' ? Object.keys(g.ann as object) : [];
|
|
218
|
+
if (annOptions.some((k) => k !== 'distance_metric')) throw badRequest(`invalid schema for attribute "${attr}": unknown ann option in ${JSON.stringify(annOptions)}`);
|
|
219
|
+
// "sparse_knn … When configured, this attribute can be used as part of a `SparseKNN` query. This is only supported on
|
|
220
|
+
// the `{}f16` type. This requires a `distance_metric` string field, which only supports `dot_product`"; "`{}f16`
|
|
221
|
+
// attributes are not filterable", and neither is `bytes` ("does not support indexing or filtering of any kind")
|
|
222
|
+
if (g.sparse_knn !== undefined && g.sparse_knn !== null) {
|
|
223
|
+
const sk = g.sparse_knn as Record<string, unknown>;
|
|
224
|
+
if (g.type !== '{}f16') throw badRequest(`invalid schema for attribute "${attr}": sparse_knn is only supported on the {}f16 type`);
|
|
225
|
+
if (typeof sk !== 'object' || Array.isArray(sk) || sk.distance_metric !== 'dot_product' || Object.keys(sk).length !== 1) throw badRequest(`invalid schema for attribute "${attr}": sparse_knn requires distance_metric "dot_product"`);
|
|
226
|
+
}
|
|
227
|
+
if ((g.type === '{}f16' || g.type === 'bytes') && g.filterable === true) throw badRequest(`invalid schema for attribute "${attr}": ${g.type} attributes are not filterable`);
|
|
228
|
+
if (g.filterable !== undefined && typeof g.filterable !== 'boolean') throw badRequest(`invalid schema for attribute "${attr}": filterable must be a boolean`);
|
|
229
|
+
const fts = normalizeFts(g.full_text_search, attr, g.type);
|
|
230
|
+
const annMetric = g.ann !== null && typeof g.ann === 'object' ? (g.ann as Record<string, unknown>).distance_metric : undefined;
|
|
231
|
+
if (annMetric !== undefined && !(DISTANCE_METRICS as readonly string[]).includes(annMetric as string)) throw badRequest(`invalid schema for attribute "${attr}": ann.distance_metric must be one of ${DISTANCE_METRICS.join(', ')}`);
|
|
232
|
+
return {
|
|
233
|
+
...(typeof annMetric === 'string' ? { ann_distance_metric: annMetric } : {}),
|
|
234
|
+
type: g.type,
|
|
235
|
+
// "by default, BM25-enabled attributes are not filterable" (SDK AttributeSchemaConfig doc).
|
|
236
|
+
...(late ? { late_interaction: g.ann !== false } : {}),
|
|
237
|
+
// glob, regex and fuzzy: "If set, `filterable` defaults to `false`; you can override this by setting `filterable: true`"
|
|
238
|
+
filterable: g.type === '{}f16' || g.type === 'bytes' || late ? false : typeof g.filterable === 'boolean' ? g.filterable : fts === false && g.regex !== true && g.fuzzy !== true && g.glob !== true,
|
|
239
|
+
...(g.sparse_knn !== undefined && g.sparse_knn !== null ? { sparse_knn: { distance_metric: 'dot_product' } } : {}),
|
|
240
|
+
full_text_search: fts,
|
|
241
|
+
...(g.glob === true ? { glob: true } : {}),
|
|
242
|
+
...(g.regex === true ? { regex: true } : {}),
|
|
243
|
+
...(g.fuzzy === true ? { fuzzy: true } : {}),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The type Turbopuffer infers for an attribute first written without a declared schema. */
|
|
248
|
+
function inferType(v: unknown): string | null {
|
|
249
|
+
if (typeof v === 'string') return 'string';
|
|
250
|
+
if (typeof v === 'boolean') return 'bool';
|
|
251
|
+
if (typeof v === 'number') return Number.isInteger(v) ? 'int' : 'float';
|
|
252
|
+
// an object, or anything else no type is inferred from, is refused by the caller
|
|
253
|
+
if (!Array.isArray(v)) return null;
|
|
254
|
+
const first = v.find((x) => x !== null);
|
|
255
|
+
if (first === undefined) return '[]string';
|
|
256
|
+
const inner = inferType(first);
|
|
257
|
+
return inner === null || inner.startsWith('[') ? null : `[]${inner}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function valueMatchesType(v: unknown, type: string): boolean {
|
|
261
|
+
if (type.startsWith('[]')) return Array.isArray(v) && v.every((x) => x === null || valueMatchesType(x, type.slice(2)));
|
|
262
|
+
switch (type) {
|
|
263
|
+
case 'string': case 'uuid': case 'datetime': return typeof v === 'string';
|
|
264
|
+
case 'bool': return typeof v === 'boolean';
|
|
265
|
+
case 'float': return typeof v === 'number' && Number.isFinite(v);
|
|
266
|
+
case 'int': return typeof v === 'number' && Number.isInteger(v);
|
|
267
|
+
case 'uint': return typeof v === 'number' && Number.isInteger(v) && v >= 0;
|
|
268
|
+
case 'bytes': return typeof v === 'string' && /^[A-Za-z0-9+/]*={0,2}$/.test(v) && v.length % 4 === 0;
|
|
269
|
+
// "Max dimensions per sparse vector: 1,024" (https://turbopuffer.com/docs/limits)
|
|
270
|
+
case '{}f16': return v !== null && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length <= 1024 && Object.values(v).every((x) => typeof x === 'number' && Number.isFinite(x));
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── writes ──────────────────────────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
const WRITE_KEYS = new Set(['upsert_condition', 'patch_condition', 'delete_condition', 'upsert_rows', 'upsert_columns', 'patch_rows', 'patch_columns', 'deletes', 'delete_by_filter', 'delete_by_filter_allow_partial', 'patch_by_filter', 'patch_by_filter_allow_partial', 'schema', 'distance_metric', 'return_affected_ids', 'disable_backpressure']);
|
|
278
|
+
const UNMODELED_WRITE_KEYS: Record<string, string> = {
|
|
279
|
+
encryption: 'turbopuffer.write.encryption',
|
|
280
|
+
sharding: 'turbopuffer.write.sharding',
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export type WriteOutcome = {
|
|
284
|
+
next: NamespaceState;
|
|
285
|
+
created: boolean;
|
|
286
|
+
upserts: Doc[];
|
|
287
|
+
deletes: string[];
|
|
288
|
+
response: Record<string, unknown>;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
function assertId(id: unknown, where: string): string | number {
|
|
292
|
+
const valid = (typeof id === 'string' && id.length > 0 && id.length <= 64 * 1024) || (typeof id === 'number' && Number.isSafeInteger(id) && id >= 0);
|
|
293
|
+
if (!valid) throw badRequest(`invalid ${where}: a document id must be a non-empty string or an unsigned integer, got ${JSON.stringify(id)}`);
|
|
294
|
+
return id as string | number;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function rowsFromColumns(cols: unknown, where: string): Record<string, unknown>[] {
|
|
298
|
+
if (cols === null || typeof cols !== 'object' || Array.isArray(cols)) throw badRequest(`invalid ${where}: expected an object of columns`);
|
|
299
|
+
const c = cols as Record<string, unknown>;
|
|
300
|
+
if (!Array.isArray(c.id)) throw badRequest(`invalid ${where}: the id column is required`);
|
|
301
|
+
const n = c.id.length;
|
|
302
|
+
for (const [k, v] of Object.entries(c)) {
|
|
303
|
+
if (!Array.isArray(v)) throw badRequest(`invalid ${where}: column "${k}" must be an array`);
|
|
304
|
+
if (v.length !== n) throw badRequest(`invalid ${where}: column "${k}" has ${v.length} values but the id column has ${n}`);
|
|
305
|
+
}
|
|
306
|
+
return Array.from({ length: n }, (_, i) => Object.fromEntries(Object.entries(c).map(([k, v]) => [k, (v as unknown[])[i]])));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function rowList(v: unknown, where: string): Record<string, unknown>[] {
|
|
310
|
+
if (!Array.isArray(v)) throw badRequest(`invalid ${where}: expected an array of rows`);
|
|
311
|
+
return v.map((r, i) => {
|
|
312
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r)) throw badRequest(`invalid ${where}[${i}]: expected an object`);
|
|
313
|
+
return r as Record<string, unknown>;
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function logicalBytes(d: Doc): number {
|
|
318
|
+
return utf8Length(JSON.stringify(d.id)) + utf8Length(JSON.stringify(d.attributes)) + (d.vector ? d.vector.length * 4 : 0);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function approxLogicalBytes(ns: NamespaceState): number {
|
|
322
|
+
let n = 0;
|
|
323
|
+
for (const d of ns.docs.values()) n += logicalBytes(d);
|
|
324
|
+
return n;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** `copy_from_namespace` and `branch_from_namespace` (https://turbopuffer.com/docs/write): the destination is made as a
|
|
328
|
+
* copy of the source's documents, schema and distance metric. "The destination namespace you are copying into must be
|
|
329
|
+
* empty. The initial request currently cannot make schema changes or contain documents"; a branch is "an instant
|
|
330
|
+
* copy-on-write clone of the source namespace. The destination namespace must be empty", and "reads, writes, queries,
|
|
331
|
+
* and deletes on one namespace do not affect the other". A copy answers "namespace cloned successfully" (the async
|
|
332
|
+
* copy's result, https://turbopuffer.com/docs/api-overview), and a destination that exists "destination namespace
|
|
333
|
+
* already exists" (the same page's failed result).
|
|
334
|
+
*
|
|
335
|
+
* Where the docs stop and the twin decides: a World holds one organization's namespaces in one store whatever region a
|
|
336
|
+
* request names, so a copy's `source_region` and `source_api_key` read the same store; `rows_affected` counts the
|
|
337
|
+
* documents cloned; a copy bills the logical bytes copied and a branch none ("billed at a flat rate"); a branch's
|
|
338
|
+
* answer carries the copy's message. Pure. */
|
|
339
|
+
export function applyClone(spaces: Map<string, NamespaceState>, name: string, rawBody: unknown, at: string): WriteOutcome {
|
|
340
|
+
assertNamespaceName(name);
|
|
341
|
+
const body = rawBody as Record<string, unknown>;
|
|
342
|
+
const branch = body.branch_from_namespace !== undefined;
|
|
343
|
+
const key = branch ? 'branch_from_namespace' : 'copy_from_namespace';
|
|
344
|
+
if (body.copy_from_namespace !== undefined && branch) throw badRequest('invalid write request: copy_from_namespace and branch_from_namespace cannot both be set');
|
|
345
|
+
for (const k of Object.keys(body)) if (k !== key && k !== 'return_affected_ids' && k !== 'disable_backpressure') throw cloneRefusal(k, key);
|
|
346
|
+
const raw = body[key];
|
|
347
|
+
const cfg = typeof raw === 'string' ? { source_namespace: raw } : raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : undefined;
|
|
348
|
+
const allowed = branch ? ['source_namespace'] : ['source_namespace', 'source_api_key', 'source_region', 'dest_encryption'];
|
|
349
|
+
if (!cfg || typeof cfg.source_namespace !== 'string' || Object.keys(cfg).some((k) => !allowed.includes(k))) throw badRequest(`invalid ${key}: expected a namespace name or { ${allowed.join(', ')} }`);
|
|
350
|
+
if (cfg.dest_encryption !== undefined) throw badRequest('turbopuffer twin: "dest_encryption" is real Turbopuffer surface this twin does not model yet (turbopuffer.write.encryption is the filed gap)');
|
|
351
|
+
const source = spaces.get(cfg.source_namespace);
|
|
352
|
+
if (source === undefined) throw new TurbopufferError(404, `namespace '${cfg.source_namespace}' was not found`);
|
|
353
|
+
if (spaces.has(name)) throw badRequest('destination namespace already exists');
|
|
354
|
+
const docs = new Map([...source.docs].map(([k, d]) => [k, { ...d, attributes: { ...d.attributes } }]));
|
|
355
|
+
const next: NamespaceState = {
|
|
356
|
+
name, schema: structuredClone(source.schema), distance_metric: source.distance_metric, vector_dims: source.vector_dims,
|
|
357
|
+
created_at: at, updated_at: at, last_write_at: at, ...(branch ? { parent: source.name } : {}), docs,
|
|
358
|
+
};
|
|
359
|
+
const upserts = [...docs.values()];
|
|
360
|
+
const bytes = branch ? 0 : upserts.reduce((n, d) => n + logicalBytes(d), 0);
|
|
361
|
+
const response = {
|
|
362
|
+
status: 'OK', message: 'namespace cloned successfully', rows_affected: upserts.length,
|
|
363
|
+
...(body.return_affected_ids === true && upserts.length ? { upserted_ids: upserts.map((d) => d.id) } : {}),
|
|
364
|
+
billing: { billable_logical_bytes_written: bytes }, performance: { server_total_ms: 0 },
|
|
365
|
+
};
|
|
366
|
+
return { next, created: true, upserts, deletes: [], response };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** A clone request carrying something else: "The initial request currently cannot make schema changes or contain
|
|
370
|
+
* documents" (https://turbopuffer.com/docs/write, copy_from_namespace); its `encryption` and `sharding`, which the page
|
|
371
|
+
* lets a copy set, are not modelled. */
|
|
372
|
+
function cloneRefusal(k: string, key: string): TurbopufferError {
|
|
373
|
+
if (k === 'encryption' || k === 'sharding') return gap(`"${k}" on a ${key} request`, `turbopuffer.write.${k}`);
|
|
374
|
+
return badRequest(`invalid write request: a ${key} request cannot also carry "${k}"`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Apply one write request body to a namespace (or create it). Pure. */
|
|
378
|
+
export function applyWrite(existing: NamespaceState | undefined, name: string, rawBody: unknown, at: string): WriteOutcome {
|
|
379
|
+
assertNamespaceName(name);
|
|
380
|
+
if (rawBody === null || typeof rawBody !== 'object' || Array.isArray(rawBody)) throw badRequest('invalid write request: expected a JSON object');
|
|
381
|
+
const body = rawBody as Record<string, unknown>;
|
|
382
|
+
for (const k of Object.keys(body)) {
|
|
383
|
+
const gap = UNMODELED_WRITE_KEYS[k];
|
|
384
|
+
if (gap !== undefined) throw badRequest(`turbopuffer twin: "${k}" is real Turbopuffer surface this twin does not model yet (${gap} is the filed gap)`);
|
|
385
|
+
if (!WRITE_KEYS.has(k)) throw badRequest(`invalid write request: unknown field "${k}"`);
|
|
386
|
+
}
|
|
387
|
+
const ops = ['upsert_rows', 'upsert_columns', 'patch_rows', 'patch_columns', 'deletes', 'delete_by_filter', 'patch_by_filter'].filter((k) => body[k] !== undefined);
|
|
388
|
+
if (ops.length === 0 && body.schema === undefined) throw badRequest('invalid write request: the request contains no write operation');
|
|
389
|
+
|
|
390
|
+
const ns: NamespaceState = existing
|
|
391
|
+
? { ...existing, schema: { ...existing.schema }, docs: new Map(existing.docs) }
|
|
392
|
+
: { name, schema: {}, distance_metric: null, vector_dims: null, created_at: at, updated_at: at, last_write_at: at, docs: new Map() };
|
|
393
|
+
// "updated_at … when the namespace's data or schema was last modified"; "last_write_at … when the namespace's data was
|
|
394
|
+
// last modified" (https://turbopuffer.com/docs/metadata)
|
|
395
|
+
ns.updated_at = at;
|
|
396
|
+
if (ops.length > 0) ns.last_write_at = at;
|
|
397
|
+
|
|
398
|
+
// 1. schema (declared) — a type may never change under an attribute that already has one.
|
|
399
|
+
if (body.schema !== undefined) {
|
|
400
|
+
if (body.schema === null || typeof body.schema !== 'object' || Array.isArray(body.schema)) throw badRequest('invalid write request: schema must be an object');
|
|
401
|
+
for (const [attr, raw] of Object.entries(body.schema as Record<string, unknown>)) mergeSchema(ns, attr, raw, existing === undefined);
|
|
402
|
+
}
|
|
403
|
+
// 2. distance metric — fixed once set.
|
|
404
|
+
if (body.distance_metric !== undefined) {
|
|
405
|
+
if (typeof body.distance_metric !== 'string' || !(DISTANCE_METRICS as readonly string[]).includes(body.distance_metric)) throw badRequest(`invalid write request: distance_metric must be one of ${DISTANCE_METRICS.join(', ')}`);
|
|
406
|
+
if (ns.distance_metric !== null && ns.distance_metric !== body.distance_metric) throw badRequest(`invalid write request: namespace "${name}" uses distance_metric ${ns.distance_metric}, not ${body.distance_metric}`);
|
|
407
|
+
ns.distance_metric = body.distance_metric;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const upserted = new Map<string, Doc>();
|
|
411
|
+
const deleted = new Set<string>();
|
|
412
|
+
const upsertedIds: (string | number)[] = [];
|
|
413
|
+
const patchedIds: (string | number)[] = [];
|
|
414
|
+
const deletedIds: (string | number)[] = [];
|
|
415
|
+
let rowsUpserted = 0;
|
|
416
|
+
let rowsPatched = 0;
|
|
417
|
+
let rowsDeleted = 0;
|
|
418
|
+
const ctx = (): FilterContext => ({ schema: ns.schema, tokenCache: new Map() });
|
|
419
|
+
const remove = (key: string) => { const d = ns.docs.get(key); if (d) { ns.docs.delete(key); upserted.delete(key); deleted.add(key); } };
|
|
420
|
+
const put = (d: Doc) => { ns.docs.set(d.key, d); upserted.set(d.key, d); deleted.delete(d.key); };
|
|
421
|
+
|
|
422
|
+
// Order of application: filtered deletes, filtered patches, upserts, patches, deletes.
|
|
423
|
+
if (body.delete_by_filter !== undefined) {
|
|
424
|
+
const pred = compileFilter(body.delete_by_filter, ctx());
|
|
425
|
+
for (const d of [...ns.docs.values()]) if (pred(d)) { remove(d.key); rowsDeleted++; deletedIds.push(d.id); }
|
|
426
|
+
}
|
|
427
|
+
if (body.patch_by_filter !== undefined) {
|
|
428
|
+
const pbf = body.patch_by_filter as Record<string, unknown> | null;
|
|
429
|
+
if (pbf === null || typeof pbf !== 'object' || Array.isArray(pbf) || pbf.patch === null || typeof pbf.patch !== 'object' || Array.isArray(pbf.patch)) throw badRequest('invalid patch_by_filter: expected { filters, patch }');
|
|
430
|
+
const pred = compileFilter(pbf.filters, ctx());
|
|
431
|
+
const patch = pbf.patch as Record<string, unknown>;
|
|
432
|
+
if ('id' in patch) throw badRequest('invalid patch_by_filter: the patch may not set id');
|
|
433
|
+
for (const d of [...ns.docs.values()]) if (pred(d)) { put(patchDoc(ns, d, patch)); rowsPatched++; patchedIds.push(d.id); }
|
|
434
|
+
}
|
|
435
|
+
const upsertRows = [
|
|
436
|
+
...(body.upsert_rows !== undefined ? rowList(body.upsert_rows, 'upsert_rows') : []),
|
|
437
|
+
...(body.upsert_columns !== undefined ? rowsFromColumns(body.upsert_columns, 'upsert_columns') : []),
|
|
438
|
+
];
|
|
439
|
+
const passes = conditionOf(ns, body, 'upsert_condition');
|
|
440
|
+
const passesPatch = conditionOf(ns, body, 'patch_condition');
|
|
441
|
+
const passesDelete = conditionOf(ns, body, 'delete_condition');
|
|
442
|
+
for (const row of upsertRows) {
|
|
443
|
+
const current = row.id === undefined ? undefined : ns.docs.get(docKey(assertId(row.id, 'upsert_rows')));
|
|
444
|
+
if (current !== undefined && passes && !passes(current, row)) continue;
|
|
445
|
+
const d = buildDoc(ns, row, 'upsert_rows');
|
|
446
|
+
embedRow(ns, d);
|
|
447
|
+
checkAllVectors(ns, d, 'upsert_rows');
|
|
448
|
+
put(d);
|
|
449
|
+
rowsUpserted++;
|
|
450
|
+
upsertedIds.push(d.id);
|
|
451
|
+
}
|
|
452
|
+
const patchRows = [
|
|
453
|
+
...(body.patch_rows !== undefined ? rowList(body.patch_rows, 'patch_rows') : []),
|
|
454
|
+
...(body.patch_columns !== undefined ? rowsFromColumns(body.patch_columns, 'patch_columns') : []),
|
|
455
|
+
];
|
|
456
|
+
for (const row of patchRows) {
|
|
457
|
+
const id = assertId(row.id, 'patch_rows');
|
|
458
|
+
const current = ns.docs.get(docKey(id));
|
|
459
|
+
if (current === undefined) continue; // a patch never creates a document
|
|
460
|
+
if (passesPatch && !passesPatch(current, row)) continue;
|
|
461
|
+
const { id: _id, ...patch } = row;
|
|
462
|
+
put(patchDoc(ns, current, patch));
|
|
463
|
+
rowsPatched++;
|
|
464
|
+
patchedIds.push(id);
|
|
465
|
+
}
|
|
466
|
+
if (body.deletes !== undefined) {
|
|
467
|
+
if (!Array.isArray(body.deletes)) throw badRequest('invalid deletes: expected an array of ids');
|
|
468
|
+
for (const raw of body.deletes) {
|
|
469
|
+
const id = assertId(raw, 'deletes');
|
|
470
|
+
if (passesDelete && !conditionalDelete(ns, id, passesDelete)) continue;
|
|
471
|
+
remove(docKey(id));
|
|
472
|
+
rowsDeleted++;
|
|
473
|
+
deletedIds.push(id);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const upserts = [...upserted.values()];
|
|
478
|
+
const bytes = upserts.reduce((n, d) => n + logicalBytes(d), 0);
|
|
479
|
+
const queried = ['upsert_condition', 'patch_condition', 'delete_condition', 'patch_by_filter', 'delete_by_filter'].some((k) => body[k] !== undefined);
|
|
480
|
+
const queriedBytes = existing ? approxLogicalBytes(existing) : 0;
|
|
481
|
+
const returnIds = body.return_affected_ids === true;
|
|
482
|
+
const response: Record<string, unknown> = {
|
|
483
|
+
status: 'OK',
|
|
484
|
+
// the Write page's answers: "message": "documents committed successfully"
|
|
485
|
+
message: 'documents committed successfully',
|
|
486
|
+
rows_affected: rowsUpserted + rowsPatched + rowsDeleted,
|
|
487
|
+
...(upsertRows.length > 0 ? { rows_upserted: rowsUpserted } : {}),
|
|
488
|
+
...(patchRows.length > 0 || body.patch_by_filter !== undefined ? { rows_patched: rowsPatched } : {}),
|
|
489
|
+
...(body.deletes !== undefined || body.delete_by_filter !== undefined ? { rows_deleted: rowsDeleted } : {}),
|
|
490
|
+
...(body.delete_by_filter !== undefined || body.patch_by_filter !== undefined ? { rows_remaining: false } : {}),
|
|
491
|
+
...(returnIds && upsertedIds.length > 0 ? { upserted_ids: upsertedIds } : {}),
|
|
492
|
+
...(returnIds && patchedIds.length > 0 ? { patched_ids: patchedIds } : {}),
|
|
493
|
+
...(returnIds && deletedIds.length > 0 ? { deleted_ids: deletedIds } : {}),
|
|
494
|
+
billing: {
|
|
495
|
+
billable_logical_bytes_written: bytes,
|
|
496
|
+
// "query (object, optional): query billing information when the write involves a query-like operation (for a
|
|
497
|
+
// conditional write, patch_by_filter, delete_by_filter …)" (https://turbopuffer.com/docs/write); the twin bills the
|
|
498
|
+
// namespace's logical bytes as queried and none as returned, its reading
|
|
499
|
+
...(queried ? { query: { billable_logical_bytes_queried: queriedBytes, billable_logical_bytes_returned: 0 } } : {}),
|
|
500
|
+
},
|
|
501
|
+
performance: { server_total_ms: 0 },
|
|
502
|
+
};
|
|
503
|
+
return { next: ns, created: existing === undefined, upserts, deletes: [...deleted], response };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Whether a conditional delete of `id` goes ahead: "If the document does not exist, the write is … skipped
|
|
507
|
+
* unconditionally for patches and deletes", and "`$ref_new` references are given a `null` value for all attributes"
|
|
508
|
+
* (https://turbopuffer.com/docs/write, delete_condition). */
|
|
509
|
+
function conditionalDelete(ns: NamespaceState, id: string | number, passes: (current: Doc, next: Record<string, unknown>) => boolean): boolean {
|
|
510
|
+
const current = ns.docs.get(docKey(id));
|
|
511
|
+
return current !== undefined && passes(current, {});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** A write's condition (https://turbopuffer.com/docs/write, Conditional writes): "The condition syntax matches the
|
|
515
|
+
* filters parameter in the query API, with an additional feature: you can reference the new value being written using
|
|
516
|
+
* $ref_new references", evaluated "using the current value of the document with the matching ID". For deletes,
|
|
517
|
+
* "`$ref_new` references are given a `null` value for all attributes". Undefined when the body sets none. */
|
|
518
|
+
function conditionOf(ns: NamespaceState, body: Record<string, unknown>, key: string): ((current: Doc, next: Record<string, unknown>) => boolean) | undefined {
|
|
519
|
+
const cond = body[key];
|
|
520
|
+
if (cond === undefined) return undefined;
|
|
521
|
+
if (!Array.isArray(cond)) throw badRequest(`invalid ${key}: expected a filter`);
|
|
522
|
+
const resolve = (node: unknown, next: Record<string, unknown>): unknown => {
|
|
523
|
+
if (Array.isArray(node)) return node.map((n) => resolve(n, next));
|
|
524
|
+
if (node !== null && typeof node === 'object') {
|
|
525
|
+
const ref = (node as Record<string, unknown>).$ref_new;
|
|
526
|
+
if (Object.keys(node).length !== 1 || typeof ref !== 'string') throw badRequest(`invalid ${key}: an object in a condition must be {"$ref_new": "<attribute>"}`);
|
|
527
|
+
return next[ref] ?? null;
|
|
528
|
+
}
|
|
529
|
+
return node;
|
|
530
|
+
};
|
|
531
|
+
compileFilter(resolve(cond, {}), { schema: ns.schema, tokenCache: new Map() }); // a condition that does not parse refuses the write
|
|
532
|
+
return (current, next) => compileFilter(resolve(cond, next), { schema: ns.schema, tokenCache: new Map() })(current);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function mergeSchema(ns: NamespaceState, attr: string, raw: unknown, creating = false): void {
|
|
536
|
+
if (attr === 'id') return mergeIdSchema(ns, raw);
|
|
537
|
+
const next = normalizeAttrSchema(attr, raw);
|
|
538
|
+
const prev = ns.schema[attr];
|
|
539
|
+
const embed = raw !== null && typeof raw === 'object' && !Array.isArray(raw) && 'embed' in (raw as object) ? (raw as Record<string, unknown>).embed : undefined;
|
|
540
|
+
if (embed === undefined) { if (prev?.embed) next.embed = prev.embed; } else embedSchema(ns, attr, next, prev, embed, creating);
|
|
541
|
+
if (prev !== undefined && prev.type !== next.type) throw badRequest(`invalid schema for attribute "${attr}": cannot change type from ${prev.type} to ${next.type}`);
|
|
542
|
+
if (attr === 'vector' || isVectorType(next.type)) {
|
|
543
|
+
const dims = dimsOf(next.type);
|
|
544
|
+
// "Vector attributes require an ANN index, configured via the `ann` schema parameter"; `ann` "Must be set to `true`
|
|
545
|
+
// for vector type attributes" (https://turbopuffer.com/docs/write)
|
|
546
|
+
const ann = raw !== null && typeof raw === 'object' ? (raw as Record<string, unknown>).ann : undefined;
|
|
547
|
+
if (ann !== true && (ann === null || typeof ann !== 'object')) throw badRequest(`invalid schema for attribute "${attr}": vector attributes require ann: true`);
|
|
548
|
+
if (attr === 'vector') {
|
|
549
|
+
if (ns.vector_dims !== null && ns.vector_dims !== dims) throw badRequest(`invalid schema: vector has ${ns.vector_dims} dimensions, not ${dims}`);
|
|
550
|
+
ns.vector_dims = dims;
|
|
551
|
+
// "Vector columns must be declared in the schema and are fixed at namespace creation time" (Multiple vector columns)
|
|
552
|
+
} else if (prev === undefined && !creating) throw badRequest(`invalid schema for attribute "${attr}": vector columns are fixed at namespace creation time`);
|
|
553
|
+
if (next.ann_distance_metric !== undefined) annMetric(ns, next.ann_distance_metric);
|
|
554
|
+
delete next.ann_distance_metric;
|
|
555
|
+
}
|
|
556
|
+
ns.schema[attr] = next;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// "Document IDs are unsigned 64-bit integers, 128-bit UUIDs, or strings up to 64 bytes" and a UUID id "must be set
|
|
560
|
+
// explicitly in the schema" (https://turbopuffer.com/docs/write: `"id": "uuid"` in its Configuring the schema example);
|
|
561
|
+
// "All attributes are nullable, except for `id`". The id's type is otherwise the first document's: `uint` for a number
|
|
562
|
+
// (the Namespace metadata example answers `"id": {"type": "uint"}`), `string` for a string.
|
|
563
|
+
const ID_TYPES = ['uint', 'uuid', 'string'];
|
|
564
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
565
|
+
|
|
566
|
+
function mergeIdSchema(ns: NamespaceState, raw: unknown): void {
|
|
567
|
+
const type = typeof raw === 'string' ? raw : raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>).type : undefined;
|
|
568
|
+
if (typeof type !== 'string' || !ID_TYPES.includes(type)) throw badRequest(`invalid schema for attribute "id": type must be one of ${ID_TYPES.join(', ')}`);
|
|
569
|
+
const prev = ns.schema.id;
|
|
570
|
+
if (prev !== undefined && prev.type !== type) throw badRequest(`invalid schema for attribute "id": cannot change type from ${prev.type} to ${type}`);
|
|
571
|
+
ns.schema.id = { type, filterable: true, full_text_search: false };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** An id against the namespace's id type, which the first document sets when no schema did. */
|
|
575
|
+
function checkIdType(ns: NamespaceState, id: string | number, where: string): void {
|
|
576
|
+
const declared = ns.schema.id?.type;
|
|
577
|
+
if (declared === undefined) { ns.schema.id = { type: typeof id === 'number' ? 'uint' : 'string', filterable: true, full_text_search: false }; return; }
|
|
578
|
+
const ok = declared === 'uint' ? typeof id === 'number' : declared === 'uuid' ? typeof id === 'string' && UUID.test(id) : typeof id === 'string';
|
|
579
|
+
if (!ok) throw badRequest(`invalid ${where}: the id ${JSON.stringify(id)} is not of the namespace's id type ${declared}`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── native embedding ──────────────────────────────────────────────────────────────────────
|
|
583
|
+
// https://turbopuffer.com/docs/embedding and /docs/write (embed): "use the `embed` property on an attribute's schema to
|
|
584
|
+
// specify an embedding model. Writes and queries to that attribute are turned into vectors on the fly." `embed` "Can
|
|
585
|
+
// only be set on `string` fields", and is a model name or {model, attribute?, dims?, dtype?}: `attribute` is "The
|
|
586
|
+
// vector attribute that stores embedding vectors for this field. If omitted, turbopuffer creates a computed vector
|
|
587
|
+
// attribute named `embed_<attribute>`. … Required when enabling embedding on an existing attribute"; `dims` "If not set,
|
|
588
|
+
// will use the model's default dimensionality. … Must match the size of the vector specified in `attribute`". `embed`
|
|
589
|
+
// "can be added or removed, but not modified", and `null` removes it ("any future write to this namespace requires
|
|
590
|
+
// writing the embed_text vector"). "With `embed` set, you can still provide a vector yourself. Each row that includes
|
|
591
|
+
// a vector is stored as-is, and rows with no vector have one computed". A query ranks by `["Embed", text, {model?}]`
|
|
592
|
+
// under ANN or kNN; `model` is "Required when using the `Embed` function to rank a vector attribute with native
|
|
593
|
+
// embedding, optional when ranking a source attribute that has `embed` set in the schema".
|
|
594
|
+
// Where the docs stop and the twin decides: an embedding is a labelled placeholder, not a model's output (the twin runs
|
|
595
|
+
// no model): a unit vector derived from the model's name and the text, the same for a document and a query of the same
|
|
596
|
+
// text, so ANN over it ranks by nothing a model knows; the models are the Embedding page's and its changelog's, and a
|
|
597
|
+
// model's default dimensions, which the page shows only on request, are 1024 (the dims every example on the page
|
|
598
|
+
// names); a stored vector is f32; the tokens embedded, which the page says both answers carry, are not answered, since
|
|
599
|
+
// no page names their field.
|
|
600
|
+
export const EMBEDDING_MODELS = [
|
|
601
|
+
'baai/bge-m3', 'cohere/embed-v4.0', 'google/gemini-embedding-2', 'nvidia/nemotron-3-embed-1b', 'nvidia/nemotron-3-embed-8b',
|
|
602
|
+
'openai/text-embedding-3-large', 'openai/text-embedding-3-small', 'openai/text-embedding-ada-002', 'qwen/qwen3-embedding-0p6b',
|
|
603
|
+
'qwen/qwen3-embedding-4b', 'qwen/qwen3-embedding-8b', 'voyage/voyage-4', 'voyage/voyage-4-large', 'voyage/voyage-4-lite',
|
|
604
|
+
'voyage/voyage-4-nano', 'voyage/voyage-code-3', 'voyage/voyage-code-4', 'zeroentropy/zembed-1',
|
|
605
|
+
// deprecated, still available (https://turbopuffer.com/docs/embedding/changelog, August 2026)
|
|
606
|
+
'cohere/embed-english-v3.0', 'google/gemini-embedding-001', 'google/text-embedding-005', 'voyage/voyage-3-large', 'voyage/voyage-3.5', 'voyage/voyage-3.5-lite',
|
|
607
|
+
];
|
|
608
|
+
const DEFAULT_EMBED_DIMS = 1024;
|
|
609
|
+
const sha256Bytes = (s: string): Buffer => createHash('sha256').update(s).digest();
|
|
610
|
+
|
|
611
|
+
function checkModel(model: unknown): string {
|
|
612
|
+
if (typeof model !== 'string' || !EMBEDDING_MODELS.includes(model)) throw badRequest(`invalid embed: unknown embedding model ${JSON.stringify(model)}`);
|
|
613
|
+
return model;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function embedSchema(ns: NamespaceState, attr: string, next: AttrConfig, prev: AttrConfig | undefined, raw: unknown, creating: boolean): void {
|
|
617
|
+
if (raw === null) { delete next.embed; return; }
|
|
618
|
+
if (next.type !== 'string') throw badRequest(`invalid schema for attribute "${attr}": embed can only be set on string fields`);
|
|
619
|
+
const cfg = typeof raw === 'string' ? { model: raw } : raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : undefined;
|
|
620
|
+
if (!cfg || Object.keys(cfg).some((k) => !['model', 'attribute', 'dims', 'dtype'].includes(k))) throw badRequest(`invalid schema for attribute "${attr}": embed is a model name or { model, attribute, dims, dtype }`);
|
|
621
|
+
const model = checkModel(cfg.model);
|
|
622
|
+
if (prev?.embed) { next.embed = keptEmbed(prev.embed, model, cfg, attr); return; }
|
|
623
|
+
const existingAttr = prev !== undefined || (!creating && [...ns.docs.values()].some((d) => d.attributes[attr] !== undefined));
|
|
624
|
+
if (existingAttr && cfg.attribute === undefined) throw badRequest(`invalid schema for attribute "${attr}": attribute is required when enabling embedding on an existing attribute`);
|
|
625
|
+
const target = typeof cfg.attribute === 'string' ? cfg.attribute : `embed_${attr}`;
|
|
626
|
+
if (cfg.dtype !== undefined && cfg.dtype !== 'f32') throw badRequest(`invalid schema for attribute "${attr}": the twin stores embeddings as f32`);
|
|
627
|
+
const column = target === 'vector' ? (ns.vector_dims !== null ? `[${ns.vector_dims}]f32` : undefined) : ns.schema[target]?.type;
|
|
628
|
+
const dims = typeof cfg.dims === 'number' ? cfg.dims : column ? dimsOf(column) : DEFAULT_EMBED_DIMS;
|
|
629
|
+
if (column !== undefined && dimsOf(column) !== dims) throw badRequest(`invalid schema for attribute "${attr}": dims ${dims} does not match the size of "${target}" (${column})`);
|
|
630
|
+
if (column === undefined) {
|
|
631
|
+
if (target === 'vector') { ns.vector_dims = dims; ns.schema.vector = { type: `[${dims}]f32`, filterable: false, full_text_search: false }; }
|
|
632
|
+
else ns.schema[target] = { type: `[${dims}]f32`, filterable: false, full_text_search: false };
|
|
633
|
+
}
|
|
634
|
+
next.embed = { model, attribute: target, dims };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** An `embed` sent again for an attribute that has one: "embed (can be added or removed, but not modified)"
|
|
638
|
+
* (https://turbopuffer.com/docs/write, Updating attributes). */
|
|
639
|
+
function keptEmbed(prev: NonNullable<AttrConfig['embed']>, model: string, cfg: Record<string, unknown>, attr: string): NonNullable<AttrConfig['embed']> {
|
|
640
|
+
if (prev.model !== model || (cfg.attribute !== undefined && cfg.attribute !== prev.attribute)) throw badRequest(`invalid schema for attribute "${attr}": embed can be added or removed, but not modified`);
|
|
641
|
+
return prev;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** The placeholder embedding of a text: a unit vector of `dims` derived from the model's name and the text. */
|
|
645
|
+
export function placeholderEmbedding(model: string, text: string, dims: number): number[] {
|
|
646
|
+
const out: number[] = [];
|
|
647
|
+
let block = new Uint8Array();
|
|
648
|
+
for (let i = 0; out.length < dims; i++) {
|
|
649
|
+
if (i % 8 === 0) block = new Uint8Array(sha256Bytes(`${model}\u0000${text}\u0000${i / 8}`));
|
|
650
|
+
const j = (i % 8) * 4;
|
|
651
|
+
out.push(((block[j]! << 8 | block[j + 1]!) / 65535) * 2 - 1);
|
|
652
|
+
}
|
|
653
|
+
const norm = Math.sqrt(out.reduce((n, x) => n + x * x, 0)) || 1;
|
|
654
|
+
return out.map((x) => x / norm);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Fill each embedded attribute's vector where the row sent none ("rows with no vector have one computed"). */
|
|
658
|
+
function embedRow(ns: NamespaceState, d: Doc): void {
|
|
659
|
+
for (const [attr, c] of Object.entries(ns.schema)) {
|
|
660
|
+
if (!c.embed) continue;
|
|
661
|
+
const text = d.attributes[attr];
|
|
662
|
+
if (typeof text !== 'string') continue;
|
|
663
|
+
const target = c.embed.attribute;
|
|
664
|
+
if (target === 'vector') { if (d.vector === null) d.vector = placeholderEmbedding(c.embed.model, text, c.embed.dims); }
|
|
665
|
+
else if (d.attributes[target] === undefined) d.attributes[target] = placeholderEmbedding(c.embed.model, text, c.embed.dims);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function checkVector(ns: NamespaceState, v: unknown, where: string): number[] {
|
|
670
|
+
if (typeof v === 'string') throw badRequest(`turbopuffer twin: base64-encoded vectors are not modelled (turbopuffer.vectors.base64 is the filed gap)`);
|
|
671
|
+
if (!Array.isArray(v) || v.length === 0 || !v.every((x) => typeof x === 'number' && Number.isFinite(x))) throw badRequest(`invalid ${where}: vector must be a non-empty array of finite numbers`);
|
|
672
|
+
if (ns.distance_metric === null) throw badRequest(`invalid ${where}: distance_metric is required when writing vectors`);
|
|
673
|
+
if (ns.vector_dims === null) {
|
|
674
|
+
ns.vector_dims = v.length;
|
|
675
|
+
ns.schema.vector = { type: `[${v.length}]f32`, filterable: false, full_text_search: false };
|
|
676
|
+
} else if (v.length !== ns.vector_dims) throw badRequest(`invalid ${where}: vector has ${v.length} dimensions, but the namespace has ${ns.vector_dims}`);
|
|
677
|
+
return v as number[];
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function checkAttribute(ns: NamespaceState, attr: string, v: unknown, where: string): void {
|
|
681
|
+
const declared = ns.schema[attr];
|
|
682
|
+
if (declared !== undefined && isVectorType(declared.type)) { checkVectorValue(declared.type, attr, v, where); return; }
|
|
683
|
+
if (declared !== undefined && isVectorArrayType(declared.type)) {
|
|
684
|
+
// "Every vector in the array must have the same dimensionality (`N`)" (https://turbopuffer.com/docs/query, Late interaction)
|
|
685
|
+
if (!Array.isArray(v)) throw badRequest(`invalid ${where}: attribute "${attr}" is ${declared.type}, an array of vectors`);
|
|
686
|
+
for (const x of v) checkVectorValue(declared.type.slice(2), attr, x, where);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (attr.startsWith('$')) throw badRequest(`invalid ${where}: attribute names may not start with "$" ("${attr}")`);
|
|
690
|
+
if (attr.length > 128) throw badRequest(`invalid ${where}: attribute name "${attr.slice(0, 32)}…" is longer than 128 characters`);
|
|
691
|
+
let config = ns.schema[attr];
|
|
692
|
+
if (config === undefined) {
|
|
693
|
+
const inferred = inferType(v);
|
|
694
|
+
if (inferred === null) throw badRequest(`invalid ${where}: attribute "${attr}" has an unsupported value ${JSON.stringify(v).slice(0, 80)}`);
|
|
695
|
+
config = { type: inferred, filterable: true, full_text_search: false };
|
|
696
|
+
ns.schema[attr] = config;
|
|
697
|
+
}
|
|
698
|
+
if (!valueMatchesType(v, config.type)) throw badRequest(`invalid ${where}: attribute "${attr}" has type ${config.type}, but the value ${JSON.stringify(v).slice(0, 80)} does not match it`);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** `ann: {distance_metric}` (the spec's AnnConfig): the namespace's metric, which "will apply to all vector columns
|
|
702
|
+
* configured for this namespace" (https://turbopuffer.com/docs/write, distance_metric). */
|
|
703
|
+
function annMetric(ns: NamespaceState, metric: string): void {
|
|
704
|
+
if (ns.distance_metric !== null && ns.distance_metric !== metric) throw badRequest(`invalid schema: namespace uses distance_metric ${ns.distance_metric}, not ${metric}`);
|
|
705
|
+
ns.distance_metric = metric;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** A value of a vector column: "encoded as either a JSON array of numbers, or as a base64-encoded string", and "Elements
|
|
709
|
+
* of a vector attribute must have the same number of dimensions" (https://turbopuffer.com/docs/write, Vectors). The
|
|
710
|
+
* twin keeps the numbers as sent, its reading: the docs do not say how an f16 or i8 column answers a value read back.
|
|
711
|
+
* An i8 element is an integer from -128 to 127, the twin's reading of the type's name. */
|
|
712
|
+
function checkVectorValue(type: string, attr: string, v: unknown, where: string): void {
|
|
713
|
+
if (typeof v === 'string') throw badRequest(`turbopuffer twin: base64-encoded vectors are not modelled (turbopuffer.vectors.base64 is the filed gap)`);
|
|
714
|
+
const dims = dimsOf(type);
|
|
715
|
+
if (!Array.isArray(v) || v.length !== dims || !v.every((x) => typeof x === 'number' && Number.isFinite(x))) throw badRequest(`invalid ${where}: attribute "${attr}" must be ${dims} numbers (${type})`);
|
|
716
|
+
if (type.endsWith('i8') && !v.every((x) => Number.isInteger(x) && x >= -128 && x <= 127)) throw badRequest(`invalid ${where}: attribute "${attr}" is ${type}, so each element must be an integer from -128 to 127`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** "A namespace may or may not have vector indexes. If it does, all documents must include all vector attributes"
|
|
720
|
+
* (https://turbopuffer.com/docs/write, upsert_rows). */
|
|
721
|
+
function checkAllVectors(ns: NamespaceState, d: Doc, where: string): void {
|
|
722
|
+
for (const [attr, c] of Object.entries(ns.schema)) {
|
|
723
|
+
if (!isVectorType(c.type)) continue;
|
|
724
|
+
const has = attr === 'vector' ? d.vector !== null : d.attributes[attr] !== undefined;
|
|
725
|
+
if (!has) throw badRequest(`invalid ${where}: every document must include every vector attribute, and ${JSON.stringify(d.id)} has no "${attr}"`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function buildDoc(ns: NamespaceState, row: Record<string, unknown>, where: string): Doc {
|
|
730
|
+
const id = assertId(row.id, where);
|
|
731
|
+
checkIdType(ns, id, where);
|
|
732
|
+
const attributes: Record<string, unknown> = {};
|
|
733
|
+
let vector: number[] | null = null;
|
|
734
|
+
for (const [k, v] of Object.entries(row)) {
|
|
735
|
+
if (k === 'id') continue;
|
|
736
|
+
if (k === 'vector') { if (v !== null && v !== undefined) vector = checkVector(ns, v, where); continue; }
|
|
737
|
+
if (v === null || v === undefined) continue; // null is "no value" — the attribute is absent
|
|
738
|
+
checkAttribute(ns, k, v, where);
|
|
739
|
+
attributes[k] = v;
|
|
740
|
+
}
|
|
741
|
+
return { key: docKey(id), id, attributes, vector };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function patchDoc(ns: NamespaceState, d: Doc, patch: Record<string, unknown>): Doc {
|
|
745
|
+
const attributes = { ...d.attributes };
|
|
746
|
+
let vector = d.vector;
|
|
747
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
748
|
+
if (k === 'vector') { vector = v === null ? null : checkVector(ns, v, 'patch'); continue; }
|
|
749
|
+
if (v === null || v === undefined) { delete attributes[k]; continue; }
|
|
750
|
+
checkAttribute(ns, k, v, 'patch');
|
|
751
|
+
attributes[k] = v;
|
|
752
|
+
}
|
|
753
|
+
return { ...d, attributes, vector };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ── queries ─────────────────────────────────────────────────────────────────────────────────
|
|
757
|
+
|
|
758
|
+
const QUERY_KEYS = new Set(['group_by', 'rank_by', 'filters', 'top_k', 'limit', 'include_attributes', 'exclude_attributes', 'aggregate_by', 'consistency', 'distance_metric', 'vector_encoding']);
|
|
759
|
+
const UNMODELED_QUERY_KEYS: Record<string, string> = {
|
|
760
|
+
compute_attributes: 'turbopuffer.query.compute_attributes',
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
type Ranked = { doc: Doc; dist?: number };
|
|
764
|
+
|
|
765
|
+
export type QueryResult = { rows?: Record<string, unknown>[]; aggregations?: Record<string, unknown>; aggregation_groups?: Record<string, unknown>[] };
|
|
766
|
+
|
|
767
|
+
/** `vector_encoding` other than float: "base64" is documented ("The encoding to use for vectors in the response", the
|
|
768
|
+
* spec's VectorEncoding) and not modelled; anything else is refused. */
|
|
769
|
+
export function vectorEncodingRefusal(v: unknown): TurbopufferError {
|
|
770
|
+
return v === 'base64' ? gap('vector_encoding "base64"', 'turbopuffer.vectors.base64') : badRequest('invalid query: vector_encoding must be "float" or "base64"');
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function gap(what: string, id: string): TurbopufferError {
|
|
774
|
+
return badRequest(`turbopuffer twin: ${what} is real Turbopuffer surface this twin does not model yet (${id} is the filed gap)`);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Answer one query (the body of `POST /v2/namespaces/{ns}/query`, or one of `queries[]`). Pure. */
|
|
778
|
+
export function runQuery(ns: NamespaceState, rawQuery: unknown, opts: { multi?: boolean } = {}): QueryResult {
|
|
779
|
+
if (rawQuery === null || typeof rawQuery !== 'object' || Array.isArray(rawQuery)) throw badRequest('invalid query: expected a JSON object');
|
|
780
|
+
const q = rawQuery as Record<string, unknown>;
|
|
781
|
+
for (const k of Object.keys(q)) {
|
|
782
|
+
const g = UNMODELED_QUERY_KEYS[k];
|
|
783
|
+
if (g !== undefined) throw gap(`the query parameter "${k}"`, g);
|
|
784
|
+
if (!QUERY_KEYS.has(k) || (opts.multi && (k === 'consistency' || k === 'vector_encoding'))) throw badRequest(`invalid query: unknown field "${k}"`);
|
|
785
|
+
}
|
|
786
|
+
if (q.vector_encoding !== undefined && q.vector_encoding !== 'float') throw vectorEncodingRefusal(q.vector_encoding);
|
|
787
|
+
checkConsistency(q.consistency);
|
|
788
|
+
const ctx: FilterContext = { schema: ns.schema, tokenCache: new Map() };
|
|
789
|
+
const pred = q.filters === undefined || q.filters === null ? () => true : compileFilter(q.filters, ctx);
|
|
790
|
+
const matched = [...ns.docs.values()].filter(pred);
|
|
791
|
+
|
|
792
|
+
if (q.group_by !== undefined && q.aggregate_by === undefined) throw badRequest('invalid query: group_by is only valid when aggregate_by is set');
|
|
793
|
+
if (q.aggregate_by !== undefined) {
|
|
794
|
+
// "Cannot be specified with rank_by or include_attributes" (https://turbopuffer.com/docs/query, aggregate_by)
|
|
795
|
+
if (q.rank_by !== undefined || q.include_attributes !== undefined) throw badRequest('invalid query: aggregate_by cannot be specified with rank_by or include_attributes');
|
|
796
|
+
if (q.group_by !== undefined) return { aggregation_groups: groupAggregate(q.aggregate_by, q.group_by, matched, q.limit === undefined && q.top_k === undefined ? MAX_GROUPS : resolveLimit(q.top_k, q.limit)) };
|
|
797
|
+
return { aggregations: aggregate(q.aggregate_by, matched) };
|
|
798
|
+
}
|
|
799
|
+
// The Query page lists rank_by "required unless aggregate_by is set", but the Quickstart's runnable example sends a
|
|
800
|
+
// query with only `filters` and `limit` (`"filters": ["text", "Regex", "\\w+fish"]`), and "To find all documents
|
|
801
|
+
// matching filters when order isn't important to you, rank by the `id` attribute" (Lookups): the twin answers a query
|
|
802
|
+
// with neither as that lookup, in id order, its reading of the two pages.
|
|
803
|
+
const rankBy = q.rank_by ?? ['id', 'asc'];
|
|
804
|
+
|
|
805
|
+
const per = limitPer(q.limit);
|
|
806
|
+
const limit = resolveLimit(q.top_k, q.limit);
|
|
807
|
+
const ranked = rank(ns, rankBy, matched, ctx, q.distance_metric);
|
|
808
|
+
const rows = diversify(ranked, per, limit).map((r) => projectRow(r, q.include_attributes, q.exclude_attributes));
|
|
809
|
+
return { rows };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function checkConsistency(c: unknown): void {
|
|
813
|
+
if (c === undefined) return;
|
|
814
|
+
if (c === null || typeof c !== 'object' || Array.isArray(c)) throw badRequest('invalid query: consistency must be an object');
|
|
815
|
+
const level = (c as Record<string, unknown>).level;
|
|
816
|
+
// The twin is always strongly consistent, which satisfies both levels.
|
|
817
|
+
if (level !== undefined && level !== 'strong' && level !== 'eventual') throw badRequest('invalid query: consistency.level must be "strong" or "eventual"');
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function resolveLimit(topK: unknown, limit: unknown): number {
|
|
821
|
+
let n: unknown = topK;
|
|
822
|
+
if (limit !== undefined) {
|
|
823
|
+
if (typeof limit === 'object' && limit !== null && !Array.isArray(limit)) {
|
|
824
|
+
const l = limit as Record<string, unknown>;
|
|
825
|
+
n = l.total;
|
|
826
|
+
} else n = limit;
|
|
827
|
+
}
|
|
828
|
+
if (n === undefined) return 10;
|
|
829
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) throw badRequest('invalid query: top_k / limit must be a positive integer');
|
|
830
|
+
return n;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** `limit.per` (https://turbopuffer.com/docs/query): "limits the number of documents with the same value for a set of
|
|
834
|
+
* attributes (the "limit key") that can appear in the results", with `attributes` and `limit`. */
|
|
835
|
+
function limitPer(limit: unknown): { attributes: string[]; limit: number } | undefined {
|
|
836
|
+
if (limit === null || typeof limit !== 'object' || Array.isArray(limit)) return undefined;
|
|
837
|
+
const per = (limit as Record<string, unknown>).per;
|
|
838
|
+
if (per === undefined) return undefined;
|
|
839
|
+
const p = per as Record<string, unknown>;
|
|
840
|
+
if (per === null || typeof per !== 'object' || !Array.isArray(p.attributes) || !p.attributes.every((a) => typeof a === 'string') || typeof p.limit !== 'number' || !Number.isInteger(p.limit) || p.limit < 1) throw badRequest('invalid query: limit.per must be { attributes: string[], limit: positive integer }');
|
|
841
|
+
return { attributes: p.attributes as string[], limit: p.limit };
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** The ranked documents in order, at most `per.limit` for each value of the limit key, until `total`. */
|
|
845
|
+
function diversify(ranked: Ranked[], per: { attributes: string[]; limit: number } | undefined, total: number): Ranked[] {
|
|
846
|
+
if (!per) return ranked.slice(0, total);
|
|
847
|
+
const seen = new Map<string, number>();
|
|
848
|
+
const out: Ranked[] = [];
|
|
849
|
+
for (const r of ranked) {
|
|
850
|
+
if (out.length >= total) break;
|
|
851
|
+
const key = JSON.stringify(per.attributes.map((a) => attributeValue(r.doc, a)));
|
|
852
|
+
const n = seen.get(key) ?? 0;
|
|
853
|
+
if (n >= per.limit) continue;
|
|
854
|
+
seen.set(key, n + 1);
|
|
855
|
+
out.push(r);
|
|
856
|
+
}
|
|
857
|
+
return out;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** "Max aggregation groups per query: 10k" (https://turbopuffer.com/docs/limits). */
|
|
861
|
+
const MAX_GROUPS = 10_000;
|
|
862
|
+
|
|
863
|
+
/** `group_by` (https://turbopuffer.com/docs/query): "Groups documents by the specified attributes or expressions (the
|
|
864
|
+
* "group key") before computing aggregates. Aggregates are computed separately for each group. Up to limit.total
|
|
865
|
+
* groups are returned, ordered by group key." An entry is an attribute name, or `{label: ["ForEachUnique", attr]}`,
|
|
866
|
+
* which "creates a separate group for each unique element of the array". Answered as `aggregation_groups`: "one for
|
|
867
|
+
* each aggregation group, containing the group key and the computed value of each requested aggregation".
|
|
868
|
+
* Where the docs stop and the twin decides: a document whose grouping attribute is absent is grouped under null, and
|
|
869
|
+
* null sorts first; with no limit every group is answered, up to the 10k limit. */
|
|
870
|
+
function groupAggregate(spec: unknown, groupBy: unknown, docs: Doc[], limit: number): Record<string, unknown>[] {
|
|
871
|
+
if (!Array.isArray(groupBy) || groupBy.length === 0) throw badRequest('invalid query: group_by must be a non-empty array');
|
|
872
|
+
const keys = groupBy.map((g) => {
|
|
873
|
+
if (typeof g === 'string') return { label: g, attr: g, each: false };
|
|
874
|
+
const entries = g !== null && typeof g === 'object' && !Array.isArray(g) ? Object.entries(g as Record<string, unknown>) : [];
|
|
875
|
+
const [label, expr] = entries[0] ?? [];
|
|
876
|
+
if (entries.length !== 1 || !Array.isArray(expr) || expr[0] !== 'ForEachUnique' || typeof expr[1] !== 'string') throw badRequest(`invalid group_by entry ${JSON.stringify(g)}: expected an attribute name or {label: ["ForEachUnique", attribute]}`);
|
|
877
|
+
return { label: label!, attr: expr[1], each: true };
|
|
878
|
+
});
|
|
879
|
+
const groups = new Map<string, { key: unknown[]; docs: Doc[] }>();
|
|
880
|
+
for (const d of docs) {
|
|
881
|
+
let tuples: unknown[][] = [[]];
|
|
882
|
+
for (const k of keys) {
|
|
883
|
+
const v = attributeValue(d, k.attr);
|
|
884
|
+
const values = k.each ? (Array.isArray(v) ? [...new Map(v.map((x) => [JSON.stringify(x), x])).values()] : []) : [v];
|
|
885
|
+
tuples = tuples.flatMap((t) => values.map((x) => [...t, x]));
|
|
886
|
+
}
|
|
887
|
+
for (const t of tuples) {
|
|
888
|
+
const id = JSON.stringify(t);
|
|
889
|
+
const g = groups.get(id) ?? { key: t, docs: [] };
|
|
890
|
+
g.docs.push(d);
|
|
891
|
+
groups.set(id, g);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// an array value (a group key of an array attribute, the Quickstart's `"category": ["fish"]`) orders element by element
|
|
895
|
+
const one = (x: unknown, y: unknown): number => (x === null && y === null ? 0 : x === null ? -1 : y === null ? 1 : Array.isArray(x) || Array.isArray(y) ? compareArrays(x, y) : compareScalars(x, y));
|
|
896
|
+
const order = (a: unknown[], b: unknown[]): number => a.map((x, i) => one(x, b[i])).find((c) => c !== 0 && !Number.isNaN(c)) ?? 0;
|
|
897
|
+
function compareArrays(x: unknown, y: unknown): number {
|
|
898
|
+
const xs = Array.isArray(x) ? x : [x]; const ys = Array.isArray(y) ? y : [y];
|
|
899
|
+
for (let i = 0; i < Math.min(xs.length, ys.length); i++) { const c = compareScalars(xs[i], ys[i]); if (c !== 0 && !Number.isNaN(c)) return c; }
|
|
900
|
+
return xs.length - ys.length;
|
|
901
|
+
}
|
|
902
|
+
return [...groups.values()].sort((a, b) => order(a.key, b.key)).slice(0, limit)
|
|
903
|
+
.map((g) => ({ ...Object.fromEntries(keys.map((k, i) => [k.label, g.key[i] ?? null])), ...aggregate(spec, g.docs) }));
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function aggregate(spec: unknown, docs: Doc[]): Record<string, unknown> {
|
|
907
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) throw badRequest('invalid query: aggregate_by must be an object');
|
|
908
|
+
const out: Record<string, unknown> = {};
|
|
909
|
+
for (const [label, fn] of Object.entries(spec as Record<string, unknown>)) {
|
|
910
|
+
if (!Array.isArray(fn) || fn.length === 0) throw badRequest(`invalid aggregate "${label}": expected ["Count"], ["Count", attr] or ["Sum", attr]`);
|
|
911
|
+
if (fn[0] === 'Count' && fn.length === 1) out[label] = docs.length;
|
|
912
|
+
else if (fn[0] === 'Count' && fn.length === 2 && typeof fn[1] === 'string') out[label] = docs.filter((d) => attributeValue(d, fn[1] as string) !== null).length;
|
|
913
|
+
else if (fn[0] === 'Sum' && fn.length === 2 && typeof fn[1] === 'string') {
|
|
914
|
+
let sum = 0;
|
|
915
|
+
// Summed in id order, so every root folds the same floats in the same order.
|
|
916
|
+
for (const d of [...docs].sort((a, b) => compareIds(a.id, b.id))) { const v = attributeValue(d, fn[1] as string); if (typeof v === 'number') sum += v; }
|
|
917
|
+
out[label] = sum;
|
|
918
|
+
} else throw badRequest(`invalid aggregate "${label}": expected ["Count"], ["Count", attr] or ["Sum", attr]`);
|
|
919
|
+
}
|
|
920
|
+
return out;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function compareIds(a: string | number, b: string | number): number {
|
|
924
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
925
|
+
if (typeof a === 'number') return -1;
|
|
926
|
+
if (typeof b === 'number') return 1;
|
|
927
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function rank(ns: NamespaceState, rankBy: unknown, docs: Doc[], ctx: FilterContext, metric: unknown): Ranked[] {
|
|
931
|
+
if (!Array.isArray(rankBy) || rankBy.length === 0) throw badRequest(`invalid rank_by: ${JSON.stringify(rankBy)}`);
|
|
932
|
+
// [[attr, 'asc'|'desc'], ...] — multi-attribute order.
|
|
933
|
+
if (Array.isArray(rankBy[0])) return orderBy(rankBy as unknown[], docs);
|
|
934
|
+
if (rankBy.length === 2 && (rankBy[1] === 'asc' || rankBy[1] === 'desc')) return orderBy([rankBy], docs);
|
|
935
|
+
if (rankBy[1] === 'ANN' || rankBy[1] === 'kNN') return nearest(ns, rankBy, docs, metric);
|
|
936
|
+
// Text ranking: BM25 clauses and their Sum / Max / Product combinators.
|
|
937
|
+
const corpora = new Map<string, Corpus>();
|
|
938
|
+
const scorer = textScorer(ns, rankBy, ctx, corpora);
|
|
939
|
+
const out: Ranked[] = [];
|
|
940
|
+
for (const d of docs) {
|
|
941
|
+
const s = scorer(d);
|
|
942
|
+
if (s !== 0) out.push({ doc: d, dist: s }); // "Documents with a score of zero are excluded from results"
|
|
943
|
+
}
|
|
944
|
+
return out.sort((a, b) => (b.dist! - a.dist!) || compareIds(a.doc.id, b.doc.id));
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function textScorer(ns: NamespaceState, clause: unknown, ctx: FilterContext, corpora: Map<string, Corpus>): (d: Doc) => number {
|
|
948
|
+
if (!Array.isArray(clause) || clause.length < 2) throw badRequest(`invalid rank_by clause: ${JSON.stringify(clause)}`);
|
|
949
|
+
if (clause[0] === 'Sum' || clause[0] === 'Max') {
|
|
950
|
+
if (!Array.isArray(clause[1]) || clause[1].length === 0) throw badRequest(`invalid rank_by: ${clause[0]} takes a non-empty array of clauses`);
|
|
951
|
+
const parts = (clause[1] as unknown[]).map((c) => textScorer(ns, c, ctx, corpora));
|
|
952
|
+
return clause[0] === 'Sum' ? (d) => parts.reduce((n, p) => n + p(d), 0) : (d) => Math.max(...parts.map((p) => p(d)));
|
|
953
|
+
}
|
|
954
|
+
if (clause[0] === 'Product') {
|
|
955
|
+
const [w, inner] = typeof clause[1] === 'number' ? [clause[1], clause[2]] : [clause[2], clause[1]];
|
|
956
|
+
if (typeof w !== 'number') throw badRequest('invalid rank_by: Product takes a number and a clause');
|
|
957
|
+
const part = textScorer(ns, inner, ctx, corpora);
|
|
958
|
+
return (d) => w * part(d);
|
|
959
|
+
}
|
|
960
|
+
// SparseKNN (https://turbopuffer.com/docs/query): "rank by the distance between a sparse vector attribute and a sparse
|
|
961
|
+
// vector query", with the attribute's `dot_product` metric, and "compatible with FTS operators: Sum, Max". A larger dot
|
|
962
|
+
// product ranks higher, and "Documents with a score of zero are excluded from results".
|
|
963
|
+
if (clause[1] === 'SparseKNN') {
|
|
964
|
+
const [attr, , q] = clause;
|
|
965
|
+
const cfg = typeof attr === 'string' ? ns.schema[attr] : undefined;
|
|
966
|
+
if (cfg === undefined || cfg.type !== '{}f16' || cfg.sparse_knn === undefined) throw badRequest(`invalid rank_by: SparseKNN needs a {}f16 attribute with sparse_knn configured, not ${JSON.stringify(attr)}`);
|
|
967
|
+
if (q === null || typeof q !== 'object' || Array.isArray(q) || !Object.values(q).every((x) => typeof x === 'number')) throw badRequest('invalid rank_by: the SparseKNN query must be an object of numeric weights');
|
|
968
|
+
const query = Object.entries(q as Record<string, number>);
|
|
969
|
+
return (d) => {
|
|
970
|
+
const v = d.attributes[attr as string] as Record<string, number> | undefined;
|
|
971
|
+
return v ? query.reduce((n, [k, w]) => n + w * (v[k] ?? 0), 0) : 0;
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
if (clause[0] === 'Saturate' || clause[0] === 'Decay' || clause[0] === 'Dist' || clause[0] === 'Attribute') throw gap(`the ${clause[0]} rank_by expression`, 'turbopuffer.query.rank_expressions');
|
|
975
|
+
// "Rank by filter: Filters can be used inside rank_by expressions to conditionally boost documents matching certain
|
|
976
|
+
// criteria. Documents that pass the filter get a score of 1, and are otherwise scored 0" (https://turbopuffer.com/docs/query)
|
|
977
|
+
if (clause[0] === 'And' || clause[0] === 'Or' || clause[0] === 'Not' || (typeof clause[1] === 'string' && !['BM25', 'asc', 'desc', 'ANN', 'kNN'].includes(clause[1]))) {
|
|
978
|
+
const pass = compileFilter(clause, ctx);
|
|
979
|
+
return (d) => (pass(d) ? 1 : 0);
|
|
980
|
+
}
|
|
981
|
+
if (clause[1] !== 'BM25') throw rankClauseRefusal(clause);
|
|
982
|
+
const attr = clause[0];
|
|
983
|
+
if (typeof attr !== 'string') throw badRequest('invalid rank_by: BM25 needs an attribute name');
|
|
984
|
+
const config = ns.schema[attr];
|
|
985
|
+
if (config === undefined || config.full_text_search === false) throw badRequest(`invalid rank_by: attribute "${attr}" does not have full-text search enabled`);
|
|
986
|
+
const fts = config.full_text_search;
|
|
987
|
+
assertModelledFts(attr, fts);
|
|
988
|
+
const query = clause[2];
|
|
989
|
+
if (typeof query !== 'string' && !(Array.isArray(query) && query.every((x) => typeof x === 'string'))) throw badRequest('invalid rank_by: the BM25 query must be a string or an array of strings');
|
|
990
|
+
const params = (clause[3] ?? {}) as Record<string, unknown>;
|
|
991
|
+
if (typeof params !== 'object' || params === null || Array.isArray(params)) throw badRequest('invalid rank_by: BM25 params must be an object');
|
|
992
|
+
for (const k of Object.keys(params)) if (k !== 'last_as_prefix') throw badRequest(`invalid rank_by: unknown BM25 param "${k}"`);
|
|
993
|
+
const terms = queryTerms(query, fts, params.last_as_prefix === true);
|
|
994
|
+
let corpus = corpora.get(attr);
|
|
995
|
+
if (corpus === undefined) {
|
|
996
|
+
// IDF and average length are over the WHOLE namespace, not the filtered subset.
|
|
997
|
+
corpus = buildCorpus([...ns.docs.values()].map((d) => [d.key, attributeValue(d, attr)] as [string, unknown]), fts);
|
|
998
|
+
corpora.set(attr, corpus);
|
|
999
|
+
}
|
|
1000
|
+
const c = corpus;
|
|
1001
|
+
// Keep the shared token cache warm for any token filter in the same query.
|
|
1002
|
+
return (d) => { docTokens(ctx, attr, fts, d); return bm25Score(d.key, c, terms, fts); };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** A rank_by clause that is none of the kinds modelled: an attribute order inside a text expression (the Query page's
|
|
1006
|
+
* "Rank by attribute" uses the `Attribute` operator, not modelled), or no rank_by at all. */
|
|
1007
|
+
function rankClauseRefusal(clause: unknown[]): TurbopufferError {
|
|
1008
|
+
return clause[1] === 'asc' || clause[1] === 'desc' ? gap('an attribute order inside a text rank expression', 'turbopuffer.query.rank_expressions') : badRequest(`invalid rank_by clause: ${JSON.stringify(clause)}`);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function nearest(ns: NamespaceState, rankBy: unknown[], docs: Doc[], metric: unknown): Ranked[] {
|
|
1012
|
+
const [attrRaw, op, targetRaw] = rankBy;
|
|
1013
|
+
let attr = attrRaw;
|
|
1014
|
+
let target = targetRaw;
|
|
1015
|
+
// ["Embed", text, {model?}]: the text embedded as the query vector (native embedding, above)
|
|
1016
|
+
if (Array.isArray(targetRaw) && targetRaw[0] === 'Embed') {
|
|
1017
|
+
const [, text, params] = targetRaw as [unknown, unknown, Record<string, unknown> | undefined];
|
|
1018
|
+
if (typeof text !== 'string') throw badRequest('invalid rank_by: Embed takes the text to embed');
|
|
1019
|
+
const source = typeof attr === 'string' ? ns.schema[attr] : undefined;
|
|
1020
|
+
const model = params?.model !== undefined ? checkModel(params.model) : source?.embed?.model;
|
|
1021
|
+
if (model === undefined) throw badRequest(`invalid rank_by: Embed on ${JSON.stringify(attr)} needs a model: the attribute has no embed configured`);
|
|
1022
|
+
if (source?.embed) attr = source.embed.attribute;
|
|
1023
|
+
const col = attr === 'vector' ? (ns.vector_dims !== null ? `[${ns.vector_dims}]f32` : undefined) : typeof attr === 'string' ? ns.schema[attr]?.type : undefined;
|
|
1024
|
+
if (col === undefined || !isVectorType(col)) throw badRequest(`invalid rank_by: attribute ${JSON.stringify(attr)} is not a vector attribute`);
|
|
1025
|
+
target = placeholderEmbedding(model, text, dimsOf(col));
|
|
1026
|
+
}
|
|
1027
|
+
const column = typeof attr === 'string' ? ns.schema[attr] : undefined;
|
|
1028
|
+
if (column !== undefined && isVectorArrayType(column.type)) return lateInteraction(ns, attr as string, column, op, target, docs, metric);
|
|
1029
|
+
if (typeof attr !== 'string' || (attr !== 'vector' && (column === undefined || !isVectorType(column.type)))) throw badRequest(`invalid rank_by: attribute ${JSON.stringify(attr)} is not a vector attribute`);
|
|
1030
|
+
const dims = attr === 'vector' ? ns.vector_dims : dimsOf(column!.type);
|
|
1031
|
+
const valueOf = (d: Doc): number[] | null => (attr === 'vector' ? d.vector : Array.isArray(d.attributes[attr]) ? d.attributes[attr] as number[] : null);
|
|
1032
|
+
if (!Array.isArray(target) || !target.every((x) => typeof x === 'number')) throw badRequest('invalid rank_by: the ANN target must be a vector of numbers or ["Embed", text]');
|
|
1033
|
+
const m = metric ?? ns.distance_metric;
|
|
1034
|
+
if (m !== 'cosine_distance' && m !== 'euclidean_squared') throw badRequest('invalid query: the namespace has no distance_metric; write vectors with one first');
|
|
1035
|
+
if (dims !== null && target.length !== dims) throw badRequest(`invalid query: the query vector has ${target.length} dimensions, but "${attr}" has ${dims}`);
|
|
1036
|
+
const q = target as number[];
|
|
1037
|
+
const out: Ranked[] = [];
|
|
1038
|
+
for (const d of docs) {
|
|
1039
|
+
const v = valueOf(d);
|
|
1040
|
+
if (v === null || v.length !== q.length) continue;
|
|
1041
|
+
out.push({ doc: d, dist: m === 'cosine_distance' ? cosineDistance(q, v) : euclideanSquared(q, v) });
|
|
1042
|
+
}
|
|
1043
|
+
return out.sort((a, b) => (a.dist! - b.dist!) || compareIds(a.doc.id, b.doc.id));
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** Late interaction (https://turbopuffer.com/docs/query): "Pass a query as an array of vectors with `ANN` to rank
|
|
1047
|
+
* documents with late interaction. … Each document's `$dist` is the sum of the distance from each query vector to its
|
|
1048
|
+
* closest vector in the document (lower is better), and results are ordered ascending"; `kNN` "for situations that
|
|
1049
|
+
* require perfect recall"; ANN needs the attribute's late-interaction index. The distance is the namespace's metric.
|
|
1050
|
+
* Where the docs stop and the twin decides: the feature "is in private beta. Contact us to enable late interaction for
|
|
1051
|
+
* your organization", and the twin enables it for every organization; ANN and kNN rank exactly alike here. */
|
|
1052
|
+
function lateInteraction(ns: NamespaceState, attr: string, column: AttrConfig, op: unknown, target: unknown, docs: Doc[], metric: unknown): Ranked[] {
|
|
1053
|
+
if (op === 'ANN' && !column.late_interaction) throw badRequest(`invalid rank_by: attribute "${attr}" was stored with ann: false, so it takes kNN, not ANN`);
|
|
1054
|
+
const dims = dimsOf(column.type.slice(2));
|
|
1055
|
+
if (!Array.isArray(target) || target.length === 0 || !target.every((q) => Array.isArray(q) && q.length === dims && q.every((x) => typeof x === 'number'))) throw badRequest(`invalid rank_by: a late-interaction query is a non-empty array of ${dims}-dimensional vectors`);
|
|
1056
|
+
const m = metric ?? ns.distance_metric;
|
|
1057
|
+
if (m !== 'cosine_distance' && m !== 'euclidean_squared') throw badRequest('invalid query: the namespace has no distance_metric; write vectors with one first');
|
|
1058
|
+
const dist = m === 'cosine_distance' ? cosineDistance : euclideanSquared;
|
|
1059
|
+
const out: Ranked[] = [];
|
|
1060
|
+
for (const d of docs) {
|
|
1061
|
+
const vs = d.attributes[attr];
|
|
1062
|
+
if (!Array.isArray(vs) || vs.length === 0) continue;
|
|
1063
|
+
const score = (target as number[][]).reduce((n, q) => n + Math.min(...(vs as number[][]).map((v) => dist(q, v))), 0);
|
|
1064
|
+
out.push({ doc: d, dist: score });
|
|
1065
|
+
}
|
|
1066
|
+
return out.sort((a, b) => (a.dist! - b.dist!) || compareIds(a.doc.id, b.doc.id));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
export function cosineDistance(a: readonly number[], b: readonly number[]): number {
|
|
1070
|
+
let dot = 0; let na = 0; let nb = 0;
|
|
1071
|
+
for (let i = 0; i < a.length; i++) { dot += a[i]! * b[i]!; na += a[i]! * a[i]!; nb += b[i]! * b[i]!; }
|
|
1072
|
+
const denom = Math.sqrt(na) * Math.sqrt(nb);
|
|
1073
|
+
// turbopuffer's vectors are f32, so their similarity is taken at f32 precision: two equal vectors are distance 0, as
|
|
1074
|
+
// the Query page's vector example answers (`{ "$dist": 0.0, "id": 1 }`), not a double's rounding residue
|
|
1075
|
+
return denom === 0 ? 1 : 1 - Math.fround(dot / denom);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
export function euclideanSquared(a: readonly number[], b: readonly number[]): number {
|
|
1079
|
+
let s = 0;
|
|
1080
|
+
for (let i = 0; i < a.length; i++) { const x = a[i]! - b[i]!; s += x * x; }
|
|
1081
|
+
return s;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function orderBy(orders: unknown[], docs: Doc[]): Ranked[] {
|
|
1085
|
+
const keys = orders.map((o) => {
|
|
1086
|
+
if (!Array.isArray(o) || o.length !== 2 || typeof o[0] !== 'string' || (o[1] !== 'asc' && o[1] !== 'desc')) throw badRequest(`invalid rank_by: expected [attribute, "asc" | "desc"], got ${JSON.stringify(o)}`);
|
|
1087
|
+
return { attr: o[0], dir: o[1] === 'asc' ? 1 : -1 };
|
|
1088
|
+
});
|
|
1089
|
+
// absent values sort last, in either direction; documents equal on every key order by id
|
|
1090
|
+
const one = (a: Doc, b: Doc, attr: string, dir: number): number => {
|
|
1091
|
+
const va = attributeValue(a, attr);
|
|
1092
|
+
const vb = attributeValue(b, attr);
|
|
1093
|
+
return va === null && vb === null ? 0 : va === null ? 1 : vb === null ? -1 : (attr === 'id' ? compareIds(va as string | number, vb as string | number) : compareScalars(va, vb)) * dir;
|
|
1094
|
+
};
|
|
1095
|
+
return docs.map((doc) => ({ doc })).sort((a, b) => keys.map(({ attr, dir }) => one(a.doc, b.doc, attr, dir)).find((c) => c !== 0 && !Number.isNaN(c)) ?? compareIds(a.doc.id, b.doc.id));
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function projectRow(r: Ranked, include: unknown, exclude: unknown): Record<string, unknown> {
|
|
1099
|
+
const row: Record<string, unknown> = { id: r.doc.id };
|
|
1100
|
+
if (r.dist !== undefined) row.$dist = r.dist;
|
|
1101
|
+
let names: string[] = [];
|
|
1102
|
+
const all = [...Object.keys(r.doc.attributes), ...(r.doc.vector !== null ? ['vector'] : [])];
|
|
1103
|
+
if (exclude !== undefined) {
|
|
1104
|
+
if (!Array.isArray(exclude) || !exclude.every((x) => typeof x === 'string')) throw badRequest('invalid query: exclude_attributes must be an array of strings');
|
|
1105
|
+
if (include !== undefined && include !== false) throw badRequest('invalid query: include_attributes and exclude_attributes cannot both be set');
|
|
1106
|
+
names = all.filter((n) => !exclude.includes(n));
|
|
1107
|
+
} else if (include === true) names = all;
|
|
1108
|
+
else if (Array.isArray(include)) {
|
|
1109
|
+
if (!include.every((x) => typeof x === 'string')) throw badRequest('invalid query: include_attributes must be a boolean or an array of strings');
|
|
1110
|
+
names = include.filter((n) => n !== 'id');
|
|
1111
|
+
} else if (include !== undefined && include !== false) throw badRequest('invalid query: include_attributes must be a boolean or an array of strings');
|
|
1112
|
+
for (const n of names) {
|
|
1113
|
+
if (n === 'vector') { if (r.doc.vector !== null) row.vector = r.doc.vector; continue; }
|
|
1114
|
+
const v = r.doc.attributes[n];
|
|
1115
|
+
row[n] = v === undefined ? null : v;
|
|
1116
|
+
}
|
|
1117
|
+
return row;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** The wire form of a namespace's schema: every attribute with its full resolved config. */
|
|
1121
|
+
export function schemaWire(ns: NamespaceState): Record<string, unknown> {
|
|
1122
|
+
const out: Record<string, unknown> = {};
|
|
1123
|
+
for (const [attr, c] of Object.entries(ns.schema)) {
|
|
1124
|
+
if (attr === 'id') { out.id = { type: c.type }; continue; }
|
|
1125
|
+
out[attr] = {
|
|
1126
|
+
type: c.type,
|
|
1127
|
+
...(isVectorType(c.type) ? { ann: { distance_metric: ns.distance_metric } } : { filterable: c.filterable }),
|
|
1128
|
+
...(c.full_text_search !== false ? { full_text_search: c.full_text_search } : {}),
|
|
1129
|
+
...(c.glob ? { glob: true } : {}),
|
|
1130
|
+
...(c.regex ? { regex: true } : {}),
|
|
1131
|
+
...(c.fuzzy ? { fuzzy: true } : {}),
|
|
1132
|
+
...(c.sparse_knn ? { sparse_knn: c.sparse_knn } : {}),
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
return out;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
export function queryBilling(ns: NamespaceState, returned: unknown): Record<string, number> {
|
|
1139
|
+
return {
|
|
1140
|
+
billable_logical_bytes_queried: approxLogicalBytes(ns),
|
|
1141
|
+
billable_logical_bytes_returned: utf8Length(JSON.stringify(returned ?? null)),
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
export function queryPerformance(ns: NamespaceState): Record<string, unknown> {
|
|
1146
|
+
return {
|
|
1147
|
+
// "last_included_write_at (string): the timestamp of the last write operation that the query observed"
|
|
1148
|
+
// (https://turbopuffer.com/docs/query, Response); the twin's queries observe every write
|
|
1149
|
+
last_included_write_at: ns.last_write_at,
|
|
1150
|
+
approx_namespace_size: ns.docs.size,
|
|
1151
|
+
cache_hit_ratio: 1,
|
|
1152
|
+
cache_temperature: 'hot',
|
|
1153
|
+
exhaustive_search_count: 0,
|
|
1154
|
+
query_execution_ms: 0,
|
|
1155
|
+
server_total_ms: 0,
|
|
1156
|
+
};
|
|
1157
|
+
}
|