M-Bus Gateway
← Tilbage til blog
· 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.

Se asyncpg guide eller PostgreSQL JSONB guide.