Skip to content
|By Muhammad Wilayat Shah|3 views • 2 unique IPs

How to Build Semantic Property Search with PostgreSQL and Embeddings

Keyword search misses what buyers actually mean. Learn how to add semantic property search to PostgreSQL with embeddings and pgvector, including hybrid filters, freshness, and pitfalls.

Featured image
Featured image

A buyer types "quiet family home near good schools with a garden" into your property portal, and your search returns nothing, because no listing contains that exact phrase. Another types "flat" while your agents write "apartment". Keyword search matches words, but people search with intent. If you build PropTech, ERP, or real-estate platforms on PostgreSQL, you don't need a separate search cluster to close that gap. With embeddings and the pgvector extension, you can add semantic property search to the database you already run.

Why Keyword Search Fails for Property Intent

Traditional search, whether that's LIKE queries, PostgreSQL full-text search, or a basic Elasticsearch setup, works on tokens. That's fine for exact matches like a listing reference, a street name, or a postcode. It breaks down when the user describes a lifestyle instead of a feature list:

  • Synonyms and local vocabulary: "flat" vs "apartment", "kanal" vs "plot size", "lounge" vs "living room".

  • Implied features: "good for working from home" might mean a spare room, a quiet street, or fibre internet, and none of those words may show up in the query.

  • Vague qualities: "bright", "cozy", "modern feel" are written in many different ways across listing descriptions.

  • Mixed languages: in many markets, agents and buyers mix English with local languages in the same description.

Embeddings handle this by turning text into vectors (arrays of numbers) where texts with similar meaning end up close together. You compare meaning, not spelling.

Step 1: Build a Good Text Representation of Each Listing

What you embed matters more than which model you pick. Don't embed only the raw description. Put together a short, consistent document per listing that includes the fields a buyer would care about:

Code
function listingToEmbeddingText(l: Listing): string {
  return [
    `${l.propertyType} for ${l.purpose} in ${l.area}, ${l.city}.`,
    `${l.bedrooms} bedrooms, ${l.bathrooms} bathrooms, ${l.size} ${l.sizeUnit}.`,
    l.features.length ? `Features: ${l.features.join(", ")}.` : "",
    l.description.trim(),
  ].filter(Boolean).join("\n");
}

Keep hard numbers like price and bedroom count as normal columns too. You'll filter on them precisely. The embedding is for the fuzzy part.

Step 2: Store Vectors in PostgreSQL with pgvector

pgvector is a widely used open-source extension that adds a vector column type, distance operators, and approximate nearest-neighbour indexes to PostgreSQL. Many managed providers support it, but check that yours does before you plan around it.

Code
CREATE EXTENSION IF NOT EXISTS vector;

ALTER TABLE listings
  ADD COLUMN embedding vector(1536),        -- must match your model's output size
  ADD COLUMN embedding_model text,
  ADD COLUMN embedding_hash text,           -- hash of the text that was embedded
  ADD COLUMN embedded_at timestamptz;

CREATE INDEX listings_embedding_hnsw
  ON listings USING hnsw (embedding vector_cosine_ops);

The dimension (1536 above) depends on your embedding model, so set it to whatever your model returns. pgvector offers HNSW and IVFFlat index types. HNSW is a common default for read-heavy search. IVFFlat builds faster but works best when it's built after the table already has representative data. Both are approximate, so they trade a little recall for speed.

Diagram of property listings flowing into embeddings stored in PostgreSQL for semantic search

Generating Embeddings in Laravel

Here's a minimal job that embeds a listing with an HTTP embeddings API. The example uses OpenAI's endpoint, but the pattern works the same with other providers or a self-hosted model.

Code
class EmbedListing implements ShouldQueue
{
    use Dispatchable, Queueable;

    public function __construct(public int $listingId) {}

    public function handle(): void
    {
        $listing = Listing::findOrFail($this->listingId);
        $text = $listing->toEmbeddingText();
        $hash = hash('sha256', $text);

        if ($listing->embedding_hash === $hash) {
            return; // nothing meaningful changed
        }

        $vector = Http::withToken(config('services.openai.key'))
            ->post('https://api.openai.com/v1/embeddings', [
                'model' => 'text-embedding-3-small',
                'input' => $text,
            ])->throw()->json('data.0.embedding');

        DB::update(
            'UPDATE listings SET embedding = ?::vector, embedding_model = ?,
                    embedding_hash = ?, embedded_at = now() WHERE id = ?',
            ['[' . implode(',', $vector) . ']', 'text-embedding-3-small', $hash, $listing->id]
        );
    }
}

Step 3: The Similarity Query Pattern

At search time, embed the user's query with the same model, then order by distance. With pgvector, <=> is cosine distance, <-> is Euclidean (L2) distance, and <#> is negative inner product. The operator has to match the operator class you indexed with.

Code
SELECT id, title, price, 1 - (embedding <=> $1) AS similarity
FROM listings
WHERE embedding IS NOT NULL
ORDER BY embedding <=> $1
LIMIT 20;

Pass the query vector as a parameter ($1), and never build the query by string interpolation. Write the ORDER BY as "distance ascending" so PostgreSQL can use the index.

Step 4: Hybrid Search with Filters and Semantics

Real buyers have hard constraints. A three-bedroom house in the right city and budget that's only "pretty similar" is worth more than a perfect semantic match in the wrong city. Combine structured filters with semantic ranking:

Code
SELECT id, title, price, area
FROM listings
WHERE status = 'active'
  AND city = $2
  AND purpose = 'sale'
  AND price BETWEEN $3 AND $4
  AND bedrooms >= $5
ORDER BY embedding <=> $1
LIMIT 20;

You can go further and blend PostgreSQL full-text search with vector results. Run both queries, then merge them with a simple method like reciprocal rank fusion (score each result by 1 / (k + rank) in each list and add the scores up). Exact matches on terms like "corner plot" or a society name still rank highly, and intent-based matches still show up.

A TypeScript Search Endpoint

Code
// Next.js route handler (simplified)
export async function POST(req: Request) {
  const { query, city, minPrice, maxPrice, minBeds } = await req.json();
  const queryVector = await embed(query); // same model as indexing

  const { rows } = await pool.query(
    `SELECT id, title, price FROM listings
     WHERE status = 'active' AND city = $2
       AND price BETWEEN $3 AND $4 AND bedrooms >= $5
     ORDER BY embedding <=> $1 LIMIT 20`,
    [JSON.stringify(queryVector), city, minPrice, maxPrice, minBeds]
  );
  return Response.json(rows);
}

Popular search phrases repeat a lot, so caching query embeddings in Redis (keyed by a hash of the normalized query text) cuts both latency and API cost.

Step 5: Keeping Embeddings Fresh When Listings Change

Listings change all the time: price drops, renovated kitchens, edited descriptions, status changes. A few habits keep vectors accurate:

  1. Re-embed on meaningful changes only. Hash the embedding text and dispatch the job from a model observer or an outbox table. A price change doesn't need a new vector if price isn't in the embedded text.

  2. Keep status out of the vector. Filter on status = 'active' in SQL instead of re-embedding when a property is sold.

  3. Track the model. The embedding_model column lets you backfill in batches when you switch models. Vectors from different models are not comparable, so don't mix them in one query.

  4. Run a nightly sweep. Find rows where embedding IS NULL or embedded_at < updated_at and requeue them. This catches failed jobs.

Common Pitfalls

Stale Vectors

If the description says "newly renovated" but the vector was made from last year's text, results drift quietly. Hash checks and the sweep job above are your safety net, so watch the count of unembedded or outdated rows.

Over-Filtering

Approximate indexes like HNSW find a set of nearest candidates first, and your WHERE clause filters them afterwards. With very selective filters (one small neighbourhood, a narrow price band), you can get fewer results than your LIMIT, or none at all. Options include raising hnsw.ef_search for those queries, using partial indexes for high-traffic segments (for example, per city), using the iterative index scan settings if your pgvector version supports them (see its documentation), or letting the planner skip the vector index when filters already narrow things to a small set.

Cost and Latency

Every search query needs an embedding call, and every listing edit can trigger another one. Batch your backfills, cache query embeddings, and set timeouts with a keyword-search fallback so a slow provider never leaves users staring at a spinner. Larger vectors also use more storage and memory, so pick a dimension that fits your data size. Measure on your own data rather than trusting generic numbers.

Ignoring Evaluation

Collect 30–50 real queries from your search logs, decide which listings should come up for each one, and check results whenever you change the embedding text, the model, or the index settings. Semantic search feels right until you test it.

Conclusion

Semantic property search doesn't need a new stack. Write a clean text representation per listing, store embeddings in PostgreSQL with pgvector, rank by vector distance inside your normal SQL filters, and re-embed only when meaningful content changes. Start with one city or one listing type, compare results against your current keyword search using real buyer queries, and expand from there. Your users will notice the difference the first time they search for how a home should feel and find it.