Skip to main content

Building an AI Wine Concierge That Recommends a Bottle and Says Why

March 15, 2026 · Updated October 2026 · 12 min read

First written in March 2026, updated in May 2026 for the move to an openly licensed catalog and to Claude Sonnet 4.6 and for the start of beer, spirits and cocktails, and revised in October 2026.

Retrieval-augmented generation (RAG) means the AI looks up real data before it answers, instead of answering from memory. Vinny is a consumer-facing AI wine concierge I built, and the hard parts of its retrieval were combining data sources with different schemas, fitting inside a storage ceiling, tuning search quality, and keeping a wine persona from inventing wines.

Vinny searches two tiers at once: a catalog of about 100,000 openly licensed wines and 181 Wikipedia articles on grapes, regions and winemaking, through one Supabase search function over pgvector HNSW indexes. It began on a smaller review dataset licensed for non-commercial use only, inside a 500MB storage ceiling, and the sections below cover the choices that ceiling forced and how the catalog later moved to an open license.

Why Two Tiers?

Wine questions fall into two distinct categories. "What's a good bold red under $30?" requires searching actual bottle data with tasting notes, scores, and prices. "What's the difference between Chablis and other Chardonnay?" requires domain knowledge about terroir, winemaking techniques, and regional classification.

A single-source RAG system forces you to choose: either your AI knows specific bottles but can't explain why a Burgundy Pinot Noir tastes different from an Oregon one, or it knows wine theory but can't recommend an actual bottle.

The two-tier approach separates these concerns while unifying retrieval:

flowchart TD
  accTitle: Two-tier RAG retrieval for the beverage concierge
  accDescr: A user question is checked for bottle-shopping intent. Shopping intent routes to a Tavily web search; otherwise the query is embedded and sent to a single Supabase RPC that searches Tier 1 wine catalog and Tier 2 knowledge articles over pgvector HNSW indexes. Candidates are reranked by Cohere and attributed to their source, then added to a system prompt carrying the sommelier persona, user preferences, and tasting journal before Claude Sonnet 4.6 produces a grounded recommendation.
  q["User question"] --> intent{"Bottle-shopping intent?"}
  intent -->|"yes"| web["Tavily web search"]
  intent -->|"no"| embed["Embed query"]
  embed --> rpc["Unified Supabase RPC"]
  subgraph retrieval["pgvector HNSW retrieval"]
    rpc --> t1[("Tier 1: wine catalog")]
    rpc --> t2[("Tier 2: 181 knowledge articles")]
  end
  t1 --> rerank["Cohere rerank"]
  t2 --> rerank
  web --> ctx
  rerank -->|"results + source attribution"| ctx["System prompt: persona + preferences + journal"]
  ctx --> llm["Claude Sonnet 4.6"]
  llm --> ans["Grounded recommendation"]

Tier 1: Wine Catalog. The first catalog came from a Kaggle review dataset licensed for non-commercial use, filtered to reviews of 88 points or more as a quality gate. Each review includes the title, tasting description, variety, winery, country, province, score, and price. The embedding text combines title + description + variety, with title weighted first as the highest-signal identifier. (This original Kaggle corpus was later re-sourced to an openly licensed dataset; see Catalog Re-Source and Embedding Modernization below.)

Tier 2: Domain Knowledge (181 articles, 1,103 chunks). Curated Wikipedia articles across 10 categories: grape varieties, wine regions, appellations, classification systems, winemaking techniques, wine types, service, education, science, and history. Articles are chunked by paragraph boundaries at roughly 500 words per chunk.

Embedding Strategy: 512 Dimensions Under a Storage Ceiling

Storage was the binding constraint: a 500MB ceiling. At 1536 dimensions (the default for text-embedding-3-small), each vector consumes ~6.2KB. Multiply by 72,000+ records and you've blown the budget before adding indexes.

OpenAI's text-embedding-3-small natively supports a dimensions parameter. Setting it to 512 reduces per-vector storage to ~2KB, a 3x reduction. The entire wine corpus fits in ~146MB, leaving room for the knowledge base, HNSW indexes, user profiles, and tasting journal entries.

Embeddings were later moved back to the full native 1536 dimensions; see Catalog Re-Source and Embedding Modernization below.

Unified Search: One RPC, Two Sources

Both tiers use the same vector(512) column type, each with its own HNSW index. One Supabase RPC queries both tables and returns results tagged by source:

SQL
CREATE OR REPLACE FUNCTION match_wine_documents(
  query_embedding vector(512),
  match_threshold float default 0.5,
  match_count int default 10,
  source_filter text default null
)
RETURNS TABLE (
  id bigint, source text, title text,
  content text, metadata jsonb, similarity float
)

The source field tells the system prompt whether a result is a specific bottle recommendation (wines) or educational context (knowledge). Vinny's persona uses this to format citations differently, inline numbered references for bottles, contextual explanations for knowledge.

The 0.5 similarity threshold was tuned through test queries. Representative wine questions scored 0.60-0.64 similarity against relevant results, so 0.5 provides comfortable headroom without flooding results with noise.

Conversation Design: The Sommelier Persona

Vinny's system prompt establishes a persona: "a friendly sommelier with the charm of a dinner party host and the expertise of a Master of Wine." The technical constraints around that persona did more work than the voice itself:

No markdown headers in responses. Wine conversation should flow like prose, not documentation. The prompt explicitly forbids structured output formatting in chat responses.

Vintage handling via prompt, not data. Wine reviews reference specific vintages (2018 Caymus Cabernet), but recommending a specific vintage that's no longer available is a recommendation the reader cannot act on. The prompt instructs Vinny to present wines as producer/style recommendations with the caveat that catalog pricing may not reflect current retail.

Tool routing by intent. The chat endpoint detects explicit bottle-shopping intent ("where can I buy Caymus?") and routes to a web search tool before the general vector search. Pronoun follow-ups ("where can I buy this?") after Vinny recommends a wine are handled by injecting the current bottle context into the search query.

Input hygiene. All user-writable fields (display name, tasting notes, grape preferences) are sanitized for control characters and capped in length before they go into the system prompt, and the persona prompt tells the model to ignore instructions found in user content. That limits prompt injection; it does not prevent it.

Personalization: Preferences + Tasting Journal

Authenticated users complete a 6-step onboarding quiz that captures preferred styles, sweetness/body preferences, favorite grapes, budget range, experience level, and typical occasions. These preferences are appended to the system prompt for every chat request.

The tasting journal adds a temporal dimension. Users log wines they've tried with ratings and notes. The 20 most recent entries are injected into context, so Vinny can reference past preferences: "You gave that Barolo an 85 last month, the Nebbiolo I'm suggesting has a similar tannic structure but more approachable early drinking."

All personalization data is protected by Supabase Row Level Security. The policy is simple: auth.uid() = id for profiles, auth.uid() = user_id for journal entries. No custom session middleware needed.

MCP: Vinny as a Service

Beyond the chat UI, Vinny exposes its search tools via the Model Context Protocol. External AI agents (like Claude Desktop) can query the catalog through a JSON-RPC 2.0 endpoint.

The MCP endpoint authenticates via bearer token with constant-time comparison (crypto.timingSafeEqual), rate limits at 100 calls/day via Upstash Redis, and forces Node.js runtime for crypto API compatibility. Each request spins up a fresh McpServer instance, stateless by design for serverless deployment on Vercel.

That makes the wine catalog a reusable API any agent framework can call, not just the chat UI's backend.

Rate Limiting: Three Tiers

Every chat message calls a paid model API, so uncontrolled usage turns directly into spend. Rate limiting bounds that exposure.

The rate limiter uses Upstash Redis with a sliding window algorithm:

  • Anonymous users: 10 messages/day by IP address
  • Authenticated users: 50 messages/day by user ID
  • MCP clients: 100 calls/day by static key

Separate Redis prefixes prevent quota collisions between tiers. The middleware intercepts requests before they reach the chat endpoint, so rejected requests never reach the model.

Data Pipeline: Idempotent Ingestion

The Kaggle dataset required careful handling. The ingestion script processes CSV records in 100-wine batches, generates embeddings via embedMany(), and upserts with a composite unique constraint on (title, taster_name). This makes re-runs fully idempotent: you can restart a failed ingestion without duplicating records.

Wikipedia ingestion follows the same pattern: fetch articles via the MediaWiki API, chunk by paragraph boundaries, batch embed, and upsert with a (title, chunk_index) unique constraint.

A subtle implementation detail: standard fetch with connection keep-alive caused issues with Supabase's connection pooler during bulk upserts. A custom noKeepAliveFetch wrapper resolved the undici connection pooling bugs.

Designing Around Constraints

Three constraints shaped the architecture from day one, and each one drove a specific design choice:

  • 512-dim embeddings kept the original corpus plus HNSW indexes inside the storage ceiling (later moved to halfvec(1536); see Catalog Re-Source and Embedding Modernization).
  • Sliding-window rate limiting bounds API spend before a request ever reaches the model.
  • Edge-compatible middleware keeps compute light enough to run on serverless.

What I'd Do Differently

Chunk size tuning matters more than embedding dimensions. I optimized vector dimensions hard and chunk boundaries barely at all. The Wikipedia paragraph-based chunking works, but variable-length chunks create inconsistent retrieval quality. Fixed-size chunks with overlap would likely improve results.

I'd adopt the Vercel AI SDK's tool calling earlier. The tool() abstraction with Zod schemas made adding web search, journal writes and bottle verification straightforward.

RLS is underrated for AI applications. Row Level Security removes a common class of authorization bugs: the policy is one line of SQL, and it applies to every query made with a user's session, from chat context injection to journal CRUD and profile updates.

Adding Beer, Spirits and Cocktails (May 2026)

Vinny shipped wine-first. The obvious next question: what about beer, spirits and cocktails? The next phase expanded the data model to cover all four beverage categories. The core design question: do this with one polymorphic beverages table, or separate tables per category?

I picked separate tables, one per beverage type. Three reasons:

The columns differ by category. Wine has 15+ wine-specific columns (points, variety, winery, body, acidity, harmonize, vintage). Beer needs ibu, srm, style, substyle, is_craft, serving_temp. Spirits need proof, age_statement, cask_type, botanicals. Cocktails need ingredients (JSONB), technique, glassware, family, ice_type. A polymorphic table ends up with 50+ mostly-NULL columns. Index efficiency degrades when most rows don't use most indexed columns.

Category-scoped search. Separate HNSW indexes per table mean a query for "hoppy IPA" searches only beer rows, instead of pulling wine rows into the candidate set and filtering them out afterwards.

RPC type safety. The existing hybrid_search_wines RPC uses typed filter parameters (filter_variety, filter_min_body). The same pattern extends cleanly: hybrid_search_beers takes filter_style and filter_min_ibu. A polymorphic approach would need either one RPC with 30+ nullable parameters or runtime dispatch logic inside the RPC.

The costs: more tables and functions to maintain (three new tables, three new hybrid search functions, three new index sets). The model sees one search_beverages tool with a category parameter, and the backend dispatches to the matching search function. Cross-category food pairings (e.g., "what pairs with steak?") require fan-out, through a search_beverage_pairings function over a food_pairings table unified by a beverage_domain column; that pairing search is built in the database, not yet a chat tool.

Data sources for the new categories:

  • Wikidata (CC0): openly licensed spirit products (whisky, gin, rum, tequila, vodka, brandy). The spirits catalog is loaded.
  • Catalog.beer (CC BY 4.0): openly licensed beer data. The ingest and in-app attribution are built; the load is pending.
  • TheCocktailDB and the IBA list: ingredients, glassware, technique and family. The load is pending.

Migration is purely additive. The wines table is never modified. New tables, new RPCs, new indexes, so the existing wine search path stays as it was.

Catalog Re-Source and Embedding Modernization (May 2026)

Two changes landed after the original build, both about putting the project on durable footing.

The wine catalog moved to an open license. The first catalog was seeded from a Kaggle WineEnthusiast dataset, the review data described above. That data is licensed CC BY-NC-SA, which carries use restrictions. To keep the catalog on an open license with none, I re-sourced the entire wine catalog to the CC0-licensed X-Wines dataset (~100K wines) and purged every non-CC0 row. Grapeminds stays in the stack as a live wine API for tasting notes, drinking windows, and region detail; it was always an enrichment layer, never the bulk catalog.

Embeddings moved to the full native dimensions. The original 512-dimension vectors were a storage optimization to fit the corpus plus HNSW indexes inside the 500MB storage ceiling. Postgres halfvec changed the trade-off: it stores each vector component in 2 bytes instead of 4, halving the ~6.2KB that a full-precision 1536-dimension vector would cost to roughly 3.1KB. That made text-embedding-3-small's native 1536 dimensions affordable, so the catalog was re-embedded at 1536 dimensions and stored as halfvec(1536). Retrieval now keeps every dimension while staying inside the storage budget.

Tech Stack

  • Framework: Next.js 16, React 19, TypeScript
  • Database: Supabase (Postgres 15 + pgvector HNSW), one table per beverage category
  • AI: Claude Sonnet 4.6 (chat, with prompt caching), GPT-5.4 (images, and failover if the Anthropic call fails before streaming starts), Claude Haiku 4.5 (past an optional monthly venue spend cap), text-embedding-3-small (1536-dim, stored as halfvec(1536))
  • AI SDK: Vercel AI v6 (tool-loop agent, useChat, tool(), provider failover)
  • Reranking: Cohere rerank-v4.0-fast
  • Auth: Supabase Auth (Email + Google OAuth)
  • Rate Limiting: Upstash Redis (sliding window)
  • Web Search: Tavily (domain-constrained, verified URLs)
  • Data sources: X-Wines (CC0 wine catalog), Wikipedia wine articles, Grapeminds (live wine API), Wikidata (CC0 spirits, loaded); Catalog.beer (beer) and TheCocktailDB with the IBA list (cocktails) built, loads pending
  • MCP: @modelcontextprotocol/sdk (JSON-RPC 2.0 server)
  • Hosting: Vercel (serverless)