Skip to content

Repository files navigation

Recall

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

How It Works

Recall is a Retrieval-Augmented Generation (RAG) pipeline in four stages:

  1. 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.
  2. 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.
  3. Generate — the API sends those relevant chunks (as context) plus your question to an LLM (GPT-4o-mini by default) and returns the answer.
  4. 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.


Project Layout

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

File-by-File Breakdown

api/cmd/server/main.go

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 /health and POST /ask (behind API key middleware)
  • Starts an HTTP server on the configured port

api/internal/config/config.go

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).

api/internal/handlers/handlers.go

HTTP layer. The Ask handler:

  1. Decodes the JSON {"question": "..."} request
  2. Calls the LLM client to embed the question
  3. Queries pgvector for the top 5 most similar chunks
  4. Sends those chunks as context to the chat model
  5. Returns {"answer": "...", "sources": [...]}

Also contains RequireAPIKey middleware — skips check if no key is configured (for local dev), otherwise validates the X-API-Key header.

api/internal/llm/llm.go

Plain-net/http LLM client (no SDK dependency). Two methods:

  • Embed(text) — POST to /embeddings, returns []float32
  • Answer(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.

api/internal/retrieval/retrieval.go

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 $2

Uses 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.

cli/main.go

Two modes:

  • Single-shotrecall "my question?" prints just the answer; pass -sources to also show retrieved chunks with distances
  • Interactiverecall (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.

evals/main.go

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.

evals/test_questions.json

Five test cases covering the user's actual Notion content (coding roadmap, web app checklist, security items, explain-it test).

ingestion/ingest.py

Orchestrates the ingestion pipeline:

  1. Calls fetch_all_notes() to pull pages from Notion
  2. For each page: chunks the text, embeds each chunk in a batch, deletes old chunks for that page, inserts the new ones
  3. Reports summary stats

Idempotent — re-running refreshes existing pages without duplicating rows.

ingestion/lib/notion.py

Thin requests-based Notion API wrapper (no SDK). Features:

  • search_pages() / query_database() — paginated page listing
  • extract_page_text() — recursively walks a page's block tree, extracting plain text from paragraphs, headings, lists, to-dos, quotes, callouts, toggles
  • get_page_title() — handles multiple title property shapes
  • Retry loop for transient 429/5xx errors

ingestion/lib/chunker.py

Splits text into 350-word overlapping chunks (50-word overlap). The overlap prevents sentences from being split across chunk boundaries and lost to both.

ingestion/lib/embeddings.py

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.

ingestion/lib/db.py

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.

db/schema.sql

Creates the note_chunks table with columns:

  • page_id, page_title, chunk_index, chunk_text
  • embedding vector(1536) — matches text-embedding-3-small dimensionality
  • ivfflat index on the vector column (for when the corpus grows)
  • B-tree index on page_id (for the delete-before-insert pattern)

scripts/recall

The one-command launcher. Symlink this to ~/.local/bin/recall so you can run it from anywhere. It:

  1. Resolves its own path through symlinks to find the project root
  2. Loads .env
  3. 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
  4. Launches the interactive CLI
  5. On exit, kills the API process if this script started it

scripts/api, scripts/ask, scripts/ingest, scripts/evals

Convenience wrappers — each cds to the right directory and runs the corresponding Go/Python program with shared cache directories set.


Setup

1. Environment

cp .env.example .env

Fill 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.

2. Neon

CREATE EXTENSION IF NOT EXISTS vector;

Then apply db/schema.sql in the Neon SQL console or via psql.

3. Notion

Create a Notion internal connection at https://www.notion.so/profile/integrations, copy the token, and share your target pages/databases with that connection.

4. Python dependencies

python3 -m pip install -r ingestion/requirements.txt --target .pythondeps

Usage

One-command (recommended)

scripts/recall

Or install the global command:

ln -sf "$PWD/scripts/recall" ~/.local/bin/recall
recall

This starts the API if needed, opens the interactive prompt, and cleans up on exit.

Single-shot

scripts/ask "What does my roadmap say about AI?"
scripts/ask -sources "What are the security checklist items?"

Ingest notes

scripts/ingest

Re-run whenever your Notion content changes — it's idempotent.

Run evals

scripts/evals

Direct API calls

curl -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?"}'

Interactive CLI Commands

/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

Deployment

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.


Security

  • Never commit .env
  • /ask is protected by X-API-Key (skipped if no key configured for local dev)
  • Rotate any tokens that were pasted into chat or terminal history
  • Re-run scripts/ingest after Notion content changes

Architecture Notes

  • 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.Marshal for vector literals? pgvector accepts JSON array syntax — [0.1,0.2,...] — so json.Marshal is the simplest correct serialisation.

About

A terminal-first RAG assistant that lets you ask questions over your own Notion notes

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages