@pithy-sh/vector 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 +21 -0
- package/README.md +17 -0
- package/package.json +53 -0
- package/pithy.manifest.json +47 -0
- package/src/capability.ts +120 -0
- package/src/cloudflare-test.d.ts +12 -0
- package/src/config/config.ts +181 -0
- package/src/config/workerConfig.ts +75 -0
- package/src/data/document.ts +77 -0
- package/src/data/documents.ts +168 -0
- package/src/data/tables.ts +31 -0
- package/src/embed/embed.ts +126 -0
- package/src/error/errors.ts +146 -0
- package/src/http/guard.ts +27 -0
- package/src/http/handlers.ts +206 -0
- package/src/http/provisionGuard.ts +39 -0
- package/src/http/routes.ts +162 -0
- package/src/http/schemas.ts +158 -0
- package/src/index/drift.ts +119 -0
- package/src/index/filter.ts +278 -0
- package/src/index/index.ts +244 -0
- package/src/index/limits.ts +89 -0
- package/src/index/metadata.ts +160 -0
- package/src/index/provisioned.ts +183 -0
- package/src/index.ts +30 -0
- package/src/migrations/0001_documents.ts +63 -0
- package/src/provision/provisionVector.ts +250 -0
- package/src/provision/resolveVectorConfig.ts +85 -0
- package/src/seeds/example.ts +79 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/reprocess.ts +180 -0
- package/src/workflows/retryPolicy.ts +54 -0
- package/src/workflows/specs.ts +71 -0
- package/src/workflows/worker.ts +130 -0
- package/src/workflows/wrangler.jsonc +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pithy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @pithy-sh/vector
|
|
2
|
+
|
|
3
|
+
Semantic search over your own content, in your own Cloudflare account. Workers AI embeds, Vectorize indexes, D1 keeps the text.
|
|
4
|
+
|
|
5
|
+
The interesting part of this package is not the search. It is the metadata, which Vectorize makes a provisioning-time decision wearing the costume of a query-time one — and gives you no error when you get it wrong.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pithy add vector
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Documentation: [pithy.sh/docs/capabilities/vector](https://pithy.sh/docs/capabilities/vector).** Overview, adding it, using it, and the reference: the embedding models, metadata indexes, provisioning, the reprocess worker.
|
|
12
|
+
|
|
13
|
+
_Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT — adopter-side app value. The root `LICENSE` covers it.
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pithy-sh/vector",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/pithy-sh/pithy.git",
|
|
8
|
+
"directory": "packages/vector"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"pithy.manifest.json",
|
|
13
|
+
"!src/**/*.test.*"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
"./src/*": "./src/*.ts"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json --noEmit false --outDir dist",
|
|
24
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:node": "vitest run --project=node",
|
|
27
|
+
"test:workers": "vitest run --project=workers",
|
|
28
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
29
|
+
"clean": "rm -rf dist .turbo",
|
|
30
|
+
"reset": "bun run clean && rm -rf node_modules"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@cloudflare/workers-types": "^5.20260729.1",
|
|
34
|
+
"@hono/zod-validator": "^0.9.0",
|
|
35
|
+
"@pithy-sh/cloudflare": "workspace:*",
|
|
36
|
+
"@pithy-sh/core": "workspace:*",
|
|
37
|
+
"@pithy-sh/secrets": "workspace:*",
|
|
38
|
+
"hono": "^4.13.2",
|
|
39
|
+
"kysely": "^0.29.0",
|
|
40
|
+
"zod": "^4.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@cloudflare/vitest-plugin": "^1.0.0",
|
|
44
|
+
"@pithy-sh/tsconfig": "workspace:*",
|
|
45
|
+
"@types/node": "^22.15.0",
|
|
46
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
47
|
+
"comment-json": "^5.0.0",
|
|
48
|
+
"kysely-d1": "^0.4.0",
|
|
49
|
+
"typescript": "^7.0.2",
|
|
50
|
+
"vitest": "^4.1.0",
|
|
51
|
+
"wrangler": "^4.115.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vector",
|
|
3
|
+
"package": "@pithy-sh/vector",
|
|
4
|
+
"requiredBindings": [
|
|
5
|
+
{
|
|
6
|
+
"type": "vectorize",
|
|
7
|
+
"name": "VECTORIZE",
|
|
8
|
+
"remote": true
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"type": "ai",
|
|
12
|
+
"name": "AI",
|
|
13
|
+
"remote": true
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"type": "d1",
|
|
17
|
+
"name": "DB"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"type": "workflow",
|
|
21
|
+
"name": "VECTOR_REPROCESS",
|
|
22
|
+
"job": "reprocess",
|
|
23
|
+
"className": "VectorReprocessWorkflow",
|
|
24
|
+
"optional": true
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"peerCapabilities": [],
|
|
28
|
+
"optionalCapabilities": ["auth"],
|
|
29
|
+
"migrationNamespace": "vector",
|
|
30
|
+
"whenToEnable": "Semantic search over your own content — ask in a sentence, get the passages that mean the same thing. Embeddings come from Workers AI and live in your own Vectorize index; the source text lives in your D1, because Vectorize returns ids and scores, not content, and caps metadata at 10 KiB. What this adds over calling the binding yourself is the part that bites: an index's filterable metadata is declared in Zod, so one declaration provisions the metadata indexes, types the filter builder, and is reconciled against the live index every time you run `pithy vector provision`. Vectorize does not error when you filter on a field it indexed late — it silently drops every vector written before the index existed, and you get a short result set that looks fine. So `provision` records what it observed, and the Worker refuses to boot when the config declares a filterable field that record does not have — edit a metadata schema, deploy without re-provisioning, and you get an error naming the field instead of quietly wrong search. A filter naming a field the schema does not mark filterable is refused outright rather than run. The model is pinned per index and used for writes and queries alike, so an index cannot be built in one embedding space and searched in another. Routes scope to the caller, so add auth too.",
|
|
31
|
+
"scaffold": [
|
|
32
|
+
"Add a `vector({ indexes: { ... } })` block to pithy.config.ts and declare at least one index: the Workers AI `model` that fills it, the `dimensions` that model produces, and a `metric` (`cosine` for text).",
|
|
33
|
+
"Give each index a `metadata` Zod object describing what its vectors carry, and mark the fields you will filter on with `filterable(...)` beside their `.describe()`. Vectorize allows ten metadata indexes per index, and a field indexed after vectors are written does not apply to them — decide this before you ingest.",
|
|
34
|
+
"Bind `VECTORIZE` (Vectorize) and `AI` (Workers AI) in each wrangler.jsonc env, plus the `DB` D1 binding your other capabilities use. Both are marked remote: neither has a local emulation, so `wrangler dev` reaches the real resource. A second index names its own `binding` — one Vectorize binding addresses exactly one index.",
|
|
35
|
+
"Run `pithy vector provision --env <env>` to create the index, its metadata indexes, and the prebuilt reprocess worker. It waits for each metadata index to become live: Vectorize applies them asynchronously, and a vector written before one exists is never covered by it. It then writes what it observed into wrangler.jsonc as the `VECTOR_PROVISIONED` var for that environment — commit it. The Worker checks its declared filterable fields against that record at boot and refuses to serve on a mismatch, so re-run provision whenever you change a metadata schema.",
|
|
36
|
+
"Run `pithy migrate` to create pithy_vector_documents — the durable corpus results hydrate from and re-embeds read out of.",
|
|
37
|
+
"Add `@pithy-sh/auth` if it is not already installed. Search routes scope to the authenticated caller; without auth every route is denied.",
|
|
38
|
+
"Changed a model, or added a filterable field after ingest? Run `pithy vector reprocess` — it re-embeds the rows whose model differs from config, and `--all` re-embeds the corpus. A late metadata index needs `pithy vector reset`, which rebuilds the index from that corpus."
|
|
39
|
+
],
|
|
40
|
+
"configOptions": [
|
|
41
|
+
{
|
|
42
|
+
"key": "defaultTopK",
|
|
43
|
+
"default": 10,
|
|
44
|
+
"describe": "How many matches a query returns when the caller names none. A search page, not a scan — Vectorize's own ceiling is 50 per query when values or metadata come back, 100 when neither does. There is no `indexes` block above: `pithy add` can only seed scalar options, so add one by hand before any search works. Each index names its model, its dimensions, its metric, and the Zod object describing its metadata — mark a field `.meta({ filterable: true })` to make it filterable, then run `pithy vector provision`. Until an index is declared the routes answer `vector/index_not_found`."
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { BindingSpecInput } from "@pithy-sh/core/src/capability/bindings";
|
|
5
|
+
import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import type { DatabaseSpecMap } from "@pithy-sh/core/src/data/databases";
|
|
7
|
+
import type { KvNamespaceSpecMap } from "@pithy-sh/core/src/kv/namespaces";
|
|
8
|
+
import { workflowBindings } from "@pithy-sh/core/src/workflow/bindings";
|
|
9
|
+
import type { Migration } from "kysely/migration";
|
|
10
|
+
import { VectorConfig, type VectorConfigInput } from "./config/config";
|
|
11
|
+
import { vectorTables } from "./data/tables";
|
|
12
|
+
import { provisionGuard } from "./http/provisionGuard";
|
|
13
|
+
import { registerVectorRoutes } from "./http/routes";
|
|
14
|
+
import { vector_0001_documents } from "./migrations/0001_documents";
|
|
15
|
+
import { vectorExampleSeed } from "./seeds/example";
|
|
16
|
+
import { PACKAGE_VERSION } from "./version.generated";
|
|
17
|
+
import { vectorWorkflows } from "./workflows/specs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where vector's migrations sort in the app database. Unique per database; the registry composes keys like
|
|
21
|
+
* `0900_vector_0001_documents`. Sits after storage (800).
|
|
22
|
+
*/
|
|
23
|
+
export const VECTOR_MIGRATION_ORDER = 900;
|
|
24
|
+
|
|
25
|
+
/** The options `pithy add vector` writes into `pithy.config.ts`: the config, plus where the routes mount. */
|
|
26
|
+
export type VectorOptions = VectorConfigInput & {
|
|
27
|
+
/** The path the routes mount under. Defaults to `/vector`. */
|
|
28
|
+
basePath?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The vector capability, with its resolved config attached. The workflow slice is kept literal so a composed
|
|
33
|
+
* project types `c.var.workflows.trigger("vector/reprocess", …)` precisely — an unregistered key or a
|
|
34
|
+
* mistyped payload is a compile error, not a 500.
|
|
35
|
+
*/
|
|
36
|
+
export interface VectorCapability
|
|
37
|
+
extends Capability<DatabaseSpecMap, KvNamespaceSpecMap, "vector", typeof vectorWorkflows> {
|
|
38
|
+
/** The resolved vector config. */
|
|
39
|
+
vectorConfig: VectorConfig;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The vector capability: semantic search over the adopter's own Vectorize indexes and Workers AI embeddings.
|
|
44
|
+
*
|
|
45
|
+
* Fully optional. Config, migrations, and bindings arrive only on `pithy add vector`, and `pithy remove
|
|
46
|
+
* vector` is the clean inverse. The index is Vectorize and the embeddings are Workers AI, both in the
|
|
47
|
+
* adopter's account, reached through bindings — nothing routes through a Pithy-operated service.
|
|
48
|
+
*
|
|
49
|
+
* What this package adds over a thin wrapper is one thing: an index's filterable metadata is declared in Zod,
|
|
50
|
+
* beside the rest of its schema. That single declaration provisions the metadata indexes, types the filter
|
|
51
|
+
* builder, and is compared against the live index by `pithy vector provision` — because Vectorize does not
|
|
52
|
+
* error when you filter on a field it indexed late. It silently omits every vector written before the index
|
|
53
|
+
* existed, which is a bug with no error message and no obvious cause.
|
|
54
|
+
*
|
|
55
|
+
* That declaration is checked three times, each as early as it can be. `VectorConfig.parse` below refuses an
|
|
56
|
+
* unindexable filterable field, an eleventh one, and two indexes sharing a binding. `pithy vector provision`
|
|
57
|
+
* compares the declaration against the live index over the control plane, waits for each metadata index to
|
|
58
|
+
* become visible, and records what it saw. And {@link provisionGuard} re-checks that record at boot, so a
|
|
59
|
+
* schema edited and deployed without re-provisioning fails with `vector/metadata_index_drift` instead of
|
|
60
|
+
* quietly returning partial results. The boot check reads a var, never the network: an account API token has
|
|
61
|
+
* no business on the request path of a search.
|
|
62
|
+
*
|
|
63
|
+
* `dependsOn` is deliberately empty. Auth is a seam, not a peer: without `@pithy-sh/auth` the routes deny
|
|
64
|
+
* rather than open, which is the right failure and needs no dependency edge.
|
|
65
|
+
*
|
|
66
|
+
* The `VECTORIZE` and `AI` bindings are declared `remote` because neither has a local emulation. Without it
|
|
67
|
+
* `wrangler dev` binds nothing and every search fails locally for a reason that looks like a code fault.
|
|
68
|
+
*/
|
|
69
|
+
export function vector(options: VectorOptions = { indexes: {} }): VectorCapability {
|
|
70
|
+
const { basePath, ...configInput } = options;
|
|
71
|
+
// Parse the index set at assembly — an unindexable filterable field, an eleventh one, or a model/dimension
|
|
72
|
+
// mismatch fails on deploy, not on the first search.
|
|
73
|
+
const resolved = VectorConfig.parse(configInput);
|
|
74
|
+
|
|
75
|
+
const migrations: Record<string, Migration> = { "0001_documents": vector_0001_documents };
|
|
76
|
+
const requiredBindings: BindingSpecInput[] = [
|
|
77
|
+
// One Vectorize binding per configured index — a binding addresses exactly one index, so a project with
|
|
78
|
+
// two indexes needs two. Deduplicated in config order; a single-index project sees just `VECTORIZE`.
|
|
79
|
+
...[...new Set(Object.values(resolved.indexes).map((index) => index.binding))].map((name) => ({
|
|
80
|
+
type: "vectorize" as const,
|
|
81
|
+
name,
|
|
82
|
+
remote: true,
|
|
83
|
+
})),
|
|
84
|
+
{ type: "ai", name: "AI", remote: true },
|
|
85
|
+
{ type: "d1", name: "DB" },
|
|
86
|
+
// The reprocess Workflow's binding, derived from the spec rather than restated. Optional: it exists only
|
|
87
|
+
// once `pithy vector provision` has deployed the vector worker, and an unprovisioned project must still
|
|
88
|
+
// boot and serve every search route.
|
|
89
|
+
...workflowBindings(vectorWorkflows),
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const capability = defineCapability({
|
|
93
|
+
name: "vector",
|
|
94
|
+
// The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
|
|
95
|
+
// cannot read its own package.json. Reported per capability by the control-plane manifest.
|
|
96
|
+
version: PACKAGE_VERSION,
|
|
97
|
+
requiredBindings,
|
|
98
|
+
config: VectorConfig,
|
|
99
|
+
workflows: vectorWorkflows,
|
|
100
|
+
databases: {
|
|
101
|
+
app: {
|
|
102
|
+
binding: "DB",
|
|
103
|
+
tables: vectorTables(),
|
|
104
|
+
migrationOrder: VECTOR_MIGRATION_ORDER,
|
|
105
|
+
migrations,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
seeds: [vectorExampleSeed],
|
|
109
|
+
// The drift check runs as middleware because the record it reads is a var, and in Workers vars arrive
|
|
110
|
+
// per request — there is no env at assembly for a `compose` hook to read.
|
|
111
|
+
middleware: [provisionGuard(resolved)],
|
|
112
|
+
routes: registerVectorRoutes({ config: resolved, ...(basePath !== undefined ? { basePath } : {}) }),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return Object.assign(capability, { vectorConfig: resolved });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isVectorCapability(capability: Capability): capability is VectorCapability {
|
|
119
|
+
return capability.name === "vector" && "vectorConfig" in capability;
|
|
120
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/// <reference types="@cloudflare/vitest-plugin/types" />
|
|
5
|
+
|
|
6
|
+
// Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare config
|
|
7
|
+
// in `vitest.workers.config.ts`: the app `DB` database the `pithy_vector_*` tables live in.
|
|
8
|
+
declare namespace Cloudflare {
|
|
9
|
+
interface Env {
|
|
10
|
+
DB: D1Database;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { byteLength, DEFAULT_TOPK, MAX_DIMENSIONS, MAX_NAME_BYTES, MAX_TOPK_WITHOUT_PAYLOAD } from "../index/limits";
|
|
6
|
+
import { introspectMetadata } from "../index/metadata";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The vector capability's config — the thin, user-owned surface in `pithy.config.ts`. Every field is
|
|
10
|
+
* `.describe()`d: the descriptions feed the self-documenting CLI (CLAUDE.md §Config).
|
|
11
|
+
*
|
|
12
|
+
* An index is declared, not discovered. It names the embedding model that fills it, the vector shape that
|
|
13
|
+
* model produces, the metric to compare by, and — the part that matters — a Zod object describing the
|
|
14
|
+
* metadata its vectors carry, with the filterable fields marked. That one declaration drives four things
|
|
15
|
+
* that must never disagree: the metadata indexes `pithy vector provision` creates, the type of the filter
|
|
16
|
+
* builder, the drift check that same command runs against the live index, and the boot check the Worker runs
|
|
17
|
+
* against what that command recorded — which is how a schema edited and deployed without re-provisioning
|
|
18
|
+
* fails loudly instead of returning partial results.
|
|
19
|
+
*
|
|
20
|
+
* The model is pinned per index because an index built with one model and queried with another does not
|
|
21
|
+
* fail. It returns neighbors in a space the query vector does not live in — plausible results, quietly
|
|
22
|
+
* wrong. Dimensions and metric are fixed at index creation and cannot be changed afterwards, so they are
|
|
23
|
+
* validated here, where the fix is an edit rather than a migration.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** An index name is a path segment and a Cloudflare resource name, so it is lowercase, digits, and dashes. */
|
|
27
|
+
const INDEX_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
28
|
+
|
|
29
|
+
/** A wrangler binding name: uppercase, digits, underscores. The env key the Vectorize index arrives on. */
|
|
30
|
+
const BINDING_NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
|
31
|
+
|
|
32
|
+
/** The binding an index arrives on when it does not name its own. The single-index case, which is most of them. */
|
|
33
|
+
export const DEFAULT_VECTORIZE_BINDING = "VECTORIZE";
|
|
34
|
+
|
|
35
|
+
/** How nearest neighbors are scored. Fixed when the index is created — changing it means a new index. */
|
|
36
|
+
export const VectorMetric = z
|
|
37
|
+
.enum(["cosine", "euclidean", "dot-product"])
|
|
38
|
+
.describe(
|
|
39
|
+
"How this index scores nearest neighbors. `cosine` for text embeddings (the usual choice), `euclidean` for spatial distance, `dot-product` for magnitude-sensitive scoring. Fixed at index creation.",
|
|
40
|
+
);
|
|
41
|
+
export type VectorMetric = z.infer<typeof VectorMetric>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A metadata schema: a Zod object, not a value. It is held as a schema so this package can introspect it —
|
|
45
|
+
* one pass yields the metadata indexes to provision and the type of the filter builder.
|
|
46
|
+
*/
|
|
47
|
+
const MetadataObject = z
|
|
48
|
+
.custom<z.ZodObject>((value) => value instanceof z.ZodObject, {
|
|
49
|
+
message: "An index's `metadata` must be a Zod object schema — z.object({ ... }), not a value.",
|
|
50
|
+
})
|
|
51
|
+
.describe(
|
|
52
|
+
"The metadata every vector in this index carries, as a Zod object. Mark a field `.meta({ filterable: true })` — beside its `.describe()` — to make it filterable; each marked field becomes a Vectorize metadata index, and Vectorize allows ten per index.",
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
export const VectorIndexConfig = z
|
|
56
|
+
.object({
|
|
57
|
+
model: z
|
|
58
|
+
.string()
|
|
59
|
+
.min(1)
|
|
60
|
+
.describe(
|
|
61
|
+
"The Workers AI embedding model that fills this index — `@cf/baai/bge-base-en-v1.5` and friends. Pinned per index and applied to writes *and* queries: an index built with one model and queried with another returns plausible, quietly wrong results.",
|
|
62
|
+
),
|
|
63
|
+
dimensions: z
|
|
64
|
+
.number()
|
|
65
|
+
.int()
|
|
66
|
+
.min(1)
|
|
67
|
+
.max(MAX_DIMENSIONS)
|
|
68
|
+
.describe(
|
|
69
|
+
`How many components each vector carries. It must match the model's output — 768 for bge-base, 1024 for bge-large. Vectorize allows at most ${MAX_DIMENSIONS}, and the value is fixed at index creation.`,
|
|
70
|
+
),
|
|
71
|
+
metric: VectorMetric.default("cosine"),
|
|
72
|
+
binding: z
|
|
73
|
+
.string()
|
|
74
|
+
.regex(BINDING_NAME_PATTERN)
|
|
75
|
+
.default(DEFAULT_VECTORIZE_BINDING)
|
|
76
|
+
.describe(
|
|
77
|
+
"The wrangler binding this index arrives on. One Vectorize binding addresses exactly one index, so a project with two indexes names two bindings — leaving both on the default would silently point one index's writes at the other's vectors.",
|
|
78
|
+
),
|
|
79
|
+
metadata: MetadataObject.optional(),
|
|
80
|
+
namespace: z
|
|
81
|
+
.string()
|
|
82
|
+
.optional()
|
|
83
|
+
.describe(
|
|
84
|
+
"The namespace writes and queries use when a caller names none. Namespaces partition one index — a tenant, a locale, a corpus — and are cheaper than one index per partition.",
|
|
85
|
+
),
|
|
86
|
+
})
|
|
87
|
+
.describe("One vector index: the model that fills it, the shape it holds, and the metadata it can filter on.");
|
|
88
|
+
export type VectorIndexConfig = z.output<typeof VectorIndexConfig>;
|
|
89
|
+
|
|
90
|
+
export const VectorConfig = z
|
|
91
|
+
.object({
|
|
92
|
+
indexes: z
|
|
93
|
+
.record(z.string(), VectorIndexConfig)
|
|
94
|
+
.prefault({})
|
|
95
|
+
.describe(
|
|
96
|
+
"Every index this app searches, keyed by a short name used in routes and in `pithy vector provision`. Indexes are config, not database rows — their shape is fixed at creation, so they belong where a change is reviewable. Defaults to none: `pithy add vector` can only seed scalar options, so the capability has to compose before an index block is hand-written, or the generated `pithy.config.ts` would fail to load and take every other `pithy` command down with it. With no index configured, the routes answer `vector/index_not_found` and the drift check has nothing to compare — which is the honest state of a capability that is installed but not yet set up.",
|
|
97
|
+
),
|
|
98
|
+
defaultTopK: z
|
|
99
|
+
.number()
|
|
100
|
+
.int()
|
|
101
|
+
.min(1)
|
|
102
|
+
.max(MAX_TOPK_WITHOUT_PAYLOAD)
|
|
103
|
+
.default(DEFAULT_TOPK)
|
|
104
|
+
.describe(
|
|
105
|
+
"How many matches a query returns when the caller names no `topK`. A search page, not a scan. Vectorize's own ceiling is 50 per query when values or metadata come back, 100 when neither does.",
|
|
106
|
+
),
|
|
107
|
+
})
|
|
108
|
+
.describe("Configuration for the vector capability — the set of indexes this app searches.")
|
|
109
|
+
.check((ctx) => {
|
|
110
|
+
const names = Object.keys(ctx.value.indexes);
|
|
111
|
+
const bindings = new Map<string, string>();
|
|
112
|
+
|
|
113
|
+
// No index is a legal, if inert, state — deliberately, and it is the state `pithy add vector`
|
|
114
|
+
// leaves behind. `renderRegistration` can only seed scalar config options, so the generated
|
|
115
|
+
// `pithy.config.ts` reads `vector({ defaultTopK: 10 })` with no `indexes` block; rejecting that
|
|
116
|
+
// would make the file throw on load, and `loadProject` sits under every other `pithy` command —
|
|
117
|
+
// including the `pithy migrate` that `pithy add` runs immediately afterwards. The command would
|
|
118
|
+
// break the project it was asked to set up. An unconfigured capability that answers
|
|
119
|
+
// `vector/index_not_found` is the honest intermediate state; a config that cannot be loaded is not.
|
|
120
|
+
|
|
121
|
+
for (const name of names) {
|
|
122
|
+
if (!INDEX_NAME_PATTERN.test(name)) {
|
|
123
|
+
ctx.issues.push({
|
|
124
|
+
code: "custom",
|
|
125
|
+
input: ctx.value,
|
|
126
|
+
path: ["indexes", name],
|
|
127
|
+
message: `Index name \`${name}\` must be lowercase letters, digits, and dashes — it is a Cloudflare resource name and a path segment.`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (byteLength(name) > MAX_NAME_BYTES) {
|
|
131
|
+
ctx.issues.push({
|
|
132
|
+
code: "custom",
|
|
133
|
+
input: ctx.value,
|
|
134
|
+
path: ["indexes", name],
|
|
135
|
+
message: `Index name \`${name}\` is longer than ${MAX_NAME_BYTES} bytes, which Vectorize rejects.`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const index = ctx.value.indexes[name];
|
|
140
|
+
if (!index) continue;
|
|
141
|
+
|
|
142
|
+
// A Vectorize binding addresses one index. Two indexes sharing a binding is not a warning case:
|
|
143
|
+
// every write to one would land in the other, and nothing would error.
|
|
144
|
+
const shared = bindings.get(index.binding);
|
|
145
|
+
if (shared !== undefined) {
|
|
146
|
+
ctx.issues.push({
|
|
147
|
+
code: "custom",
|
|
148
|
+
input: ctx.value,
|
|
149
|
+
path: ["indexes", name, "binding"],
|
|
150
|
+
message: `Indexes \`${shared}\` and \`${name}\` both bind \`${index.binding}\`. One binding addresses one index — give each index its own.`,
|
|
151
|
+
});
|
|
152
|
+
} else {
|
|
153
|
+
bindings.set(index.binding, name);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (index.namespace !== undefined && byteLength(index.namespace) > MAX_NAME_BYTES) {
|
|
157
|
+
ctx.issues.push({
|
|
158
|
+
code: "custom",
|
|
159
|
+
input: ctx.value,
|
|
160
|
+
path: ["indexes", name, "namespace"],
|
|
161
|
+
message: `Namespace \`${index.namespace}\` is longer than ${MAX_NAME_BYTES} bytes, which Vectorize rejects.`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Introspect here, at parse time, so an unindexable field or an eleventh filterable one fails on deploy
|
|
166
|
+
// — not at provisioning time, where the first ten indexes already exist and the eleventh is silently
|
|
167
|
+
// absent from every filter that names it.
|
|
168
|
+
if (index.metadata) {
|
|
169
|
+
for (const problem of introspectMetadata(index.metadata).problems) {
|
|
170
|
+
ctx.issues.push({ code: "custom", input: ctx.value, path: ["indexes", name, "metadata"], message: problem });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
export type VectorConfig = z.output<typeof VectorConfig>;
|
|
176
|
+
export type VectorConfigInput = z.input<typeof VectorConfig>;
|
|
177
|
+
|
|
178
|
+
/** The index with this name, or undefined. Names come from config, so an unknown one is a 404. */
|
|
179
|
+
export function resolveIndex(config: VectorConfig, name: string): VectorIndexConfig | undefined {
|
|
180
|
+
return config.indexes[name];
|
|
181
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { MetadataIndexDescriptor, metadataIndexes } from "../index/metadata";
|
|
6
|
+
import type { VectorConfig } from "./config";
|
|
7
|
+
import { VectorMetric } from "./config";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The vector config as the prebuilt reprocess worker receives it — a **serializable projection**, not the
|
|
11
|
+
* config itself.
|
|
12
|
+
*
|
|
13
|
+
* `VectorConfig.indexes[x].metadata` holds a live `z.ZodObject`. A Zod schema is not JSON, so the media
|
|
14
|
+
* pattern of stringifying the whole config into a `vars` entry cannot be copied here: the metadata field
|
|
15
|
+
* would arrive as an unusable husk and every filter the worker compiled against it would be wrong. So the
|
|
16
|
+
* worker gets what it actually needs — the model, the vector shape, the binding, the namespace, and the
|
|
17
|
+
* *result* of introspecting the metadata schema (the filterable descriptors), which is plain data.
|
|
18
|
+
*
|
|
19
|
+
* The projection happens at provision time, on the CLI side, where the real schema exists. That means a
|
|
20
|
+
* schema change reaches the worker only on the next `pithy vector provision` — which is correct, because a
|
|
21
|
+
* new filterable field needs a metadata index provisioned before it can be filtered on anyway.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One index as the worker sees it: everything needed to embed, write, and filter, with no Zod in sight. */
|
|
25
|
+
export const VectorWorkerIndex = z
|
|
26
|
+
.object({
|
|
27
|
+
indexName: z
|
|
28
|
+
.string()
|
|
29
|
+
.min(1)
|
|
30
|
+
.describe("The provisioned Vectorize index name for this environment — what the binding addresses."),
|
|
31
|
+
binding: z.string().min(1).describe("The wrangler binding this index arrives on in the worker's env."),
|
|
32
|
+
model: z.string().min(1).describe("The Workers AI embedding model pinned to this index."),
|
|
33
|
+
dimensions: z.number().int().positive().describe("How many components each vector carries."),
|
|
34
|
+
metric: VectorMetric.describe("How this index scores nearest neighbors."),
|
|
35
|
+
namespace: z.string().optional().describe("The namespace writes and queries use when a caller names none."),
|
|
36
|
+
filterable: z
|
|
37
|
+
.array(MetadataIndexDescriptor)
|
|
38
|
+
.describe(
|
|
39
|
+
"The metadata indexes this index declares, introspected from its Zod metadata schema at provision time. Plain data, because a Zod schema cannot travel through a wrangler var.",
|
|
40
|
+
),
|
|
41
|
+
})
|
|
42
|
+
.describe("One index as the prebuilt reprocess worker sees it — the serializable half of a VectorIndexConfig.");
|
|
43
|
+
export type VectorWorkerIndex = z.infer<typeof VectorWorkerIndex>;
|
|
44
|
+
|
|
45
|
+
/** The whole projection, keyed by the config's index names — the `VECTOR_CONFIG` var the worker parses. */
|
|
46
|
+
export const VectorWorkerConfig = z
|
|
47
|
+
.object({
|
|
48
|
+
indexes: z
|
|
49
|
+
.record(z.string(), VectorWorkerIndex)
|
|
50
|
+
.describe("Every index the worker may reprocess, keyed by the name used in pithy.config.ts and on routes."),
|
|
51
|
+
})
|
|
52
|
+
.describe("The vector capability's config as the prebuilt reprocess worker receives it: serializable, Zod-free.");
|
|
53
|
+
export type VectorWorkerConfig = z.infer<typeof VectorWorkerConfig>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Project a resolved config into the worker's view for one environment. `resolveIndexName` maps a config
|
|
57
|
+
* index name to its provisioned Vectorize index name, so the same projection serves every environment.
|
|
58
|
+
* Throws through {@link metadataIndexes} if a metadata schema is not provisionable — the same failure
|
|
59
|
+
* `pithy vector provision` would hit, raised before a worker is deployed against it.
|
|
60
|
+
*/
|
|
61
|
+
export function toWorkerConfig(config: VectorConfig, resolveIndexName: (name: string) => string): VectorWorkerConfig {
|
|
62
|
+
const indexes: Record<string, VectorWorkerIndex> = {};
|
|
63
|
+
for (const [name, index] of Object.entries(config.indexes)) {
|
|
64
|
+
indexes[name] = {
|
|
65
|
+
indexName: resolveIndexName(name),
|
|
66
|
+
binding: index.binding,
|
|
67
|
+
model: index.model,
|
|
68
|
+
dimensions: index.dimensions,
|
|
69
|
+
metric: index.metric,
|
|
70
|
+
...(index.namespace !== undefined ? { namespace: index.namespace } : {}),
|
|
71
|
+
filterable: index.metadata ? metadataIndexes(index.metadata) : [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { indexes };
|
|
75
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteDate, sqliteJson } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One embedded document — the row in `pithy_vector_documents`, keyed by `(indexName, id)`.
|
|
9
|
+
*
|
|
10
|
+
* The key is composite because a Vectorize id is unique within an index, not across them: `docs` and `faqs`
|
|
11
|
+
* may each hold a document called `intro`, and they are different documents.
|
|
12
|
+
*
|
|
13
|
+
* This table earns its place three times over, and each reason is independently sufficient:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Vectorize returns ids and scores, not content.** A match is `{ id, score }` — the text a user reads
|
|
16
|
+
* has to come from somewhere, and hydrating results from this table is that somewhere.
|
|
17
|
+
* 2. **Long source text cannot live in Vectorize at all.** Metadata is capped at 10 KiB per vector, so a
|
|
18
|
+
* document of any real length exceeds the ceiling and must be stored outside the index.
|
|
19
|
+
* 3. **Re-embedding needs a durable corpus.** Changing the model, or adding a metadata index late, invalidates
|
|
20
|
+
* what is already in the index — and Cloudflare does not give the text back. Without this table, a
|
|
21
|
+
* re-embed is a re-ingest, which for most adopters means it never happens.
|
|
22
|
+
*
|
|
23
|
+
* `metadata` is stored through `sqliteJson`, so it is validated on both sides and never hand-serialized.
|
|
24
|
+
* `model` records which model produced the vector, which is what makes `pithy vector reprocess` able to
|
|
25
|
+
* re-embed exactly the rows that drifted rather than the whole corpus.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The metadata a document carries. Vectorize can only *filter* strings, numbers, and booleans; it stores more. */
|
|
29
|
+
export const VectorDocumentMetadata = z
|
|
30
|
+
.record(z.string(), z.unknown())
|
|
31
|
+
.describe(
|
|
32
|
+
"The metadata this document's vector carries, as stored. Vectorize indexes only the fields marked filterable in the index's metadata schema; the rest ride along for hydration.",
|
|
33
|
+
);
|
|
34
|
+
export type VectorDocumentMetadata = z.infer<typeof VectorDocumentMetadata>;
|
|
35
|
+
|
|
36
|
+
export const VectorDocument = z
|
|
37
|
+
.object({
|
|
38
|
+
id: z
|
|
39
|
+
.string()
|
|
40
|
+
.describe(
|
|
41
|
+
"The document's id, which is also its Vectorize vector id — at most 64 bytes, because a longer id cannot be addressed in the index. Unique within an index, not across them: it is half of the primary key.",
|
|
42
|
+
),
|
|
43
|
+
indexName: z
|
|
44
|
+
.string()
|
|
45
|
+
.describe(
|
|
46
|
+
"The index this document was embedded into, as named in pithy.config.ts. The other half of the primary key, so the same id in two indexes is two documents.",
|
|
47
|
+
),
|
|
48
|
+
namespace: z.string().nullable().describe("The namespace this document belongs to, or null for the default."),
|
|
49
|
+
content: z
|
|
50
|
+
.string()
|
|
51
|
+
.nullable()
|
|
52
|
+
.describe("The source text that was embedded. Null when the adopter keeps the text elsewhere."),
|
|
53
|
+
metadata: sqliteJson(VectorDocumentMetadata).describe(
|
|
54
|
+
"The document's metadata, stored as JSON and validated on read and write.",
|
|
55
|
+
),
|
|
56
|
+
model: z
|
|
57
|
+
.string()
|
|
58
|
+
.nullable()
|
|
59
|
+
.describe(
|
|
60
|
+
"The embedding model that produced this document's vector. `pithy vector reprocess` re-embeds exactly the rows whose model differs from config.",
|
|
61
|
+
),
|
|
62
|
+
createdAt: SQLiteDate.describe("When the document was first embedded."),
|
|
63
|
+
updatedAt: SQLiteDate.describe("When the document was last re-embedded or its metadata changed."),
|
|
64
|
+
})
|
|
65
|
+
.describe("One embedded document — the row in `pithy_vector_documents`, keyed by `(indexName, id)`.");
|
|
66
|
+
export type VectorDocument = z.output<typeof VectorDocument>;
|
|
67
|
+
export type VectorDocumentRow = z.input<typeof VectorDocument>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The document schema with an index's own metadata shape, so a read validates the adopter's fields instead of
|
|
71
|
+
* accepting any JSON. The table is the same; only the metadata codec narrows.
|
|
72
|
+
*/
|
|
73
|
+
export function vectorDocumentFor<M extends z.ZodObject>(metadata: M) {
|
|
74
|
+
return VectorDocument.extend({
|
|
75
|
+
metadata: sqliteJson(metadata).describe("The document's metadata, validated against this index's schema."),
|
|
76
|
+
});
|
|
77
|
+
}
|