Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
80640ea
chore: add sqlite-vec dependency
claude Aug 9, 2026
8bf7408
feat(rag): add agent_embeddings vec0 table and nearest_match columns
claude Aug 9, 2026
fb3b019
feat(rag): load sqlite-vec extension in the writer's database connection
claude Aug 9, 2026
7703535
feat(rag): add Titan Text Embeddings V2 embedder
claude Aug 9, 2026
a1525e4
fix(rag): use Uint8ArrayBlobAdapter for mocked Bedrock body in Titan …
claude Aug 9, 2026
ddcfbe2
feat(rag): add deterministic no-AWS embedder for Phase 1 and tests
claude Aug 9, 2026
b040534
feat(rag): add same-source nearest-match KNN lookup and storage
claude Aug 9, 2026
ec064b2
feat(rag): thread closest-past-reading context into the Bedrock prompt
claude Aug 9, 2026
e56cf8d
feat(rag): wire nearest-match lookup and embedding storage into runFetch
claude Aug 9, 2026
81e9dd3
feat(rag): expose nearestMatch on the status endpoint
claude Aug 9, 2026
27fc989
feat(rag): wire Titan/local embedder into the Lambda handler and loca…
claude Aug 9, 2026
d5203b1
feat(rag): grant IAM invoke permission for the Titan embedding model
claude Aug 9, 2026
477eb13
docs: explain the sqlite-vec + Titan RAG demo
claude Aug 9, 2026
5b065de
chore: fix hasInstallScript metadata in package-lock.json
claude Aug 9, 2026
4b0d3df
docs: mark RAG sqlite-vec + Titan spec as implemented
claude Aug 9, 2026
86e5752
fix: correct RAG embed ordering doc and validate Titan embedding shape
claude Aug 9, 2026
bb45e80
fix(status): feature-detect RAG columns and guard partial nearestMatc…
claude Aug 9, 2026
98c8d39
fix(tests): use raw JSON for Titan non-finite entry test
claude Aug 9, 2026
c96510e
fix(embed): bound Titan InvokeModel calls with AbortSignal.timeout
claude Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ A working example of the **SQLite-as-a-database-for-an-agent-on-AWS, rehydrated-
pattern: a Discord bot that checks the weather and Bitcoin price once a day, asks an LLM
(Amazon Bedrock) to turn the raw value into a friendly message, posts it to a Discord
webhook, and remembers what it already posted — all state lives in a single SQLite file
in S3. No database server, no VPC.
in S3. No database server, no VPC. The same file also doubles as a vector database: each
posted message gets embedded (Titan Text Embeddings V2) and searched with `sqlite-vec`,
so the bot can mention the closest past result — see
[docs/08-rag-vector-search.md](docs/08-rag-vector-search.md).

## Quick start

Expand Down Expand Up @@ -66,6 +69,7 @@ deploy you leave running unattended.
| [docs/04-extending.md](docs/04-extending.md) | Adding a third source |
| [docs/05-from-tutorial-to-prod.md](docs/05-from-tutorial-to-prod.md) | What changes if you outgrow this |
| [docs/07-budget-protection.md](docs/07-budget-protection.md) | Setting up an AWS Budget alert, and what could actually drive cost up |
| [docs/08-rag-vector-search.md](docs/08-rag-vector-search.md) | SQLite as a vector database too: sqlite-vec + Titan embeddings |

## Cost

Expand Down
10 changes: 10 additions & 0 deletions docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ Reserved concurrency 1 means only one invocation of this function runs at a time
race can't happen at all. The conditional write is a second line of defense that also
protects against an out-of-band `aws s3 cp` — belt and suspenders.

## Bedrock calls: formatting and embedding

Before formatting, the writer makes a small Bedrock round trip — Titan Text Embeddings
V2, via `InvokeModel` rather than `Converse` — to embed the raw fetched value and search
a `sqlite-vec` table inside the same `memory.db` file for the closest same-source past
notification, so the formatter can fold that match into its prompt. Only after the
formatted message is posted to Discord does the writer embed and store *that* posted
notification, via the same Titan call, for future lookups; see
[docs/08-rag-vector-search.md](08-rag-vector-search.md).

## EventBridge's payload

The schedule's `Input` is the literal string `{"op":"fetch"}`, not a transformed event.
Expand Down
77 changes: 77 additions & 0 deletions docs/08-rag-vector-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# RAG: SQLite as a vector database too

The rest of this tutorial's docs show SQLite replacing a database *server* (see
[01-architecture.md](01-architecture.md)). This doc shows the same file replacing a
*vector database* too — no Pinecone, no pgvector, no separate service. The `sqlite-vec`
loadable extension turns a table in `memory.db` into a KNN index; Amazon Titan Text
Embeddings V2 turns text into the vectors that index stores.

## What actually happens, per source, per `fetch` run

1. A new value shows up (dedup already ruled out "unchanged from yesterday" before this
point — see [03-schema.md](03-schema.md)).
2. The raw value gets embedded (Titan) and searched against `agent_embeddings` for the
closest **same-source** past notification. First-ever notification for a source? No
match — nothing to search yet.
3. If a match exists, its text and date go into the same Bedrock prompt that formats
today's message — the model may naturally reference it ("looks like last Tuesday's
reading!"), but isn't required to.
4. The message posts to Discord as usual.
5. The *formatted* message — not the raw value — gets embedded and stored, becoming a
candidate for tomorrow's (or next week's) search.

Two Titan calls per posted notification: one to search with (step 2, embeds the raw
value, since the formatted message doesn't exist yet), one to store with (step 5, embeds
the formatted message, since that's the richer, more semantically meaningful text and by
this point it exists). Deduped/unchanged values never reach either call — same principle
as the LLM formatting call already skipping unchanged values (spec: `docs/03-schema.md`).

## Why one `agent_embeddings` table, not one per source

Sources are a closed vocabulary maintained in exactly one place — `SOURCE_NAMES` in
`src/db/schema.ts` (see [04-extending.md](04-extending.md)). A vector table per source
would mean editing a second place every time a source is added, breaking that invariant.
Instead, `agent_embeddings` is one table across every source, and same-source filtering
happens in application code (`src/rag/similarity.ts`'s `findNearestMatch`): a fixed KNN
scan of the 50 closest vectors *regardless of source*, then a filter down to the
requested source, then the closest survivor. Good enough for a workload that grows by at
most a couple of rows a day — not engineered for a corpus where the true nearest
same-source match might not be among the 50 closest across all sources combined.

## Why the query embeds the raw value but the stored embedding is the formatted message

This is the one asymmetry worth calling out. At search time (step 2 above), the
notification hasn't been formatted yet — there's nothing to embed *except* the raw
value. At store time (step 5), the formatted message exists, and it's the more
semantically rich text (Titan famously embeds "a sunny 72°F afternoon" more usefully than
it embeds the bare string "72F"). Both go through the same embedding model, into the same
256-dimension space, so a raw-value query against formatted-message-embedded history still
works — Titan doesn't require its inputs to share a style, just a language.

## Seeing it work

The `status` endpoint's `recentNotifications[]` includes a `nearestMatch` field per
notification — `null` if there was no history yet (or the embedding step failed and was
isolated, see below), otherwise the matched notification's own source/message/date and
the cosine distance between the two vectors. This is read straight off two plain columns
on `agent_notifications` (`nearest_match_id`, `nearest_match_distance`) — the reader never
runs a vector query itself, only the writer does.

## What happens when Titan is unavailable

Both embedding calls (search and store) are wrapped in the same per-source error
isolation `runFetch` already has for fetch/format/post failures. A Titan outage degrades
this feature to "no similarity mentioned today" — it never blocks the Discord post, and
it shows up in `agent_runs.error` like any other per-source failure (see
[03-schema.md](03-schema.md)'s explanation of why that column exists).

## Out of scope

- Cross-source similarity search (a "closest crypto price to today's weather" comparison
isn't semantically meaningful for this tutorial's two sources).
- A configurable embedding model or dimension count (fixed at Titan v2 / 256 dims — the
fixed value avoids a "how do I migrate the corpus" problem this tutorial doesn't need).
- Backfilling embeddings for notifications posted before this feature shipped — the
corpus starts growing from the first `fetch` run after deploying this.
- A similarity threshold below which nothing gets mentioned — every match found is used,
regardless of distance, to keep the demo mechanical and simple to test.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# RAG via sqlite-vec + Titan Embeddings — Design

**Date:** 2026-08-08
**Status:** Proposed
**Status:** Implemented
**Scope:** Extends the existing SQLite-S3 agent tutorial to demonstrate that the same single SQLite file can also serve as a vector store — no separate vector database needed. The agent embeds each notification it posts and, on the next new value for that source, mentions the most similar past result.

---
Expand Down
8 changes: 7 additions & 1 deletion infra/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ class AgentStack extends cdk.Stack {
const bedrockPolicy = new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['bedrock:InvokeModel', 'bedrock:Converse'],
resources: buildBedrockResources(bedrockModelId, this.region),
resources: [
...buildBedrockResources(bedrockModelId, this.region),
// Titan Text Embeddings V2 for RAG (RAG design spec §8) — fixed, unlike the chat
// model: it isn't configurable, so it needs no family-resolution branch through
// buildBedrockResources.
`arn:aws:bedrock:${this.region}::foundation-model/amazon.titan-embed-text-v2:0`,
],
});
agentFunction.addToRolePolicy(bedrockPolicy);

Expand Down
82 changes: 81 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"dependencies": {
"better-sqlite3": "^13.0.1",
"sqlite-vec": "^0.1.9",
"@aws-sdk/client-bedrock-runtime": "^3.1103.0",
"@aws-sdk/client-s3": "^3.1103.0"
},
Expand Down
39 changes: 34 additions & 5 deletions src/agent/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { readFileSync, writeFileSync } from 'node:fs';
import type Database from 'better-sqlite3';
import { bootstrap } from '../db/bootstrap.js';
import { openDatabase } from '../db/open.js';
import type { Embedder } from '../embed/titan.js';
import type { DiscordPoster } from '../discord/poster.js';
import type { MessageFormatter } from '../format/types.js';
import { findNearestMatch, insertEmbedding } from '../rag/similarity.js';
import type { SourceFetcher } from '../sources/types.js';
import type { Store } from '../store/types.js';
import { finishRun, startRun } from './runLog.js';
Expand All @@ -16,6 +18,7 @@ export interface RunFetchParams {
sources: SourceFetcher[];
poster: DiscordPoster;
formatter: MessageFormatter;
embedder: Embedder;
runId?: string;
now?: () => number;
}
Expand Down Expand Up @@ -78,7 +81,19 @@ export async function runFetch(params: RunFetchParams): Promise<RunFetchResult>
continue; // dedup: no formatter call, no post, no notification row
}

const formatted = await params.formatter.format(source.name, rawValue);
// RAG query step: find the closest same-source past notification. Failure here is
// isolated — it degrades to "no similarity context this run" (same as a source's
// first-ever notification), it never blocks the post itself (spec §6).
let match: Awaited<ReturnType<typeof findNearestMatch>> = null;
try {
const queryVector = await params.embedder.embed(rawValue);
match = findNearestMatch(db, source.name, queryVector);
} catch (embedError: unknown) {
const message = embedError instanceof Error ? embedError.message : String(embedError);
errors.push(`${source.name} (embedding query): ${message}`);
}

const formatted = await params.formatter.format(source.name, rawValue, match);
await params.poster.post(formatted);

const postedAt = now();
Expand All @@ -93,10 +108,24 @@ export async function runFetch(params: RunFetchParams): Promise<RunFetchResult>
last_posted_at = excluded.last_posted_at`,
).run(source.name, rawValue, postedAt, postedAt);

db.prepare(
`INSERT INTO agent_notifications (source, value, formatted_message, posted_at)
VALUES (?, ?, ?, ?)`,
).run(source.name, rawValue, formatted, postedAt);
const insertResult = db
.prepare(
`INSERT INTO agent_notifications
(source, value, formatted_message, posted_at, nearest_match_id, nearest_match_distance)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(source.name, rawValue, formatted, postedAt, match?.notificationId ?? null, match?.distance ?? null);

// RAG store step: embed what was actually posted and make it a future match
// candidate. Failure here is isolated too — the notification has already
// committed; only the corpus fails to grow by this one entry (spec §6).
try {
const storeVector = await params.embedder.embed(formatted);
insertEmbedding(db, Number(insertResult.lastInsertRowid), storeVector);
} catch (storeError: unknown) {
const message = storeError instanceof Error ? storeError.message : String(storeError);
errors.push(`${source.name} (embedding store): ${message}`);
}

notificationsSent++;
} catch (error: unknown) {
Expand Down
Loading