· PostgreSQL· full-text search· FastAPI· SQLAlchemy· Python· backend· søgning
PostgreSQL full-text search i FastAPI — tsvector og GIN-indeks
PostgreSQL full-text search med tsvector, tsquery og GIN-indeks: dansk tekstsøgning, ranking med ts_rank, SQLAlchemy async integration, multi-kolonne søgning og FastAPI endpoint.
Af M-Bus Gateway
PostgreSQL's full-text search slår simpel LIKE-søgning med sprogsensitiv stemming, ranking og GIN-indeks-acceleration. Her er implementeringen til en FastAPI SaaS-platform.
tsvector og tsquery — grundprincip
-- tsvector: Ordformer normaliseret til stamme
SELECT to_tsvector('danish', 'Lejekontrakter og fjernvarmeafregninger');
-- 'afregning':2 'fjernvarm':2 'lejekontrakt':1
-- tsquery: Søgeord med operator-support
SELECT to_tsquery('danish', 'fjernvarme & afregning');
-- 'fjernvarm' & 'afregning'
-- Match:
SELECT to_tsvector('danish', 'Fjernvarmeafregning 2025') @@
to_tsquery('danish', 'fjernvarme');
-- true (stemmed match: fjernvarmeafregning → fjernvarm)
-- Ranking:
SELECT ts_rank(
to_tsvector('danish', 'Fjernvarmeafregning for Nørrebro 2025'),
to_tsquery('danish', 'fjernvarme & nørrebro')
);
-- 0.0759... (højere = bedre match)
SQLModel med genereret tsvector-kolonne
# server/src/db/models.py
from sqlalchemy import Index, text, Computed
from sqlalchemy.dialects.postgresql import TSVECTOR
class Property(SQLModel, table=True):
__tablename__ = "property"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
tenant_id: uuid.UUID = Field(foreign_key="tenant.id", index=True)
name: str
address: str
city: str
zip_code: str
notes: str | None = None
# Genereret søgekolonne (opdateres automatisk):
search_vector: str | None = Field(
default=None,
sa_column=Column(
TSVECTOR,
Computed(
"to_tsvector('danish', "
"coalesce(name, '') || ' ' || "
"coalesce(address, '') || ' ' || "
"coalesce(city, '') || ' ' || "
"coalesce(zip_code, '') || ' ' || "
"coalesce(notes, ''))",
persisted=True,
),
),
)
__table_args__ = (
# GIN-indeks på tsvector — hurtig FTS:
Index("ix_property_search", "search_vector", postgresql_using="gin"),
)
FastAPI søgeendpoint
# server/src/property/router.py
from sqlalchemy import select, text, func
from sqlalchemy.dialects.postgresql import TSVECTOR
@router.get("/search", response_model=list[PropertySearchResult])
async def search_properties(
q: str = Query(..., min_length=2, max_length=100),
limit: int = Query(20, ge=1, le=50),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(require_role("landlord")),
) -> list[PropertySearchResult]:
# Sanitér søgeord → tsquery:
# plainto_tsquery håndterer naturligt sprog uden operatorer
tsquery = func.plainto_tsquery("danish", q)
stmt = (
select(
Property,
func.ts_rank(Property.search_vector, tsquery).label("rank"),
)
.where(
Property.tenant_id == current_user.tenant_id,
Property.deleted_at.is_(None),
Property.search_vector.op("@@")(tsquery),
)
.order_by(text("rank DESC"))
.limit(limit)
)
rows = (await session.execute(stmt)).all()
return [
PropertySearchResult(
**PropertyOut.model_validate(row.Property).model_dump(),
rank=float(row.rank),
)
for row in rows
]
Multi-tabel cross-entity søgning
# Søg på tværs af ejendomme, enheder og lejere:
@router.get("/portfolio/search")
async def portfolio_search(
q: str = Query(..., min_length=2),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(require_role("landlord")),
):
tsquery = func.plainto_tsquery("danish", q)
tid = current_user.tenant_id
# UNION af tre tabeller:
property_q = (
select(
literal("property").label("entity_type"),
Property.id.label("entity_id"),
Property.name.label("label"),
Property.address.label("detail"),
func.ts_rank(Property.search_vector, tsquery).label("rank"),
)
.where(Property.tenant_id == tid, Property.deleted_at.is_(None),
Property.search_vector.op("@@")(tsquery))
)
unit_q = (
select(
literal("unit").label("entity_type"),
Unit.id.label("entity_id"),
Unit.unit_number.label("label"),
Property.name.label("detail"),
func.ts_rank(Unit.search_vector, tsquery).label("rank"),
)
.join(Property, Unit.property_id == Property.id)
.where(Property.tenant_id == tid, Unit.deleted_at.is_(None),
Unit.search_vector.op("@@")(tsquery))
)
combined = property_q.union_all(unit_q).order_by(text("rank DESC")).limit(20)
rows = (await session.execute(combined)).all()
return [{"type": r.entity_type, "id": r.entity_id, "label": r.label,
"detail": r.detail} for r in rows]
Dansk tekstsøgning — diakritika og forkortelser
-- Dansk stemming (kræver dansk language config):
SELECT to_tsvector('danish', 'varmeafregning varmeregnskab');
-- 'varmeafregn':1 'varmeregnskab':2
-- Diakritika: æ, ø, å håndteres korrekt:
SELECT to_tsvector('danish', 'Nørrebro') @@ to_tsquery('danish', 'nørrebro');
-- true
-- Prefix-søgning med :*
SELECT to_tsvector('danish', 'fjernvarme') @@ to_tsquery('danish', 'fjern:*');
-- true — nyttigt til autosuggest
-- websearch_to_tsquery — Google-lignende syntax:
SELECT websearch_to_tsquery('danish', 'fjernvarme -techem "nørrebro"');
-- 'fjernvarm' & !'techem' & 'nørrebro'
-- Sikkert mod brugerinput (ingen injection risk)
Alembic migration
# alembic/versions/xxx_add_fts.py
def upgrade():
# Tilføj computed column:
op.execute("""
ALTER TABLE property
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('danish',
coalesce(name, '') || ' ' ||
coalesce(address, '') || ' ' ||
coalesce(city, '') || ' ' ||
coalesce(zip_code, '') || ' ' ||
coalesce(notes, ''))
) STORED
""")
# GIN-indeks (concurrent — ingen table lock):
op.execute(
"CREATE INDEX CONCURRENTLY ix_property_search "
"ON property USING gin(search_vector)"
)
def downgrade():
op.execute("DROP INDEX IF EXISTS ix_property_search")
op.execute("ALTER TABLE property DROP COLUMN search_vector")
Konklusion
PostgreSQL full-text search med GENERATED ALWAYS AS ... STORED tsvector-kolonner og GIN-indeks giver sub-millisekund søgning selv i store ejendomsporteføljer. plainto_tsquery og websearch_to_tsquery er sikre mod injection og håndterer dansk tekst med stemming og diakritika. UNION-søgning på tværs af tabeller giver en unified search-oplevelse uden Elasticsearch-overhead.