A full-stack AI wine recommendation app that accepts natural language queries and returns ranked wine suggestions with sommelier-style explanations.
Example query: "bold red wine for steak under $30"
Built with Claude Code — Anthropic's AI coding assistant.
┌─────────────────────────────────────────┐
│ User (Browser) │
│ "bold red wine for steak $30" │
└─────────────────┬───────────────────────┘
│ POST /recommend
┌─────────────────▼───────────────────────┐
│ React Frontend (Vite) │
│ Search form → Wine cards + blurbs │
└─────────────────┬───────────────────────┘
│
┌─────────────────▼───────────────────────┐
│ Express Backend (Node.js) │
│ │
│ ┌─────────────┐ ┌────────────────┐ │
│ │ Redis Cache │◄───► /recommend │ │
│ │ (1h TTL) │ │ pipeline │ │
│ └─────────────┘ └───────┬────────┘ │
└────────────────────────────-│───────────┘
│
┌───────────────────▼───────────────────┐
│ OpenAI Embeddings API │
│ text-embedding-3-small │
│ query string → 1536-dim vector │
└───────────────────┬───────────────────┘
│
┌───────────────────▼───────────────────┐
│ FAISS Vector DB │
│ cosine similarity search │
│ returns top-20 wine candidates │
└───────────────────┬───────────────────┘
│
┌───────────────────▼───────────────────┐
│ Hybrid Ranker │
│ 0.35 semantic + 0.20 price_fit │
│ + 0.10 rating + 0.10 popularity │
│ → top-5 wines │
└───────────────────┬───────────────────┘
│
┌───────────────────▼───────────────────┐
│ GPT-4o-mini Explainer │
│ 1 sommelier blurb per wine │
│ single API call for all 5 wines │
└───────────────────────────────────────┘
Embeddings are the core of the RAG pipeline — they make semantic search possible.
"bold red wine for steak"
│
▼
OpenAI text-embedding-3-small
│
▼
[0.021, -0.134, 0.887, ...] ← 1536 numbers (a vector)
The idea: every piece of text — whether a user query or a wine description — gets converted into a list of 1536 numbers. Texts that mean similar things end up as vectors that point in the same direction in that 1536-dimensional space.
At index build time (build_index.py):
- Each wine's
descriptionfield is sent to the Embeddings API - The returned vector is stored in the FAISS index
- This runs once and is saved to disk
At query time (search.py):
- The user's query is sent to the same Embeddings API
- The returned query vector is compared against all stored wine vectors
- FAISS returns the 20 wines whose vectors are most similar (cosine similarity)
Why cosine similarity? It measures the angle between vectors, not their length — so "cheap red" and "affordable red wine" land near the same wines even though the words differ.
The model used here is text-embedding-3-small — cheap (~$0.02 per million tokens), fast, and good enough for wine descriptions.
| Layer | Technology |
|---|---|
| Frontend | React 19, Vite 8 |
| Backend | Node.js, Express 5 |
| Vector DB | FAISS (via Python) |
| Embeddings | OpenAI text-embedding-3-small |
| LLM | OpenAI gpt-4o-mini |
| Cache | Redis (ioredis) |
| Dataset | Kaggle Wine Reviews (130k wines) |
Wine-Recommendation-RAG-Fullstack-App/
├── back/
│ ├── index.js # Express server entry point
│ ├── routes/
│ │ └── recommend.js # POST /recommend — main pipeline
│ ├── services/
│ │ ├── vectorSearch.js # Calls search.py via child_process
│ │ ├── ranker.js # Hybrid scoring engine
│ │ ├── explainer.js # GPT-4o-mini explanation layer
│ │ └── cache.js # Redis get/set wrapper
│ ├── scripts/
│ │ ├── filter_data.py # Step 1: filter raw dataset
│ │ ├── build_index.py # Step 2: embed wines + build FAISS index
│ │ └── search.py # Step 3: query-time vector search
│ └── .env # API keys and config (not committed)
├── front/
│ └── src/
│ ├── App.jsx # Search form + wine result cards
│ ├── App.css # Component styles
│ └── index.css # Design tokens, global reset
├── data/
│ ├── winemag-data-130k-v2.json # Raw Kaggle dataset (not committed)
│ ├── wines_filtered.json # Filtered subset (not committed)
│ ├── wines.index # FAISS index (not committed)
│ └── wines_ids.json # Wine metadata for search results (not committed)
└── README.md
- Node.js 18+
- Python 3.9+
- Redis (running locally on default port 6379)
- OpenAI API key with billing enabled
Python dependencies:
Use a virtual environment to keep these isolated from your system Python:
cd back
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install numpy faiss-cpu openaiActivate this venv (source back/venv/bin/activate) whenever running the scripts below.
Redis (via Docker):
docker run -d --name wine-redis -p 6379:6379 redisStop/start later with docker stop wine-redis / docker start wine-redis. Verify it's up with docker exec wine-redis redis-cli ping (should return PONG).
Docker Desktop credential helper error? If
docker runfails withdocker-credential-desktop: executable file not found in $PATH, Docker Desktop'sdocker-credential-desktopsymlink is broken (common after an update). Fix by removing the"credsStore"line from~/.docker/config.json— no credential store is needed to pull public images likeredis.
Prefer not to use Docker? Install natively instead: brew install redis && brew services start redis.
# Backend
cd back
npm install
# Frontend
cd ../front
npm installCreate back/.env:
OPENAI_API_KEY=sk-...
REDIS_HOST=localhost
REDIS_PORT=6379
PORT=3001Download the Kaggle Wine Reviews dataset and place winemag-data-130k-v2.json in data/.
Filter the dataset (run once, with the venv from Prerequisites active):
source back/venv/bin/activate
python3 back/scripts/filter_data.pyThis produces data/wines_filtered.json.
By default filters to wines with
points >= 98(~116 wines) for low-cost testing. Change the threshold to93infilter_data.pyfor the full ~11.5k dataset.
Build the FAISS index (run once, costs ~$0.01–0.02 in OpenAI credits for the full dataset):
OPENAI_API_KEY=sk-your-actual-key-here python3 back/scripts/build_index.pyReplace
sk-your-actual-key-herewith your real key. Don't leave angle brackets (<...>) in the command — the shell treats<as input redirection and fails withno such file or directory.
This produces data/wines.index and data/wines_ids.json.
docker start wine-redis # or: docker run -d --name wine-redis -p 6379:6379 redisSee the Redis setup note under Prerequisites if you haven't created the container yet.
# Terminal 1 — backend (port 3001)
cd back
npm run dev
# Terminal 2 — frontend (port 5173)
cd front
npm run devOpen http://localhost:5173.
Request:
{ "query": "light white wine for seafood under $25" }Response:
{
"wines": [
{
"title": "...",
"variety": "Chardonnay",
"country": "France",
"price": 22,
"points": 98,
"score": 0.7341,
"explanation": "A crisp, mineral-driven Chardonnay that pairs perfectly with delicate seafood."
}
],
"fromCache": false
}Returns { "status": "ok" }.
Results are re-ranked after vector search using a weighted formula:
final_score =
0.35 × semantic_similarity (cosine similarity from FAISS)
0.20 × price_fit (how well price matches budget hint in query)
0.10 × rating (normalised points 93–100)
0.10 × popularity (inverse price proxy)
0.25 × food_pairing_score (reserved — not yet implemented)
Budget hints are parsed from natural language: "under $30", "30€", "30 dollars".
| Measure | Implementation |
|---|---|
| Secure HTTP headers | helmet() middleware — sets X-Content-Type-Options, X-Frame-Options, etc. |
| Rate limiting | express-rate-limit — max 10 requests/min per IP on /recommend |
| Input validation | Query string capped at 500 characters before reaching OpenAI API |
| CORS | Restricted to frontend origin via CORS_ORIGIN env variable |
| Env isolation | Child process (Python) receives only OPENAI_API_KEY, not full process.env |
| Secrets | .env is gitignored — use .env.example as template |
Note: The dataset license (CC BY-NC-SA 4.0) prohibits commercial use. Do not deploy this app commercially without replacing the dataset.
- Upgrade to full 11.5k wine dataset — change
points >= 98to>= 93infilter_data.pyand rebuild the index - Implement food pairing score (0.25 weight currently unused in ranker)
- Filter UI — dropdowns for country, variety, price range
- Docker Compose for one-command startup
- Unit tests for ranker scoring logic
- Error boundary in React for graceful frontend failures
- Python long-lived process — biggest win. Currently a new Python process starts on every request, loading the FAISS index from disk each time (~500ms–2s cold start). Fix: keep
search.pyrunning as a persistent process communicating via stdin/stdout JSON. - Embedding cache — on cache miss, the query is re-embedded every time even for near-identical queries. Cache the query vector in Redis alongside the result.
- Streaming LLM responses — stream GPT-4o-mini tokens to the frontend so the user sees blurbs appear progressively instead of waiting for all 5 at once.
- FAISS IVFFlat index — current
IndexFlatIPdoes exact search over all vectors (fine for 116 wines). At 11.5k+ wines, switch toIndexIVFFlatfor approximate nearest-neighbour search — much faster at scale with minimal accuracy loss.
The wine dataset used in this project is sourced from Kaggle:
Wine Reviews by zynicide on Kaggle
Original data scraped from WineEnthusiast (June 2017)
License: CC BY-NC-SA 4.0
This dataset is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0. This means the dataset and any derivatives may not be used for commercial purposes. This project is for educational and non-commercial use only.