Saturday, November 1, 2025

Intelligent Property Search with Relevance-Based Ranking

Paul (Founder)
Development

Property databases grow large quickly. Agencies managing hundreds of properties need search functionality finding relevant results fast. Alphabetical sorting works for small datasets but becomes unwieldy at scale—staff searching "Flat 3 High Street" shouldn't need to scroll past 50 "Flat 1" and "Flat 2" results reaching their target.

Week 44 implemented relevance-based property search ranking results intelligently rather than alphabetically. Exact reference matches appear first, properties matching search terms in headlines rank highly, recently updated properties receive priority boosts, and phonetic matching catches common misspellings. This article explores the ranking algorithm, PostgreSQL full-text search integration, balancing multiple relevance factors, and testing search quality.

What Your Team Will Notice

Property search now feels "smart" returning obviously relevant results at the top. Search for a property reference like "ABC123", and that exact property appears first even if alphabetically it would rank lower. Search for "High Street", and properties with "High Street" in their headlines appear before properties only mentioning it in descriptions.

Recently modified properties rank slightly higher, reflecting the reality that staff often search for properties they recently worked on. This subtle recency boost doesn't overwhelm other relevance factors but helps when multiple properties match equally—the one edited yesterday appears before the one untouched for months.

Partial matches still work: searching "Hig St" finds "High Street" properties through fuzzy matching, and common misspellings like "streat" still find "street". This forgiveness prevents dead-end searches requiring perfect spelling whilst maintaining relevance quality.

The search interface provides immediate feedback: as you type, results update showing match count ("Showing 12 properties matching 'High Street'"), and clear visual indicators highlight matching terms helping staff verify correct results quickly.

Under the Bonnet: Relevance Scoring Algorithm

The ranking algorithm combines multiple factors producing a composite relevance score:

# app/models/property.rb
class Property < ApplicationRecord
  include PgSearch::Model

  pg_search_scope :search_by_relevance,
    against: {
      reference: 'A',        # Highest weight
      headline: 'B',         # High weight
      address_line_1: 'C',   # Medium weight
      town: 'C',             # Medium weight
      description: 'D'       # Lower weight
    },
    using: {
      tsearch: {
        prefix: true,        # Match partial words
        any_word: true,      # Match any search term
        dictionary: 'english'
      },
      trigram: {             # Fuzzy matching for typos
        threshold: 0.3
      }
    },
    ranked_by: ":tsearch + :trigram + recency_boost(updated_at)"

  # Custom ranking function
  def self.recency_boost(field)
    # Properties updated recently get small boost
    # Logarithmic decay: 1.0 for today, ~0.5 for 30 days ago, ~0.1 for 6+ months
    "1.0 / (1.0 + EXTRACT(EPOCH FROM (NOW() - #{field})) / 2592000.0)"
  end

  # Search with relevance ordering
  def self.search_with_relevance(query)
    return all if query.blank?

    # Exact reference match always first
    exact_match = where("reference ILIKE ?", query.strip).limit(1)

    # Relevance-ranked results
    relevance_results = search_by_relevance(query)
                         .with_pg_search_rank
                         .order('pg_search_rank DESC, updated_at DESC')

    # Combine: exact match first, then relevance-ranked
    if exact_match.any?
      Property.from("(#{exact_match.to_sql} UNION #{relevance_results.where.not(id: exact_match.select(:id)).to_sql}) AS properties")
    else
      relevance_results
    end
  end
end

This algorithm prioritizes exact reference matches absolutely, then ranks remaining results by composite score combining text relevance, fuzzy matching, and recency.

Field Weighting

Different fields receive different weights reflecting their relevance:

Reference (Weight 'A'): Property references like "ABC123" are unique identifiers—matches here are almost certainly what staff seek.

Headline (Weight 'B'): Headlines contain core property identity ("Modern 2-bed flat, High Street")—strong matches indicate relevance.

Address (Weight 'C'): Address fields matter but may contain common terms ("Street", "Road") matching broadly.

Description (Weight 'D'): Descriptions mention many terms—matches here are less distinctive than headline matches.

This weighting prevents common words in long descriptions from outranking specific headline matches.

PostgreSQL Full-Text Search

PostgreSQL's built-in full-text search provides efficient ranking:

-- Migration adding full-text search indices
CREATE INDEX index_properties_on_search_fields ON properties
  USING gin(to_tsvector('english',
    coalesce(reference, '') || ' ' ||
    coalesce(headline, '') || ' ' ||
    coalesce(address_line_1, '') || ' ' ||
    coalesce(town, '') || ' ' ||
    coalesce(description, '')
  ));

-- Trigram index for fuzzy matching
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX index_properties_on_reference_trigram ON properties
  USING gin(reference gin_trgm_ops);
CREATE INDEX index_properties_on_headline_trigram ON properties
  USING gin(headline gin_trgm_ops);

These indices enable fast full-text search without external search engines like Elasticsearch, simplifying infrastructure whilst maintaining good performance for agencies with thousands of properties.

Trigram Fuzzy Matching

Trigrams enable "close enough" matching for typos:

# Searching "Hgh Street" (missing 'i')
# Trigram similarity: 0.7 (high similarity)
# Result: Matches "High Street"

# Searching "Main Rd" (abbreviation)
# Trigram similarity: 0.6
# Result: Matches "Main Road"

The threshold (0.3) balances forgiveness and precision—low enough to catch typos, high enough to avoid nonsense matches.

Controller Search Implementation

The controller integrates search with pagination and filtering:

# app/controllers/properties_controller.rb
class PropertiesController < ApplicationController
  def index
    @properties = Property.includes(:landlords, :current_tenancy)

    # Apply search if query present
    if params[:query].present?
      @properties = @properties.search_with_relevance(params[:query])
    else
      @properties = @properties.order(updated_at: :desc)
    end

    # Apply filters
    @properties = apply_filters(@properties, params)

    # Paginate
    @properties = @properties.page(params[:page]).per(25)

    # Store search query for highlighting
    @search_query = params[:query]
  end

  private

  def apply_filters(scope, params)
    scope = scope.where(marketing_status: params[:status]) if params[:status].present?
    scope = scope.where('beds >= ?', params[:min_beds]) if params[:min_beds].present?
    scope = scope.where('price <= ?', params[:max_price]) if params[:max_price].present?
    scope
  end
end

This controller applies search, then filters, then pagination—maintaining relevance ordering whilst respecting additional constraints.

Search Result Highlighting

The view highlights matching terms:

<!-- app/views/properties/index.html.erb -->
<% @properties.each do |property| %>
  <div class="property-card">
    <h3>
      <%= highlight_search_terms(property.reference, @search_query) %>
    </h3>
    <p class="headline">
      <%= highlight_search_terms(property.headline, @search_query) %>
    </p>
    <p class="address">
      <%= highlight_search_terms(property.display_address, @search_query) %>
    </p>
  </div>
<% end %>

<%# Helper method %>
<% def highlight_search_terms(text, query)
  return text if query.blank?

  # Highlight each query term
  query.split(/\s+/).inject(text) do |result, term|
    result.gsub(/(#{Regexp.escape(term)})/i, '<mark>\1</mark>')
  end.html_safe
end %>

This highlighting helps staff quickly verify they found the correct property without reading entire descriptions.

Balancing Multiple Ranking Factors

The composite score balances competing factors:

# Scoring example for "High Street" query
# Property A: reference "HS123", headline "Office on High Street"
#   - Reference partial match: 0.3
#   - Headline exact match: 0.9
#   - Recency (updated yesterday): 0.8
#   - Total: 2.0

# Property B: reference "ABC456", headline "Modern flat", description mentions "near High Street"
#   - Reference no match: 0.0
#   - Headline no match: 0.0
#   - Description partial match: 0.2
#   - Recency (updated last week): 0.5
#   - Total: 0.7

# Result: Property A ranks higher (more specific matches in important fields)

This multi-factor approach prevents any single factor from dominating—even very recent properties don't outrank strong text matches.

Testing Search Quality

Testing search requires verifying ranking quality:

RSpec.describe "Property search relevance" do
  let!(:exact_match) { create(:property, reference: 'ABC123') }
  let!(:headline_match) { create(:property, reference: 'XYZ789', headline: 'ABC123 Building') }
  let!(:description_match) { create(:property, reference: 'DEF456', description: 'Near ABC123') }

  it "ranks exact reference match first" do
    results = Property.search_with_relevance('ABC123')

    expect(results.first).to eq(exact_match)
  end

  it "ranks headline matches above description matches" do
    results = Property.search_with_relevance('ABC123')

    headline_position = results.index(headline_match)
    description_position = results.index(description_match)

    expect(headline_position).to be < description_position
  end

  it "handles partial matches" do
    results = Property.search_with_relevance('ABC')

    expect(results).to include(exact_match)
  end

  it "handles typos through fuzzy matching" do
    property = create(:property, headline: 'High Street Property')

    results = Property.search_with_relevance('Hig Street')

    expect(results).to include(property)
  end
end

These tests verify ranking behavior matches expectations across various search scenarios.

Performance Optimization

Search performance matters for user experience:

Index Coverage: GIN indices cover all searchable fields enabling index-only scans where possible.

Query Limits: Results cap at reasonable limits (100 maximum) preventing expensive full-table scans on broad queries.

Caching: Search result counts cache for common queries reducing database load.

def self.search_with_relevance(query)
  # Cache search counts for popular queries
  cache_key = "property_search/#{query.parameterize}/count"

  count = Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
    search_by_relevance(query).count
  end

  # Limit results preventing expensive queries
  search_by_relevance(query)
    .with_pg_search_rank
    .order('pg_search_rank DESC')
    .limit(100)
end

This caching prevents repeated count queries for popular searches like "High Street" or "Flat".

What's Next

The relevance search foundation enables sophisticated features: saved searches storing query preferences, search analytics identifying common queries, faceted search filtering by property attributes interactively, and geo search finding properties near addresses using PostGIS.

Future enhancements might include personalized ranking learning individual staff search patterns, synonym handling treating "flat" and "apartment" equivalently, boolean operators enabling complex queries like "High Street AND 2-bed", and autocomplete suggesting property references as staff type.

By implementing intelligent relevance-based search, LetAdmin ensures staff find properties quickly regardless of database size, search query quality, or property naming patterns—improving workflow efficiency whilst requiring no external search infrastructure beyond PostgreSQL's built-in full-text capabilities.