Solr + Postgres JSONB: Faceted, Full-Text, and Geo Search at Speed¶
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.
Architecture: Hybrid Search¶
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)
Solr Features Gallery¶
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"
B) Faceted Search¶
# 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_attimestamps for incremental indexing - ✅ Upsert Strategy: Solr upserts with
commitWithinfor 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"