Ask questions about recent market news and get answers grounded in retrieved sources.
Live: https://finance-rag-one.vercel.app
Yahoo Finance headlines are ingested, embedded into pgvector, and searched by semantic similarity. Every answer is composed only from the passages retrieved for that question, and those passages are printed underneath it so the answer can be checked.
FastAPI · Neon Postgres · pgvector · Gemini (gemini-embedding-2, gemini-flash-lite-latest) · yfinance
Runs entirely on free tiers.
- Fresh beats similar. Ranking is not similarity alone. Every passage carries a distance
penalty that grows with age — none when new, approaching
RECENCY_WEIGHTwhen ancient — so a stale story that matches well loses to a current one that matches nearly as well. Backdate the top hit by 60 days and it falls out of the results while its distance never changes. For a news corpus this is the difference between answering about today's market and answering about last quarter's. - The model declines rather than reaching. Ask it about Nvidia when the corpus has no Nvidia coverage and it says so, then describes the AMD reporting it did find. The answer is built from the retrieved passages alone, never from what the model happens to remember. Passages reach it dated, alongside today's date, so it can lead with the most recent.
- Questions and passages are embedded differently. Passages go in as
RETRIEVAL_DOCUMENT, questions asRETRIEVAL_QUERY. They are not interchangeable — embedding both as the same thing measurably degrades retrieval. - Duplicate passages are impossible, not merely discouraged. A unique index on
md5(chunk)plusON CONFLICT DO NOTHINGmeans re-running ingest adds nothing. Without it ak=3search happily returned three copies of one story as if they were three sources. - 768 dimensions, not 3072.
gemini-embedding-2is matryoshka, so the shorter vector is a truncation rather than a weaker model, and it arrives pre-normalised — which is why an L2 (<->) ordering ranks identically to cosine here.
git clone https://github.com/kaniikaaaa/FinanceRAG.git
cd FinanceRAG
python -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # then fill in DATABASE_URL and GEMINI_API_KEYNeeds a Postgres with pgvector available — Neon's free tier has it built in. A free Gemini key comes from AI Studio.
python -m app.migrate # extension, table, embedding column, unique index
python -m app.ingest AAPL TSLA # pull news (defaults to AAPL TSLA MSFT NVDA)
python -m app.embed # embed anything not yet embedded
uvicorn app.main:app --reloadRun migrate first: it owns the schema. ingest and embed are both safe to re-run —
ingest skips passages it already has, embed skips rows it has already embedded.
.github/workflows/ingest.yml runs migrate → ingest → embed against the same database
every six hours, and on demand from the Actions tab. It needs DATABASE_URL and
GEMINI_API_KEY as repository secrets.
This is the write path, and it deliberately does not live on Vercel: embedding is a batch job measured in minutes, which is the one thing a serverless function's timeout cannot accommodate. Vercel serves the read path, where a query is three network calls and no state of its own.
GET / the web UI (?q=... files a query directly)
GET /api/health status + the models actually in use
GET /api/headlines recent titles, for the ticker
POST /ask { "question": "what's driving chip stocks?" }
/api/health deliberately touches nothing but memory — it is the deploy health check, and
making it depend on Postgres would report the app as down whenever Neon is merely asleep.
/ask returns:
{
"question": "...",
"answer": "...",
"sources": [
{
"title": "...",
"content": "...",
"published_at": "2026-07-16T22:17:00+00:00",
"url": "https://..."
}
]
}published_at falls back to fetch time for passages stored before publish dates were
captured — it is the same date the ranking scores on.
Failures come back as {"detail": "..."} with a status that says where it broke:
503 if the database is unreachable, 502 if retrieval or the model failed, 404 if nothing
in the corpus matched.
app/
├── main.py FastAPI app — serves the UI and /ask
├── db.py connection (prefers DATABASE_URL, falls back to DB_* parts)
├── migrate.py owns the schema; run before ingest
├── ingest.py yfinance news fetcher
├── embed.py backfills embeddings
├── search.py vector search + answer composition
├── gemini.py Gemini client, model names, dimensions
└── vectors.py renders an embedding as a pgvector literal
web/
└── index.html the single-page UI
Both targets need the same two environment variables, DATABASE_URL and GEMINI_API_KEY,
and neither can read .env — that file never leaves your machine.
Render (render.yaml). New → Blueprint → pick this repo. Render reads the file and
asks for the two secrets, since they are marked sync: false and so are never committed.
Runs uvicorn as an ordinary long-lived process. On the free plan the service sleeps after
15 minutes idle and the next request pays roughly a minute to wake it.
Vercel. Autodetects app/main.py, so it needs no entrypoint config — adding a
pyproject.toml actually breaks the build, because its presence switches Vercel from
requirements.txt to uv, which then demands a [project] table. Set both variables in
Project Settings → Environment Variables and redeploy; a variable only reaches a new build,
never an existing deployment.
Nothing in the code knows which one it is running on. The app holds no state: it embeds a question, runs one query against Neon, and asks Gemini to write the answer. The vectors live in Postgres and the models live behind an API, so the process itself is disposable — which is why it runs equally well as a container or as a function.
MIT