A terminal-first RAG assistant that lets you ask questions over your own Notion
notes. One command — recall — and you're chatting with your personal knowledge
base from the terminal.
Notion pages
→ Python ingestion (chunk + embed)
→ Neon Postgres + pgvector
→ Go API (retrieve + generate)
→ terminal CLI
Recall is a Retrieval-Augmented Generation (RAG) pipeline in four stages:
- Ingest — a Python script pulls your Notion pages, splits them into
overlapping text chunks, embeds each chunk via OpenRouter, and stores
everything in Neon Postgres with
pgvector. - Retrieve — when you ask a question, the Go API embeds it using the same model, then runs a cosine-distance search over your stored chunks to find the most relevant ones.
- Generate — the API sends those relevant chunks (as context) plus your question to an LLM (GPT-4o-mini by default) and returns the answer.
- Display — the CLI shows the answer, and optionally the source chunks with their similarity distances so you know where the answer came from.
The ingestion runs on demand (re-run it when your notes change). The API and CLI can run locally or the API can be deployed and the CLI pointed at it remotely.
recall/
├── api/ Go API server
│ ├── cmd/server/main.go Entry point — wires up config, DB, LLM, HTTP
│ ├── internal/
│ │ ├── config/config.go Reads env vars into a typed Config struct
│ │ ├── handlers/handlers.go HTTP handlers: /health, /ask, API key middleware
│ │ ├── llm/llm.go Embedding + chat completion client via OpenRouter
│ │ └── retrieval/retrieval.go pgvector query: nearest-neighbour chunk search
│ ├── Dockerfile Container build for deployment (Railway/Fly.io)
│ └── go.mod Go module (pgx, pgvector)
│
├── cli/ Go terminal client
│ ├── main.go CLI entry — single-shot or interactive mode
│ └── go.mod Go module (stdlib only, zero dependencies)
│
├── evals/ Eval harness
│ ├── main.go Runs test questions, LLM-as-judge scoring
│ ├── test_questions.json 5 test cases with reference answers
│ └── go.mod Go module (stdlib only)
│
├── ingestion/ Python ingestion pipeline
│ ├── ingest.py Entry point: fetch → chunk → embed → insert
│ └── lib/
│ ├── notion.py Notion API wrapper — pages, blocks, text extraction
│ ├── chunker.py Splits text into overlapping word windows
│ ├── embeddings.py Batch embedding via OpenRouter/OpenAI client
│ └── db.py Postgres insert/delete for chunks + vectors
│
├── scripts/ Shell launchers
│ ├── recall One-command launcher — starts API if needed, opens interactive CLI
│ ├── api Start just the Go API server
│ ├── ask Ask a single question from the command line
│ ├── ingest Run the Python ingestion pipeline
│ └── evals Run the eval harness
│
├── db/
│ └── schema.sql pgvector table + indexes (run once on Neon)
│
├── docker-compose.yml Local API container (optional, for Docker dev)
├── .env.example Environment variable template
└── README.md This file
The Go program entry point. It:
- Loads config from environment variables
- Opens a pgx connection pool to Neon
- Creates the LLM client (OpenRouter-compatible)
- Registers two routes:
GET /healthandPOST /ask(behind API key middleware) - Starts an HTTP server on the configured port
Centralises all environment variable reading so the rest of the code never calls
os.Getenv directly. Supports fallback env var names (e.g. LLM_API_KEY or
OPENROUTER_API_KEY).
HTTP layer. The Ask handler:
- Decodes the JSON
{"question": "..."}request - Calls the LLM client to embed the question
- Queries pgvector for the top 5 most similar chunks
- Sends those chunks as context to the chat model
- Returns
{"answer": "...", "sources": [...]}
Also contains RequireAPIKey middleware — skips check if no key is configured
(for local dev), otherwise validates the X-API-Key header.
Plain-net/http LLM client (no SDK dependency). Two methods:
Embed(text)— POST to/embeddings, returns[]float32Answer(question, contextChunks)— builds a system prompt ("answer using only this context, don't guess"), POSTs to/chat/completions, returns the assistant's reply
Sends OpenRouter-specific headers (HTTP-Referer, X-Title) when configured.
pgvector query layer. TopChunks(ctx, embedding, limit) runs:
SELECT page_id, page_title, chunk_text, embedding <=> $1::vector AS distance
FROM note_chunks
ORDER BY embedding <=> $1::vector
LIMIT $2Uses SET LOCAL enable_indexscan = off / enable_bitmapscan = off to force an
exact scan (better than ivfflat for small datasets). The embedding slice is
converted to pgvector's literal format via json.Marshal, which pgvector
accepts natively.
Two modes:
- Single-shot —
recall "my question?"prints just the answer; pass-sourcesto also show retrieved chunks with distances - Interactive —
recall(no args) opens a REPL with history of commands:
/help show commands
/sources toggle source display
/last show sources from last answer
/api show API URL
/clear clear screen
/exit quit
Loads .env automatically (tries .env then ../.env), so you don't need to
set env vars manually.
LLM-as-judge eval harness. Loads test questions from test_questions.json, asks
each one through the Recall API concurrently, then sends the question +
reference answer + actual answer to a judge LLM for scoring (1–5). Prints a
pass-rate report with hallucination flagging.
Five test cases covering the user's actual Notion content (coding roadmap, web app checklist, security items, explain-it test).
Orchestrates the ingestion pipeline:
- Calls
fetch_all_notes()to pull pages from Notion - For each page: chunks the text, embeds each chunk in a batch, deletes old chunks for that page, inserts the new ones
- Reports summary stats
Idempotent — re-running refreshes existing pages without duplicating rows.
Thin requests-based Notion API wrapper (no SDK). Features:
search_pages()/query_database()— paginated page listingextract_page_text()— recursively walks a page's block tree, extracting plain text from paragraphs, headings, lists, to-dos, quotes, callouts, togglesget_page_title()— handles multiple title property shapes- Retry loop for transient 429/5xx errors
Splits text into 350-word overlapping chunks (50-word overlap). The overlap prevents sentences from being split across chunk boundaries and lost to both.
Caches an OpenAI-compatible client and provides embed_batch() for efficient
batch embedding. Picks up env vars for API key, base URL, headers — works with
OpenRouter, OpenAI, or any compatible provider.
Minimal psycopg2 wrapper. delete_page_chunks() clears old rows for a page
before re-ingesting. insert_chunks() bulk-inserts via execute_values with a
::vector cast for the embedding column.
Creates the note_chunks table with columns:
page_id,page_title,chunk_index,chunk_textembedding vector(1536)— matchestext-embedding-3-smalldimensionality- ivfflat index on the vector column (for when the corpus grows)
- B-tree index on
page_id(for the delete-before-insert pattern)
The one-command launcher. Symlink this to ~/.local/bin/recall so you can run
it from anywhere. It:
- Resolves its own path through symlinks to find the project root
- Loads
.env - Checks if the API is already running (via
/health); if not, starts it in the background and waits up to 90 seconds for it to become healthy - Launches the interactive CLI
- On exit, kills the API process if this script started it
Convenience wrappers — each cds to the right directory and runs the
corresponding Go/Python program with shared cache directories set.
cp .env.example .envFill in your values:
| Variable | Description |
|---|---|
DATABASE_URL |
Neon Postgres connection string |
NOTION_TOKEN |
Notion internal integration token |
OPENROUTER_API_KEY |
OpenRouter API key (or LLM_API_KEY) |
LLM_BASE_URL |
Defaults to https://openrouter.ai/api/v1 |
EMBEDDING_MODEL |
openai/text-embedding-3-small |
LLM_MODEL |
openai/gpt-4o-mini |
API_KEY |
Any random string — sent as X-API-Key header |
Leave NOTION_DATABASE_ID blank to ingest all pages shared with the
integration, or set it to restrict to one database.
CREATE EXTENSION IF NOT EXISTS vector;Then apply db/schema.sql in the Neon SQL console or via psql.
Create a Notion internal connection at https://www.notion.so/profile/integrations, copy the token, and share your target pages/databases with that connection.
python3 -m pip install -r ingestion/requirements.txt --target .pythondepsscripts/recallOr install the global command:
ln -sf "$PWD/scripts/recall" ~/.local/bin/recall
recallThis starts the API if needed, opens the interactive prompt, and cleans up on exit.
scripts/ask "What does my roadmap say about AI?"
scripts/ask -sources "What are the security checklist items?"scripts/ingestRe-run whenever your Notion content changes — it's idempotent.
scripts/evalscurl -s http://127.0.0.1:8080/health
curl -s -X POST http://127.0.0.1:8080/ask \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d '{"question":"What is in my Web App Checklist?"}'/help Show available commands
/sources Toggle showing source snippets after each answer
/last Show sources from the last answer
/api Show the current API URL
/clear Clear the screen
/exit Quit
The API is a single Go binary that can be deployed to Railway, Fly.io, or any container platform. The CLI stays local and connects remotely:
API_URL=https://your-api.example.com scripts/ask "What do my notes say?"Required production env vars: DATABASE_URL, OPENROUTER_API_KEY, API_KEY.
- Never commit
.env /askis protected byX-API-Key(skipped if no key configured for local dev)- Rotate any tokens that were pasted into chat or terminal history
- Re-run
scripts/ingestafter Notion content changes
- Why Go for the API/CLI? Single-binary deploy, no runtime deps, fast startup, concurrent evals.
- Why Python for ingestion? Rich ecosystem for Notion access, embedding libraries, and data processing.
- Why plain net/http instead of SDKs? Full visibility into request/response shapes — no magic, easy to debug.
- Why exact scan instead of ivfflat? The ivfflat index can return zero
results on small corpora. Exact scan (
<=>with indexes disabled) works perfectly for MVP-scale data. Switch to ivfflat once the chunk table has 10,000+ rows. - Why
json.Marshalfor vector literals? pgvector accepts JSON array syntax —[0.1,0.2,...]— sojson.Marshalis the simplest correct serialisation.