GrantMatch AI — Project Story

🌍 Inspiration

Every year, billions of dollars in grant funding go unclaimed — not because there are no worthy causes, but because the NGOs that need it most simply don't have the time, staff, or expertise to find and apply for it. A small nonprofit running clean water projects in rural communities shouldn't need a full-time grant writer just to survive.

I wanted to build something that levels the playing field. The insight was simple: grant matching is fundamentally a semantic search problem. An NGO's mission statement and a grant's eligibility criteria are both just text — and modern AI embeddings are extraordinarily good at measuring meaning-distance between pieces of text. If I could encode both sides of that relationship into vectors and run a similarity search, I could automate the discovery step entirely.

The second half of the problem — writing the application — is exactly what large language models are best at: taking structured context and producing fluent, persuasive prose tailored to a specific audience.


🏗️ How I Built It

The architecture is a classic RAG (Retrieval-Augmented Generation) pipeline, but applied to grant discovery instead of Q&A:

$$ \text{score}(q, d) = 1 - \frac{q \cdot d}{|q| \cdot |d|} $$

where $q$ is the NGO mission embedding and $d$ is the grant document embedding — cosine distance converted to a similarity score in $[1]$.

Stack

Layer Technology Why
Backend Go + chi router Fast, low memory, excellent concurrency for scraping
Vector DB PostgreSQL 16 + pgvector Single infra dependency, ivfflat index for sub-10ms ANN search
Embeddings Gemini text-embedding-004 768-dim, state-of-the-art semantic fidelity, free tier
Generation Gemini gemini-1.5-flash Fast, cheap, excellent instruction-following for structured letters
Frontend HTMX + Tailwind Zero JavaScript framework — reactive UI with plain HTML attributes
Deployment Railway + Docker One-command deploy, managed Postgres with pgvector extension

Pipeline

NGO fills form (name, mission, region, budget, categories)
        ↓
Gemini text-embedding-004  →  768-dim float32 vector
        ↓
pgvector ivfflat cosine search against grants table
        ↓
Top 10 matches ranked by 1 - cosine_distance
        ↓
User clicks "Generate Draft"
        ↓
Gemini 1.5 Flash  →  400–600 word tailored application letter

Data

Grants are scraped from the Grants.gov REST API on startup, embedded, and stored with ON CONFLICT (url) DO NOTHING so re-runs are idempotent. The ivfflat index with lists = 100 means similarity queries run in O(√n) time even as the grants table grows.


🧠 What I Learned

1. pgvector is production-ready. I had expected to need a dedicated vector database (Pinecone, Weaviate), but pgvector inside standard Postgres handled everything I needed — typed vector(768) columns, cosine/L2/inner product operators, and approximate nearest-neighbour indexes — all in the same database as my relational data. No extra infrastructure.

2. Embedding quality matters more than model size. Gemini text-embedding-004 at 768 dimensions consistently surfaces more semantically relevant grants than I initially expected. The key is embedding the combined signal — title + description + eligibility — not just the title.

3. HTMX makes server-side rendering feel modern. I built the entire reactive UI — live results, loading spinners, inline draft generation — without a single line of custom JavaScript. The hx-post, hx-target, and hx-indicator attributes replaced what would have been a full React + API layer. This kept the codebase lean and the deployment artifact small.

4. golang-migrate's file naming convention is critical. Migration files must be named 000001_name.up.sql — not 001_name.sql. Discovering this silently-skipped my entire schema on first deploy was a memorable debugging session.


⚡ Challenges

Cold-start latency on Railway

The free Railway tier spins down containers after inactivity. The first request after a cold start triggers the Grants.gov scraper + 100 embedding API calls before the server is fully warm. I moved scraping to a background goroutine so the HTTP server becomes responsive immediately, and scraping happens asynchronously.

Grants.gov date format

The Grants.gov API returns deadline dates as "MMDDYYYY" strings (e.g. "06302025"), not ISO 8601. Go's time.Parse uses reference-time formatting ("01022006"), which took longer to get right than I'd like to admit.

Template function registration

Go's html/template doesn't include a mul function. The templates use {{ mul .Score 100.0 }} to display match percentages — but this requires registering a custom FuncMap before parsing the template glob. If you call ParseGlob first and Funcs second, Go panics. The correct order is New("").Funcs(fm).ParseGlob(...).

Balancing prompt specificity

Early drafts from Gemini were generic. Adding a strict 5-section structure to the prompt — opening, problem statement, proposed project, impact metrics, closing — dramatically improved output quality and made results more useful to real grant writers reviewing the drafts.

Built With

Share this project:

Updates