How I Built a Vector Search System for 300+ Venues
The Multi-Venue Intelligence Platform
I built a platform called Swindo that aggregates information about over 300 venues in Swindon. Restaurants, pubs, cafes, entertainment spots, and more. The challenge was not just collecting the data, but making it searchable in a way that feels natural. People do not search for venues with keywords. They search with intent: "somewhere quiet for a business lunch" or "fun place for a birthday with kids."
Traditional keyword search falls apart with these queries. Vector search handles them beautifully.
Data Collection from 11 Sources
Each venue's profile is built from multiple data sources:
- Google Places API for basic info, ratings, and reviews
- TripAdvisor for visitor reviews and rankings
- Social media profiles for current events and atmosphere
- Council licensing data for operational details
- Manual curation for local knowledge that APIs miss
The data from these sources gets normalised into a unified venue profile. Each profile contains structured fields (name, address, cuisine type, price range) plus unstructured text (review summaries, atmosphere descriptions, notable features).
The Embedding Strategy
The key insight was what to embed. I do not just embed the venue name or a raw dump of all data. Instead, I generate a rich "venue narrative" for each location:
def generate_venue_narrative(venue: VenueProfile) -> str:
prompt = f"""Write a 200-word description of this venue that captures
its atmosphere, ideal use cases, standout features, and who would
enjoy it most. Be specific and vivid.
Venue data: {json.dumps(venue.to_dict())}"""
response = claude_client.messages.create(
model="claude-haiku-4-20250414",
max_tokens=400,
messages=[{"role": "user", "content": prompt}]
)
return response.content[0].text
This narrative becomes the primary text that gets embedded. It is much richer than raw data and captures the nuances that make semantic search work well.
pgvector Setup
I chose pgvector over standalone vector databases like Pinecone or ChromaDB for a simple reason: I already had PostgreSQL for the structured data. Adding pgvector meant I could store embeddings alongside venue profiles in the same database, simplifying the architecture significantly.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE venue_embeddings (
venue_id INTEGER REFERENCES venues(id),
embedding vector(1536),
narrative TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX ON venue_embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 20);
With 300 venues, an IVFFlat index with 20 lists gives excellent query performance. Searches return in under 10 milliseconds.
Hybrid Search
Pure vector search works well for natural language queries, but sometimes users want to apply hard filters too. "Italian restaurant near the town centre" has both a semantic component (the overall vibe) and structured filters (cuisine type, location).
I implemented a hybrid approach:
async def search_venues(query: str, filters: dict = None) -> list:
# Generate query embedding
query_embedding = await get_embedding(query)
sql = """
SELECT v.*, ve.narrative,
1 - (ve.embedding <=> $1::vector) AS similarity
FROM venues v
JOIN venue_embeddings ve ON v.id = ve.venue_id
WHERE 1=1
"""
params = [query_embedding]
if filters:
if filters.get("cuisine"):
sql += " AND v.cuisine_type = $2"
params.append(filters["cuisine"])
if filters.get("max_price"):
sql += f" AND v.price_range <= ${len(params)+1}"
params.append(filters["max_price"])
sql += " ORDER BY similarity DESC LIMIT 10"
return await db.fetch(sql, *params)
Keeping Embeddings Fresh
Venue data changes. New reviews come in, menus update, opening hours shift. I run a nightly job that checks for venues with updated data and regenerates their narratives and embeddings. This keeps the search results current without re-embedding the entire database every day.
Search Quality Evaluation
How do you know if your semantic search is actually good? I built a simple evaluation framework with 50 test queries and manually rated expected results. After each change to the embedding strategy or narrative generation, I run the test suite and compare scores.
Some results from the evaluation:
- "Quiet place for a work meeting" correctly returns cafes with noted work-friendly atmospheres and private areas
- "Fun night out with friends" surfaces bars and entertainment venues with high energy ratings
- "Romantic dinner" finds fine dining and intimate restaurants, not family chain restaurants
User-Facing Search Interface
The search interface on Swindo is designed for simplicity. Users type a natural language query into a search bar, and results appear ranked by relevance with similarity scores hidden behind the scenes. Each result shows the venue name, a brief excerpt from the narrative, the star rating, and the distance from the user's location if they allow geolocation. I also added category pills that users can tap to filter results quickly, combining the semantic search with hard filters in a way that feels intuitive.
Reindexing and Maintenance
As venue data changes over time, I need to regenerate narratives and recompute embeddings. I built a simple admin interface that flags venues with stale data (last updated more than 30 days ago) and lets me trigger reindexing for individual venues or in bulk. The reindexing process takes about 2 seconds per venue including the API call for narrative generation and embedding computation. For the full database of 300+ venues, a complete reindex takes about 15 minutes and costs roughly 50p in API calls.
Lessons Learned
The biggest takeaway is that what you embed matters more than how you embed it. Switching from raw venue data to AI-generated narratives improved search relevance by roughly 40% on my test suite. The embedding model itself (I use OpenAI's text-embedding-3-small) is almost commodity at this point. The differentiator is the quality of the text you feed into it.
pgvector is more than sufficient for datasets of this size. I see people reaching for Pinecone or Weaviate for a few hundred records, and it is overkill. If you already have PostgreSQL, pgvector adds vector search without adding operational complexity. That matters when you are a solo developer running the infrastructure yourself.