Skip to content

Solr + Postgres JSONB: Faceted, Full-Text, and Geo Search at Speed

Objective: Master the art of hybrid search—keeping relational truth in Postgres while unleashing Solr's full-text, faceting, and geospatial powers on JSONB data. When you need lightning-fast search with facets, suggestions, and geo queries, when you want to combine the reliability of Postgres with the search prowess of Solr—this tutorial becomes your weapon of choice.

Overview

We'll keep truth in Postgres and let Solr hunt—fast, fuzzy, facet-happy. JSONB becomes the launch pad; Solr becomes the engine. This hybrid approach gives you the best of both worlds: Postgres handles relational constraints and complex filters, while Solr delivers blazing-fast text search, faceting, suggestions, and geospatial queries.

flowchart LR
  A[(Postgres<br/>JSONB source)] -->|ETL (Python)| S[(Solr Core)]
  U[FastAPI / Client] -->|filters (org, dates)| A
  U -->|q=fulltext, facets, suggest| S
  S -->|ranked doc IDs| U
  A -->|final rows by IDs| U

Why it works: Postgres handles relational truth and strict filters; Solr handles text scoring, facets, synonyms, & geo—then we intersect.

Docker Compose Stack

# docker-compose.yml
version: "3.9"

x-hc: &hc { interval: 5s, timeout: 3s, retries: 30 }

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: app
    ports: ["5432:5432"]
    healthcheck:
      test: ["CMD-SHELL","pg_isready -U postgres -d app"]
      <<: *hc
    volumes: ["pgdata:/var/lib/postgresql/data"]

  solr:
    image: solr:9
    command: bash -lc "precreate-core appcore && solr-foreground"
    ports: ["8983:8983"]
    healthcheck:
      test: ["CMD","curl","-sf","http://localhost:8983/solr/admin/cores?action=STATUS"]
      <<: *hc
    volumes: ["solrdata:/var/solr"]

  fastapi:
    build: ./fastapi
    depends_on:
      postgres: { condition: service_healthy }
      solr: { condition: service_healthy }
    environment:
      DB_DSN: postgresql://postgres:postgres@postgres:5432/app
      SOLR_URL: http://solr:8983/solr/appcore
    ports: ["8000:8000"]

  seed:
    build: ./seed
    depends_on:
      postgres: { condition: service_healthy }
      solr: { condition: service_healthy }
    command: ["python","seed_and_index.py"]

  # Optional profiles:
  kafka:
    image: bitnami/kafka:3.7
    profiles: ["cdc"]
    environment:
      KAFKA_ENABLE_KRAFT: "yes"
      KAFKA_CFG_PROCESS_ROLES: "broker,controller"
      KAFKA_CFG_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
      KAFKA_CFG_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093"
      KAFKA_CFG_ADVERTISED_LISTENERS: "PLAINTEXT://kafka:9092"
      ALLOW_PLAINTEXT_LISTENER: "yes"
    ports: ["9092:9092"]

  grafana:
    image: grafana/grafana:latest
    profiles: ["viz"]
    ports: ["3000:3000"]

  maplibre:
    image: nginx:alpine
    profiles: ["geo"]
    volumes: ["./maplibre:/usr/share/nginx/html:ro"]
    ports: ["8080:80"]

volumes:
  pgdata:
  solrdata:

Operator Recipes

# Core (DB + Solr + FastAPI + seeding/indexing)
docker compose up -d --build

# Add CDC path later:
docker compose --profile cdc up -d

# Visualization or geo demo:
docker compose --profile viz up -d
docker compose --profile geo up -d

Postgres Model: JSONB + Relational Spine

-- Create the articles table with JSONB payload
CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE articles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org TEXT NOT NULL,
  published_at TIMESTAMPTZ NOT NULL,
  author TEXT,
  geom GEOGRAPHY(Point,4326),        -- optional geo hook
  doc JSONB NOT NULL,                 -- semi-structured payload
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Filter indexes for "hybrid search intersection" stage
CREATE INDEX ON articles (org);
CREATE INDEX ON articles (published_at);
CREATE INDEX ON articles USING GIST (geom); -- if geo included

-- JSONB indexes for common queries
CREATE INDEX ON articles USING GIN (doc);
CREATE INDEX ON articles USING GIN ((doc->'tags'));

-- Example seed data
INSERT INTO articles (org, published_at, author, geom, doc)
VALUES
('the-hollow-paper', now() - interval '1 day', 'K. Wolfe', ST_GeogFromText('POINT(-73.9857 40.7484)'),
 '{"title":"Night Markets","body":"Feral neon and steam.","tags":["city","night"],"lang":"en"}'),
('the-hollow-paper', now() - interval '2 days', 'D. Lake', ST_GeogFromText('POINT(-122.4194 37.7749)'),
 '{"title":"Salt Maps","body":"Cartography of appetite.","tags":["map","salt"],"lang":"en"}'),
('urban-echo', now() - interval '3 days', 'M. Chen', ST_GeogFromText('POINT(-74.0060 40.7128)'),
 '{"title":"Underground Networks","body":"Tunnels beneath the city.","tags":["infrastructure","urban"],"lang":"en"}'),
('data-dreams', now() - interval '4 days', 'A. Kim', ST_GeogFromText('POINT(-122.4194 37.7749)'),
 '{"title":"Algorithmic Landscapes","body":"Code shapes the world.","tags":["tech","future"],"lang":"en"}');

Solr Core & Schema Configuration

Schema Definition

{
  "add-field": [
    {"name":"id","type":"string","stored":true,"indexed":true},
    {"name":"org_s","type":"string","stored":true,"indexed":true},
    {"name":"published_dt","type":"pdate","stored":true,"indexed":true},
    {"name":"author_s","type":"string","stored":true,"indexed":true},
    {"name":"loc_p","type":"location","stored":true,"indexed":true},
    {"name":"title_t","type":"text_en","stored":true,"indexed":true},
    {"name":"body_t","type":"text_en","stored":false,"indexed":true},
    {"name":"tags_ss","type":"strings","stored":true,"indexed":true},
    {"name":"lang_s","type":"string","stored":true,"indexed":true}
  ],
  "add-copy-field":[
    {"source":"title_t","dest":"_text_"},
    {"source":"body_t","dest":"_text_"}
  ],
  "add-dynamic-field":[
    {"name":"*_t","type":"text_en","stored":false,"indexed":true},
    {"name":"*_s","type":"string","stored":true,"indexed":true},
    {"name":"*_ss","type":"strings","stored":true,"indexed":true},
    {"name":"*_i","type":"pint","stored":true,"indexed":true},
    {"name":"*_dt","type":"pdate","stored":true,"indexed":true},
    {"name":"*_p","type":"location","stored":true,"indexed":true}
  ]
}

Synonyms Configuration

# solr/appcore/conf/synonyms.txt
geo, geospatial, mapping, cartography
night, nocturnal, evening
city, urban, metropolitan
tech, technology, digital

Suggester Configuration

<!-- solr/appcore/conf/solrconfig.xml -->
<SearchComponent name="suggest" class="solr.SuggestComponent">
  <lst name="suggester">
    <str name="name">titleSuggester</str>
    <str name="lookupImpl">FuzzyLookupFactory</str>
    <str name="dictionaryImpl">DocumentDictionaryFactory</str>
    <str name="field">title_t</str>
    <str name="suggestAnalyzerFieldType">text_en</str>
  </lst>
</SearchComponent>

<requestHandler name="/suggest" class="solr.SearchHandler" startup="lazy">
  <lst name="components"><str>suggest</str></lst>
  <lst name="defaults">
    <bool name="suggest">true</bool>
    <str name="suggest.dictionary">titleSuggester</str>
  </lst>
</requestHandler>

Python Indexer: JSONB → Solr Documents

# seed/seed_and_index.py
import os
import json
import time
import psycopg2
import requests
from datetime import datetime, timedelta
from typing import List, Dict, Any

DB_DSN = os.getenv("DB_DSN", "postgresql://postgres:postgres@postgres:5432/app")
SOLR_URL = os.getenv("SOLR_URL", "http://solr:8983/solr/appcore")

def rows_since(conn: psycopg2.extensions.connection, ts: datetime) -> List[Dict[str, Any]]:
    """Fetch rows changed since timestamp."""
    with conn.cursor() as cur:
        cur.execute("""
          SELECT id::text, org, published_at, author,
                 ST_X(ST_AsText(geom::geometry)) AS lon,
                 ST_Y(ST_AsText(geom::geometry)) AS lat,
                 doc
          FROM articles
          WHERE updated_at > %s
          ORDER BY updated_at
        """, (ts,))

        docs = []
        for row in cur:
            doc = {
                "id": row[0],
                "org_s": row[1],
                "published_dt": row[2].isoformat() if row[2] else None,
                "author_s": row[3],
                "loc_p": f"{row[5]},{row[4]}" if row[4] and row[5] else None,
                "title_t": row[6].get("title"),
                "body_t": row[6].get("body"),
                "tags_ss": row[6].get("tags", []),
                "lang_s": row[6].get("lang")
            }
            # Remove None values
            doc = {k: v for k, v in doc.items() if v is not None}
            docs.append(doc)

        return docs

def solr_upsert(docs: List[Dict[str, Any]]) -> None:
    """Upsert documents to Solr."""
    if not docs:
        return

    payload = {
        "add": [
            {"doc": doc, "commitWithin": 2000} 
            for doc in docs
        ]
    }

    try:
        r = requests.post(f"{SOLR_URL}/update", json=payload, timeout=30)
        r.raise_for_status()

        # Commit the changes
        requests.get(f"{SOLR_URL}/update?commit=true", timeout=10)
        print(f"✅ Indexed {len(docs)} documents")

    except requests.exceptions.RequestException as e:
        print(f"❌ Solr update failed: {e}")
        raise

def setup_solr_schema() -> None:
    """Setup Solr schema via Schema API."""
    schema_config = {
        "add-field": [
            {"name":"id","type":"string","stored":True,"indexed":True},
            {"name":"org_s","type":"string","stored":True,"indexed":True},
            {"name":"published_dt","type":"pdate","stored":True,"indexed":True},
            {"name":"author_s","type":"string","stored":True,"indexed":True},
            {"name":"loc_p","type":"location","stored":True,"indexed":True},
            {"name":"title_t","type":"text_en","stored":True,"indexed":True},
            {"name":"body_t","type":"text_en","stored":False,"indexed":True},
            {"name":"tags_ss","type":"strings","stored":True,"indexed":True},
            {"name":"lang_s","type":"string","stored":True,"indexed":True}
        ],
        "add-copy-field":[
            {"source":"title_t","dest":"_text_"},
            {"source":"body_t","dest":"_text_"}
        ]
    }

    try:
        r = requests.post(f"{SOLR_URL}/schema", json=schema_config, timeout=30)
        r.raise_for_status()
        print("✅ Solr schema configured")
    except requests.exceptions.RequestException as e:
        print(f"⚠️  Schema setup failed (may already exist): {e}")

def main():
    """Main indexing loop."""
    print("🚀 Starting Postgres → Solr indexer")

    # Setup Solr schema
    setup_solr_schema()

    # Connect to Postgres
    conn = psycopg2.connect(DB_DSN)
    checkpoint = datetime.utcnow() - timedelta(days=365)

    print(f"📊 Starting from checkpoint: {checkpoint.isoformat()}")

    try:
        while True:
            docs = rows_since(conn, checkpoint)
            if docs:
                solr_upsert(docs)
                checkpoint = datetime.utcnow()
                print(f"🔄 Updated checkpoint: {checkpoint.isoformat()}")
            else:
                print("⏳ No new documents, waiting...")

            time.sleep(5)

    except KeyboardInterrupt:
        print("🛑 Indexer stopped by user")
    finally:
        conn.close()

if __name__ == "__main__":
    main()

FastAPI Hybrid Search Endpoint

# fastapi/app.py
import os
import json
from typing import List, Optional
from fastapi import FastAPI, Query, HTTPException
import psycopg2
import requests
from urllib.parse import urlencode

DB_DSN = os.getenv("DB_DSN")
SOLR_URL = os.getenv("SOLR_URL")

app = FastAPI(title="Hybrid Search API", version="1.0.0")

def pg_candidates(org: Optional[str], date_from: Optional[str], 
                 date_to: Optional[str], tags: Optional[List[str]]) -> List[str]:
    """Phase 1: Get candidate IDs from Postgres filters."""
    sql = """
      SELECT id::text FROM articles
      WHERE (%s::text IS NULL OR org = %s)
        AND (%s::timestamptz IS NULL OR published_at >= %s)
        AND (%s::timestamptz IS NULL OR published_at <= %s)
        AND (%s::text[] IS NULL OR doc->'tags' ?| %s)
      LIMIT 5000
    """

    with psycopg2.connect(DB_DSN) as conn, conn.cursor() as cur:
        cur.execute(sql, (org, org, date_from, date_from, date_to, date_to, tags, tags))
        return [row[0] for row in cur.fetchall()]

@app.get("/search")
def search(
    q: str = "",
    org: Optional[str] = None,
    date_from: Optional[str] = None,
    date_to: Optional[str] = None,
    tags: Optional[List[str]] = Query(default=None),
    near: Optional[str] = None,  # "lat,lon,radius"
    facets: bool = True,
    page: int = 1,
    size: int = 10
):
    """
    Hybrid search endpoint combining Postgres filters with Solr search.

    - q: Full-text search query
    - org: Organization filter
    - date_from/date_to: Date range filter
    - tags: Tag filters
    - near: Geo proximity (lat,lon,radius_km)
    - facets: Enable faceting
    - page/size: Pagination
    """

    # Phase 1: Postgres filters → candidate IDs
    candidate_ids = pg_candidates(org, date_from, date_to, tags)
    if not candidate_ids:
        return {"hits": [], "facets": {}, "total": 0}

    # Phase 2: Solr search with ID intersection
    solr_params = {
        "q": q or "*:*",
        "rows": size,
        "start": (page - 1) * size,
        "hl": "true",
        "hl.fl": "title_t,body_t",
        "defType": "edismax",
        "qf": "title_t^3 body_t^1",
        "fq": f"id:({' '.join(candidate_ids[:1000])})"  # Cap fq size
    }

    # Add geo search if specified
    if near:
        try:
            lat, lon, radius = near.split(",")
            solr_params.update({
                "sfield": "loc_p",
                "pt": f"{lat},{lon}",
                "d": radius,
                "sort": "geodist() asc"
            })
        except ValueError:
            raise HTTPException(status_code=400, detail="Invalid near format. Use: lat,lon,radius")

    # Add faceting
    if facets:
        solr_params.update({
            "facet": "true",
            "facet.field": ["org_s", "tags_ss"],
            "facet.range": "published_dt",
            "facet.range.gap": "+1DAY",
            "facet.range.start": "NOW-30DAYS/DAY",
            "facet.range.end": "NOW/DAY"
        })

    # Fire Solr query
    try:
        r = requests.get(f"{SOLR_URL}/select", params=solr_params, timeout=10)
        r.raise_for_status()
        data = r.json()
    except requests.exceptions.RequestException as e:
        raise HTTPException(status_code=500, detail=f"Solr query failed: {e}")

    # Process results
    hits = []
    highlighting = data.get("highlighting", {})

    for doc in data["response"]["docs"]:
        hit = {
            "id": doc["id"],
            "org": doc.get("org_s"),
            "title": doc.get("title_t"),
            "author": doc.get("author_s"),
            "tags": doc.get("tags_ss", []),
            "score": doc.get("score"),
            "snippet": " ".join(highlighting.get(doc["id"], {}).get("body_t", [])[:1]) if highlighting else None
        }
        hits.append(hit)

    # Extract facets
    facets_out = data.get("facet_counts", {}) if facets else {}

    return {
        "hits": hits,
        "facets": facets_out,
        "total": data["response"]["numFound"],
        "page": page,
        "size": size
    }

@app.get("/suggest")
def suggest(q: str = Query(..., min_length=1)):
    """Get search suggestions from Solr."""
    try:
        r = requests.get(f"{SOLR_URL}/suggest", params={
            "suggest": "true",
            "suggest.dictionary": "titleSuggester",
            "suggest.q": q
        }, timeout=5)
        r.raise_for_status()
        return r.json()
    except requests.exceptions.RequestException as e:
        raise HTTPException(status_code=500, detail=f"Suggestion failed: {e}")

@app.get("/health")
def health():
    """Health check endpoint."""
    return {"status": "healthy", "service": "hybrid-search"}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

A) Full-Text Ranking with eDismax

# Basic text search with field boosting
curl "http://localhost:8983/solr/appcore/select?q=market steam&qf=title_t^3 body_t^1&defType=edismax"

# Phrase boosting for exact matches
curl "http://localhost:8983/solr/appcore/select?q=night market&pf=title_t^5&qf=title_t^3 body_t^1&defType=edismax"

# Synonym expansion (geo ≈ geospatial)
curl "http://localhost:8983/solr/appcore/select?q=geo mapping&defType=edismax"
# Field facets
curl "http://localhost:8983/solr/appcore/select?q=*:*&facet=true&facet.field=org_s&facet.field=tags_ss"

# Range facets for dates
curl "http://localhost:8983/solr/appcore/select?q=*:*&facet=true&facet.range=published_dt&facet.range.gap=+1DAY&facet.range.start=NOW-7DAYS/DAY&facet.range.end=NOW/DAY"

C) Highlighting

# Get highlighted snippets
curl "http://localhost:8983/solr/appcore/select?q=neon steam&hl=true&hl.fl=title_t,body_t&hl.snippets=2"

D) Search Suggestions

# Get title suggestions
curl "http://localhost:8983/solr/appcore/suggest?suggest=true&suggest.dictionary=titleSuggester&suggest.q=sal"

E) Geospatial Queries

# Find documents within 10km of NYC
curl "http://localhost:8983/solr/appcore/select?q=*:*&sfield=loc_p&pt=40.7128,-74.0060&d=10&fq={!geofilt}&sort=geodist() asc"

# Geo distance with custom radius
curl "http://localhost:8983/solr/appcore/select?q=*:*&sfield=loc_p&pt=37.7749,-122.4194&d=5&fq={!geofilt}"

F) JSON Facet API (Advanced)

# Nested facets: top tags per organization
curl "http://localhost:8983/solr/appcore/select?q=*:*&json.facet={
  \"orgs\": {
    \"type\": \"terms\",
    \"field\": \"org_s\",
    \"facet\": {
      \"top_tags\": {
        \"type\": \"terms\",
        \"field\": \"tags_ss\",
        \"limit\": 3
      }
    }
  }
}"

Optional CDC Integration

Kafka Connect Configuration

{
  "name": "postgres-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "postgres",
    "database.password": "postgres",
    "database.dbname": "app",
    "database.server.name": "app",
    "table.include.list": "public.articles",
    "plugin.name": "pgoutput"
  }
}

Solr Sink Consumer

# cdc/solr_sink.py
import json
import requests
from kafka import KafkaConsumer
from typing import Dict, Any

SOLR_URL = "http://solr:8983/solr/appcore"

def process_kafka_message(message: Dict[str, Any]) -> None:
    """Process CDC message and update Solr."""
    op = message.get("op")
    after = message.get("after", {})
    before = message.get("before", {})

    if op == "c":
        # Create - index new document
        doc = transform_to_solr_doc(after)
        solr_upsert([doc])
    elif op == "u":
        # Update - reindex document
        doc = transform_to_solr_doc(after)
        solr_upsert([doc])
    elif op == "d":
        # Delete - remove from Solr
        doc_id = before.get("id")
        solr_delete(doc_id)

def transform_to_solr_doc(row: Dict[str, Any]) -> Dict[str, Any]:
    """Transform Postgres row to Solr document."""
    # Implementation similar to indexer
    pass

def solr_upsert(docs: List[Dict[str, Any]]) -> None:
    """Upsert documents to Solr."""
    payload = {"add": [{"doc": doc} for doc in docs]}
    requests.post(f"{SOLR_URL}/update", json=payload)

def solr_delete(doc_id: str) -> None:
    """Delete document from Solr."""
    payload = {"delete": {"id": doc_id}}
    requests.post(f"{SOLR_URL}/update", json=payload)

# Kafka consumer
consumer = KafkaConsumer('app.public.articles', 
                        bootstrap_servers=['kafka:9092'],
                        value_deserializer=lambda m: json.loads(m.decode('utf-8')))

for message in consumer:
    process_kafka_message(message.value)

Operations & Failure Modes

Idempotency Checklist

  • Checkpointing: Use updated_at timestamps for incremental indexing
  • Upsert Strategy: Solr upserts with commitWithin for batching
  • Retry Logic: Exponential backoff for failed Solr operations
  • Schema Evolution: Dynamic fields handle new JSONB keys gracefully

Backpressure Management

# Batch size limits
MAX_BATCH_SIZE = 1000
COMMIT_WITHIN_MS = 2000

# Rate limiting
import time
time.sleep(0.1)  # 100ms between batches

Schema Drift Handling

# Dynamic field mapping for new JSONB keys
def map_jsonb_to_solr(doc: Dict[str, Any]) -> Dict[str, Any]:
    """Map JSONB fields to Solr dynamic fields."""
    solr_doc = {}

    for key, value in doc.items():
        if isinstance(value, str):
            solr_doc[f"{key}_s"] = value
        elif isinstance(value, list):
            solr_doc[f"{key}_ss"] = value
        elif isinstance(value, int):
            solr_doc[f"{key}_i"] = value
        elif isinstance(value, datetime):
            solr_doc[f"{key}_dt"] = value.isoformat()

    return solr_doc

Security Hardening

# nginx.conf - Protect Solr admin
server {
    listen 80;
    server_name solr.example.com;

    location /solr/admin {
        deny all;
        return 403;
    }

    location /solr/appcore/select {
        proxy_pass http://solr:8983;
        proxy_set_header Host $host;
    }
}

Monitoring & Alerting

# monitoring/solr_metrics.py
import requests
import time
from prometheus_client import Gauge, Counter, start_http_server

# Metrics
solr_docs_total = Gauge('solr_documents_total', 'Total documents in Solr')
solr_query_duration = Gauge('solr_query_duration_seconds', 'Query duration')
solr_errors_total = Counter('solr_errors_total', 'Total Solr errors')

def collect_solr_metrics():
    """Collect Solr metrics for Prometheus."""
    try:
        r = requests.get("http://solr:8983/solr/appcore/admin/mbeans")
        data = r.json()

        # Extract document count
        docs = data['solr-mbeans']['CORE']['appcore']['searcher']['numDocs']
        solr_docs_total.set(docs)

    except Exception as e:
        solr_errors_total.inc()
        print(f"Metrics collection failed: {e}")

if __name__ == "__main__":
    start_http_server(8001)
    while True:
        collect_solr_metrics()
        time.sleep(30)

Disaster Recovery

#!/bin/bash
# disaster_recovery.sh

# 1. Backup Solr core
curl "http://localhost:8983/solr/admin/cores?action=BACKUP&core=appcore&name=backup_$(date +%Y%m%d)"

# 2. Full reindex from Postgres
python seed_and_index.py --full-reindex

# 3. Verify index integrity
curl "http://localhost:8983/solr/appcore/select?q=*:*&rows=0"

TL;DR Runbook

Quick Start

# 1. Stand up the stack
docker compose up -d --build

# 2. Check services
curl http://localhost:8000/health
curl http://localhost:8983/solr/admin/cores

# 3. Test search
curl "http://localhost:8000/search?q=night&facets=true"

# 4. Test suggestions
curl "http://localhost:8000/suggest?q=sal"

Essential Patterns

# Complete hybrid search setup
def setup_hybrid_search():
    # 1. Postgres JSONB source
    # 2. Solr schema with dynamic fields
    # 3. Python indexer (incremental)
    # 4. FastAPI hybrid endpoint
    # 5. Solr features (facets, suggest, geo)
    # 6. Optional CDC pipeline
    # 7. Monitoring & ops

    print("Hybrid search setup complete!")

Key Commands

# Core operations
docker compose up -d --build                    # Start core stack
docker compose --profile cdc up -d              # Add CDC
docker compose --profile viz up -d              # Add monitoring
docker compose --profile geo up -d              # Add geo demo

# Search examples
curl "http://localhost:8000/search?q=market&org=the-hollow-paper"
curl "http://localhost:8000/search?near=40.7128,-74.0060,10&facets=true"
curl "http://localhost:8000/suggest?q=underground"

# Solr admin
curl "http://localhost:8983/solr/appcore/select?q=*:*&facet=true&facet.field=org_s"

This tutorial provides the complete machinery for hybrid Postgres-Solr search. Each pattern includes implementation examples, search strategies, and real-world usage patterns for enterprise search systems.