Skip to content

Commit 7d1610b

Browse files
committed
feat(selfhost): Tier 1 — local RAG via SQLite vector store + embeddings (#979)
Retrieval-augmented review without Cloudflare Vectorize: - createSqliteVectorize: implements the Vectorize binding surface (upsert/query/deleteByIds) backed by a SQLite table (_selfhost_vectors), brute-force cosine similarity, namespace-scoped (one per repo). Wired as env.VECTORIZE so the core RAG path (reviewVectorAdapter) works unchanged. - Embeddings: the OpenAI-compatible adapter now routes { text: [...] } embed calls to /embeddings and returns { data: number[][] } (the shape embedTexts expects). AI_EMBED_MODEL selects a 1024-d model (bge-m3 / mxbai-embed-large via Ollama); without it RAG degrades to no-context. - AiResult widened to { response?; data? } so chat + embed share the run() seam. - Gated by GITTENSORY_REVIEW_RAG + the repo allowlist (off by default). +6 tests (cosine, namespace scoping, upsert-overwrite/delete, embed routing). 38 self-host tests green, boots with all 3 self-host tables.
1 parent 03c072b commit 7d1610b

7 files changed

Lines changed: 185 additions & 5 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,6 @@ GITTENSORY_REVIEW_DRAFT=false
123123
# # for claude-code, gpt-5 for codex). REQUIRED for non-Ollama:
124124
# # without it the adapter falls back to a provider default, never
125125
# # the Cloudflare Workers-AI id the core would otherwise pass.
126+
# AI_EMBED_MODEL=bge-m3 # embedding model for RAG (openai-compatible /embeddings). MUST be
127+
# # 1024-dimensional (e.g. bge-m3 or mxbai-embed-large via Ollama).
128+
# # Used only when RAG is enabled (GITTENSORY_REVIEW_RAG + allowlist).

docs/self-hosting.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ errors. If every provider fails, the AI summary degrades to "unavailable" and th
8989
image with `--build-arg INSTALL_AI_CLIS=true` (or `docker compose build --build-arg INSTALL_AI_CLIS=true`) to
9090
bake them in, then provide `CLAUDE_CODE_OAUTH_TOKEN` / codex auth at run time. No credentials are baked in.
9191

92+
**Local RAG (retrieval-augmented review).** Self-host ships a SQLite-backed vector store, so RAG works without
93+
Cloudflare Vectorize. Enable it with `GITTENSORY_REVIEW_RAG=true` + the repo in `GITTENSORY_REVIEW_REPOS`, and
94+
point at an **embedding-capable** OpenAI-compatible provider (Ollama) with a **1024-dimensional** model via
95+
`AI_EMBED_MODEL` (e.g. `bge-m3` or `mxbai-embed-large`). Embeddings + chunk vectors are stored in the same
96+
SQLite DB (`_selfhost_vectors`) and queried by cosine similarity. Without an embedding model, RAG degrades to
97+
no-context (the review still runs).
98+
9299
> **Set `AI_MODEL`.** The core would otherwise hand the adapter a Cloudflare Workers-AI model id
93100
> (`@cf/meta/...`) that Ollama / `claude` / `codex` can't use. The adapter ignores that id in favour of
94101
> `AI_MODEL` (falling back to a provider default), so always set `AI_MODEL` to a real model for your provider.

src/selfhost/ai.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99
interface AiRunOptions {
1010
messages?: Array<{ role: string; content: string }>;
1111
prompt?: string;
12+
text?: string[]; // embedding input — the core's embedTexts passes { text: string[] }
1213
max_tokens?: number;
1314
temperature?: number;
1415
}
16+
/** A chat completion (`response`) or an embedding result (`data`). Both optional: the core reads whichever it
17+
* asked for (extractAiText → `response`, embedTexts → `data`), each defensive about the other being absent. */
18+
export type AiResult = { response?: string; data?: number[][] };
1519
export interface SelfHostAi {
16-
run(model: string, options: AiRunOptions): Promise<{ response: string }>;
20+
run(model: string, options: AiRunOptions): Promise<AiResult>;
1721
}
1822

1923
function toMessages(options: AiRunOptions): Array<{ role: string; content: string }> {
@@ -34,14 +38,27 @@ function configuredModel(env: Record<string, string | undefined>): string | unde
3438
return env.AI_MODEL ?? env.WORKERS_AI_SUMMARY_MODEL;
3539
}
3640

37-
/** OpenAI-compatible chat endpoint (Ollama's /v1, OpenAI, vLLM, LM Studio, …). */
38-
export function createOpenAiCompatibleAi(opts: { baseUrl: string; apiKey?: string | undefined; model?: string | undefined }): SelfHostAi {
41+
/** OpenAI-compatible endpoint (Ollama's /v1, OpenAI, vLLM, LM Studio, …) — chat + embeddings. */
42+
export function createOpenAiCompatibleAi(opts: { baseUrl: string; apiKey?: string | undefined; model?: string | undefined; embedModel?: string | undefined }): SelfHostAi {
3943
const base = opts.baseUrl.replace(/\/+$/, "");
44+
const headers = (): Record<string, string> => ({ "content-type": "application/json", ...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}) });
4045
return {
4146
async run(model, options) {
47+
// Embedding request — the core's embedTexts passes { text: string[] }; route to /embeddings (for RAG).
48+
if (Array.isArray(options.text)) {
49+
const res = await fetch(`${base}/embeddings`, {
50+
method: "POST",
51+
headers: headers(),
52+
body: JSON.stringify({ model: opts.embedModel ?? "bge-m3", input: options.text }),
53+
signal: AbortSignal.timeout(120_000),
54+
});
55+
if (!res.ok) throw new Error(`ai_embed_http_${res.status}`);
56+
const json = (await res.json()) as { data?: Array<{ embedding: number[] }> };
57+
return { data: (json.data ?? []).map((d) => d.embedding) };
58+
}
4259
const res = await fetch(`${base}/chat/completions`, {
4360
method: "POST",
44-
headers: { "content-type": "application/json", ...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}) },
61+
headers: headers(),
4562
body: JSON.stringify({ model: resolveModel(opts.model, model, "llama3.1"), messages: toMessages(options), max_tokens: options.max_tokens, temperature: options.temperature }),
4663
signal: AbortSignal.timeout(120_000),
4764
});
@@ -231,6 +248,7 @@ export function buildProvider(name: string, env: Record<string, string | undefin
231248
baseUrl: env.AI_BASE_URL ?? (name === "openai" ? "https://api.openai.com/v1" : "http://localhost:11434/v1"),
232249
apiKey: env.AI_API_KEY ?? env.OPENAI_API_KEY,
233250
model: configuredModel(env),
251+
embedModel: env.AI_EMBED_MODEL,
234252
});
235253
case "anthropic": {
236254
const apiKey = env.ANTHROPIC_API_KEY ?? env.AI_API_KEY;

src/selfhost/vectorize.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// SQLite-backed Vectorize adapter for self-host RAG (#979). Implements the Cloudflare `Vectorize` binding
2+
// surface (upsert / query / deleteByIds) that gittensory's RAG (reviewVectorAdapter) wraps, backed by a
3+
// SQLite table with brute-force cosine similarity. For a repo's worth of chunks (hundreds–few-thousand
4+
// vectors per namespace) this is fast enough; namespaces (one per repo) keep each query's candidate set
5+
// small. Embeddings come from the OpenAI-compatible AI adapter's /embeddings path (e.g. Ollama bge-m3, 1024-d).
6+
import type { SqliteDriver } from "./d1-adapter";
7+
8+
const TABLE = "_selfhost_vectors";
9+
const DDL = `
10+
CREATE TABLE IF NOT EXISTS ${TABLE} (
11+
id TEXT PRIMARY KEY,
12+
namespace TEXT NOT NULL DEFAULT '',
13+
embedding TEXT NOT NULL,
14+
metadata TEXT
15+
);
16+
CREATE INDEX IF NOT EXISTS ${TABLE}_ns ON ${TABLE}(namespace);`;
17+
18+
interface VectorRecord {
19+
id: string;
20+
values: number[];
21+
namespace?: string;
22+
metadata?: Record<string, unknown>;
23+
}
24+
interface QueryOptions {
25+
topK?: number;
26+
namespace?: string;
27+
returnMetadata?: string;
28+
}
29+
interface Match {
30+
id: string;
31+
score: number;
32+
metadata?: Record<string, unknown>;
33+
}
34+
35+
export function cosineSimilarity(a: number[], b: number[]): number {
36+
let dot = 0;
37+
let na = 0;
38+
let nb = 0;
39+
const n = Math.min(a.length, b.length);
40+
for (let i = 0; i < n; i += 1) {
41+
const x = a[i] as number;
42+
const y = b[i] as number;
43+
dot += x * y;
44+
na += x * x;
45+
nb += y * y;
46+
}
47+
const denom = Math.sqrt(na) * Math.sqrt(nb);
48+
return denom === 0 ? 0 : dot / denom;
49+
}
50+
51+
export function createSqliteVectorize(driver: SqliteDriver): Vectorize {
52+
driver.exec(DDL);
53+
const adapter = {
54+
async upsert(vectors: VectorRecord[]): Promise<{ count: number; ids: string[] }> {
55+
for (const v of vectors) {
56+
driver.query(
57+
`INSERT INTO ${TABLE} (id, namespace, embedding, metadata) VALUES (?,?,?,?)
58+
ON CONFLICT(id) DO UPDATE SET namespace=excluded.namespace, embedding=excluded.embedding, metadata=excluded.metadata`,
59+
[v.id, v.namespace ?? "", JSON.stringify(v.values), v.metadata ? JSON.stringify(v.metadata) : null],
60+
);
61+
}
62+
return { count: vectors.length, ids: vectors.map((v) => v.id) };
63+
},
64+
async query(vector: number[], opts: QueryOptions): Promise<{ matches: Match[] }> {
65+
const { rows } = opts.namespace
66+
? driver.query(`SELECT id, embedding, metadata FROM ${TABLE} WHERE namespace=?`, [opts.namespace])
67+
: driver.query(`SELECT id, embedding, metadata FROM ${TABLE}`, []);
68+
const scored: Match[] = rows.map((r) => {
69+
const values = JSON.parse(r.embedding as string) as number[];
70+
const metadata = r.metadata ? (JSON.parse(r.metadata as string) as Record<string, unknown>) : undefined;
71+
const score = cosineSimilarity(vector, values);
72+
return metadata === undefined ? { id: r.id as string, score } : { id: r.id as string, score, metadata };
73+
});
74+
scored.sort((a, b) => b.score - a.score);
75+
return { matches: scored.slice(0, opts.topK ?? 12) };
76+
},
77+
async deleteByIds(ids: string[]): Promise<{ count: number }> {
78+
for (let i = 0; i < ids.length; i += 90) {
79+
const batch = ids.slice(i, i + 90);
80+
driver.query(`DELETE FROM ${TABLE} WHERE id IN (${batch.map(() => "?").join(",")})`, batch);
81+
}
82+
return { count: ids.length };
83+
},
84+
};
85+
return adapter as unknown as Vectorize;
86+
}

src/server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { readiness } from "./selfhost/health";
1515
import { gauge, incr, renderMetrics } from "./selfhost/metrics";
1616
import { runSelfHostMigrations } from "./selfhost/migrate";
1717
import { createSqliteQueue } from "./selfhost/sqlite-queue";
18+
import { createSqliteVectorize } from "./selfhost/vectorize";
1819
import type { JobMessage } from "./types";
1920

2021
/** Resolve `<NAME>_FILE` env vars (Docker secrets / multi-line keys) into `<NAME>` at startup. */
@@ -53,7 +54,10 @@ async function main(): Promise<void> {
5354
// gittensory's AI summary degrades to "unavailable" and the review proceeds deterministically).
5455
const ai = createSelfHostAi(process.env);
5556
if (ai) console.log(JSON.stringify({ event: "selfhost_ai_provider", provider: process.env.AI_PROVIDER }));
56-
env = { ...process.env, DB: db, JOBS: queue.binding, AI: ai } as unknown as Env;
57+
// Vector store for RAG (gated by GITTENSORY_REVIEW_RAG + the repo allowlist + an embedding-capable provider);
58+
// a SQLite-backed Vectorize so retrieval works without Cloudflare Vectorize.
59+
const vectorize = createSqliteVectorize(driver);
60+
env = { ...process.env, DB: db, JOBS: queue.binding, AI: ai, VECTORIZE: vectorize } as unknown as Env;
5761

5862
gauge("gittensory_queue_pending", () => queue.size());
5963
gauge("gittensory_queue_dead", () => queue.deadCount());

test/unit/selfhost-ai.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ describe("createOpenAiCompatibleAi (#979)", () => {
4141
vi.stubGlobal("fetch", vi.fn(async () => new Response("err", { status: 500 })));
4242
await expect(createOpenAiCompatibleAi({ baseUrl: "http://x/v1" }).run("m", { prompt: "p" })).rejects.toThrow(/ai_http_500/);
4343
});
44+
45+
it("routes an embedding request ({ text }) to /embeddings and returns { data }", async () => {
46+
let url = "";
47+
vi.stubGlobal("fetch", vi.fn(async (u: string) => {
48+
url = u;
49+
return new Response(JSON.stringify({ data: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }] }), { status: 200 });
50+
}));
51+
const out = await createOpenAiCompatibleAi({ baseUrl: "http://o/v1", embedModel: "bge-m3" }).run("@cf/baai/bge-m3", { text: ["a", "b"] });
52+
expect(url).toBe("http://o/v1/embeddings");
53+
expect(out).toEqual({ data: [[0.1, 0.2], [0.3, 0.4]] });
54+
});
4455
});
4556

4657
describe("createSelfHostAi — provider selection", () => {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { DatabaseSync } from "node:sqlite";
2+
import { describe, expect, it } from "vitest";
3+
import { nodeSqliteDriver } from "../../src/selfhost/d1-adapter";
4+
import { cosineSimilarity, createSqliteVectorize } from "../../src/selfhost/vectorize";
5+
6+
function makeVectorize(): ReturnType<typeof createSqliteVectorize> {
7+
return createSqliteVectorize(nodeSqliteDriver(new DatabaseSync(":memory:") as never));
8+
}
9+
10+
describe("cosineSimilarity", () => {
11+
it("is 1 for identical and 0 for orthogonal vectors", () => {
12+
expect(cosineSimilarity([1, 0], [1, 0])).toBeCloseTo(1);
13+
expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0);
14+
expect(cosineSimilarity([0, 0], [0, 0])).toBe(0); // zero-norm guard
15+
});
16+
});
17+
18+
describe("createSqliteVectorize (#979 local RAG)", () => {
19+
it("returns the nearest-by-cosine match within a namespace, with metadata + topK", async () => {
20+
const v = makeVectorize();
21+
await v.upsert([
22+
{ id: "a", values: [1, 0, 0], namespace: "repo1", metadata: { path: "a.ts" } },
23+
{ id: "b", values: [0, 1, 0], namespace: "repo1", metadata: { path: "b.ts" } },
24+
]);
25+
const res = await v.query([0.9, 0.1, 0], { topK: 1, namespace: "repo1", returnMetadata: "all" });
26+
expect(res.matches).toHaveLength(1);
27+
expect(res.matches[0]?.id).toBe("a");
28+
expect(res.matches[0]?.metadata?.path).toBe("a.ts");
29+
});
30+
31+
it("scopes results by namespace", async () => {
32+
const v = makeVectorize();
33+
await v.upsert([
34+
{ id: "x", values: [1, 0], namespace: "n1" },
35+
{ id: "y", values: [1, 0], namespace: "n2" },
36+
]);
37+
const res = await v.query([1, 0], { topK: 10, namespace: "n1" });
38+
expect(res.matches.map((m) => m.id)).toEqual(["x"]);
39+
});
40+
41+
it("upsert overwrites by id; deleteByIds removes", async () => {
42+
const v = makeVectorize();
43+
await v.upsert([{ id: "d", values: [1, 0], namespace: "n", metadata: { path: "old" } }]);
44+
await v.upsert([{ id: "d", values: [0, 1], namespace: "n", metadata: { path: "new" } }]); // overwrite
45+
let res = await v.query([0, 1], { topK: 10, namespace: "n" });
46+
expect(res.matches[0]?.metadata?.path).toBe("new");
47+
await v.deleteByIds(["d"]);
48+
res = await v.query([0, 1], { topK: 10, namespace: "n" });
49+
expect(res.matches).toHaveLength(0);
50+
});
51+
});

0 commit comments

Comments
 (0)