Skip to content

Semantic Recipe Search with LangChain, pgvector, and Local Embeddings

Keyword search fails on intent. 'Quick weeknight chicken' shares zero words with 'Fish Finger Sandwiches' but it's the right answer.

python langchain pgvector ollama ai

Keyword search fails on intent. “Quick weeknight chicken” shares zero words with “Fish Finger Sandwiches” but it’s the right answer.

The fix is embeddings. Turn each piece of text into 768 numbers that encode meaning. Similar meanings end up as nearby points in 768-dimensional space. The model has no idea what food is. It just knows that in the text it was trained on, “kid-friendly” and “for the kids” appeared in similar contexts. Pattern matching on language, not culinary reasoning.

I’m using nomic-embed-text via Ollama. 137M parameters, runs locally on nixie. No API calls, no usage limits, no sending the family’s eating habits to OpenAI.

What Gets Embedded

Each recipe goes through Pydantic validation first. Bad data in, bad results out. Same as anything:

class RecipeInput(BaseModel):
    title: str = Field(max_length=255)
    description: str = Field(default="", max_length=4096)
    ingredients: list[str] = []
    method: list[str] = []
    prep_time_mins: int | None = Field(default=None, ge=0, le=1440)
    cook_time_mins: int | None = Field(default=None, ge=0, le=1440)
    serves: int | None = Field(default=None, ge=1, le=100)
    dietary_tags: list[str] = []
    cuisine: str = Field(default="", max_length=100)
    source: str = Field(default="manual", max_length=50)

    def embed_text(self) -> str:
        parts = [self.title]
        if self.description:
            parts.append(self.description)
        parts.append(", ".join(self.ingredients))
        if self.cuisine:
            parts.append(self.cuisine)
        return "\n".join(parts)

embed_text() controls what the model sees. Title and description carry the semantic weight. Ingredients add specificity. Without them, “pasta” and “curry” are equally close to “easy dinner”. Cuisine helps with “I want something Italian”.

I left out the method steps. “Fry the onion” appears in half the recipes and just adds noise.

ChromaDB to pgvector

Started with ChromaDB because it’s the default vector store in LangChain.

Then I looked at what I’d actually need: ChromaDB for vectors, SQLite for family preferences, LangGraph’s SqliteSaver for workflow checkpoints. Three storage layers. Three things to back up. Three things to break.

PostgreSQL with pgvector does all three. One database. But the real reason I switched is combined queries. ChromaDB can find “recipes similar to X”. I needed “recipes similar to X that are also gluten-free and take under 30 minutes”:

async def search_recipes(
    pool: AsyncConnectionPool,
    embedder: Embedder,
    query: str,
    limit: int = 10,
    dietary_filter: list[str] | None = None,
    max_prep_mins: int | None = None,
) -> list[dict]:
    vectors = await embedder.embed([query])
    query_vec = str(vectors[0])

    conditions = []
    params: list = [query_vec]

    if dietary_filter:
        conditions.append("dietary_tags @> %s::text[]")
        params.append(dietary_filter)

    if max_prep_mins is not None:
        conditions.append("prep_time_mins <= %s")
        params.append(max_prep_mins)

    where = ""
    if conditions:
        where = "WHERE " + " AND ".join(conditions)

    params.append(limit)

    sql = f"""
        SELECT id, title, description, ingredients, cuisine,
               prep_time_mins, cook_time_mins, serves, dietary_tags,
               embedding <=> %s::vector AS distance
        FROM recipes
        {where}
        ORDER BY distance
        LIMIT %s
    """

    async with pool.connection() as conn:
        result = await conn.execute(sql, params)
        rows = await result.fetchall()
        columns = [desc.name for desc in result.description]
        return [dict(zip(columns, row)) for row in rows]

Vector similarity and metadata filtering in one query. The <=> operator is pgvector’s cosine distance. Once retrieval needs structured filters alongside semantic similarity, splitting them across two stores costs you more than it saves.

Schema and indexing

CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE recipes (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    title TEXT NOT NULL,
    description TEXT NOT NULL DEFAULT '',
    ingredients JSONB NOT NULL DEFAULT '[]',
    method JSONB NOT NULL DEFAULT '[]',
    prep_time_mins INTEGER,
    cook_time_mins INTEGER,
    serves INTEGER,
    dietary_tags TEXT[] NOT NULL DEFAULT '{}',
    cuisine TEXT NOT NULL DEFAULT '',
    source TEXT NOT NULL DEFAULT 'manual',
    embedding vector(768),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_recipes_embedding ON recipes USING hnsw (embedding vector_cosine_ops);
CREATE INDEX idx_recipes_dietary ON recipes USING gin (dietary_tags);

HNSW for the embedding column, GIN for the tag array. HNSW over IVFFlat because at this scale the higher build cost and memory overhead don’t matter. At 500 recipes the vector dataset is about 1.5MB. IVFFlat is the right call when you’re paging cold partitions off disk; here everything is in RAM permanently. GIN on the tag array means “which recipes contain vegetarian?” doesn’t scan every row. Overkill at five recipes. Costs nothing to add. Saves you thinking about it later.

Does It Work?

Search for “quick weeknight chicken”:

  1. Chicken Tikka Masala [gluten-free] | prep: 40m | cook: 25m | similarity: 0.62
     Creamy spiced chicken curry. Britain's favourite.

  2. Spaghetti Bolognese | prep: 10m | cook: 35m | similarity: 0.45
     Classic Italian meat sauce with pasta. A family weeknight staple.

  3. Quick Pesto Pasta [vegetarian] | prep: 2m | cook: 10m | similarity: 0.42
     10-minute midweek dinner when you can't be bothered.

Tikka Masala first, despite “weeknight” appearing nowhere in the recipe. The embeddings picked up on “quick chicken” ↔ “chicken curry”. Pesto caught “quick” ↔ “midweek dinner”.

40 minutes prep isn’t exactly “quick” though. That’s a filter problem, not a search problem. Add --max-prep 15 and Tikka Masala drops out. The semantic part is working. The constraint logic layered on top is where it gets practical.

The sample data is five recipes I typed in by hand. Our actual family meals. Fish Finger Sandwiches is labelled “Millie’s favourite”, which is true and also means the embeddings will associate it with kid-related queries. Intentional bias in the training data, for once working in my favour.

Where This Approach Earns It

Embeddings earn their keep when the user’s intent and the canonical text disagree on vocabulary. Recipe search is one of those cases. Code search is another. Anything where users describe things the way they think, rather than the way the document was written.

When the vocabulary lines up, keyword wins on accuracy and cost. Controlled-language tag sets, enums, SKUs. The interesting choice is rarely “should I use embeddings”. It’s “where does the semantic layer end and the structured filter begin”. Get that line wrong and you either ask an LLM to do work a SQL WHERE clause would do faster, or you ship keyword search with embedding overhead and call it AI.

This is just the search layer. It finds things. It doesn’t plan meals, reason about what we had on Tuesday, or know that Millie has declared herself a noodletarian this week. That’s the LangGraph piece. But you need to find recipes before you can plan with them.