grantforge-api / backend /core /grants /postgres_fts.py
GrantForge Bot
Deploy sha-9a5957fcdef15b7e2623f8b147cda6026475aee0 — source build (no GHCR)
3a3734f
Raw
History Blame Contribute Delete
7.1 kB
"""PostgreSQL full-text search (to_tsvector) + SQLite FTS5 — bez python token overlap."""
from __future__ import annotations
import logging
import re
from typing import Any, Dict, List, Optional, Set
from sqlalchemy import inspect, text
from sqlalchemy.orm import Session
from core.grants.catalog_service import _search_hay
from core.grants.completeness import grant_dict_from_row
from core.grants.models import Grant
from core.subscription.db import engine
logger = logging.getLogger(__name__)
_FTS5_READY = False
def get_db_dialect() -> str:
return engine.dialect.name
def get_fts_backend() -> str:
dialect = get_db_dialect()
if dialect == "postgresql":
return "postgresql_tsvector"
if dialect == "sqlite":
return "sqlite_fts5"
return "python_fallback"
def is_real_fts_available() -> bool:
return get_fts_backend() in ("postgresql_tsvector", "sqlite_fts5")
def build_search_document(item: Dict[str, Any]) -> str:
return _search_hay(item)
def _query_tokens(query: str) -> List[str]:
return [t for t in re.split(r"\W+", (query or "").lower()) if len(t) > 2]
def _fts5_query(query: str) -> str:
tokens = _query_tokens(query)
if not tokens:
return ""
return " OR ".join(f'"{t}"' for t in tokens[:12])
def _postgres_tsquery(query: str) -> str:
"""OR-tokeny jak FTS5 — plainto_tsquery wymaga AND i psuje multi-word PL."""
tokens = _query_tokens(query)[:12]
if not tokens:
return ""
parts: List[str] = []
for token in tokens:
safe = re.sub(r"[':\\&|!()]", " ", token).strip()
if safe:
parts.append(safe)
return " | ".join(parts) if parts else ""
def ensure_fts_schema(db: Session) -> Dict[str, Any]:
"""Tworzy FTS5 (sqlite) lub kolumnę search_document (postgres)."""
global _FTS5_READY
backend = get_fts_backend()
stats: Dict[str, Any] = {"backend": backend, "created": False}
if backend == "sqlite_fts5":
exists = db.execute(
text("SELECT name FROM sqlite_master WHERE type='table' AND name='grants_fts'")
).fetchone()
if not exists:
db.execute(
text(
"CREATE VIRTUAL TABLE grants_fts USING fts5("
"grant_id UNINDEXED, document, tokenize='unicode61')"
)
)
db.commit()
stats["created"] = True
_FTS5_READY = True
return stats
if backend == "postgresql_tsvector":
inspector = inspect(engine)
cols = {c["name"] for c in inspector.get_columns("grants")}
if "search_document" not in cols:
with engine.begin() as conn:
conn.execute(text("ALTER TABLE grants ADD COLUMN search_document TEXT"))
stats["created"] = True
return stats
return stats
def sync_fts_index(db: Session, *, limit: int = 5000) -> Dict[str, Any]:
"""Indeksuje dokumenty w FTS5 lub kolumnie search_document."""
ensure_fts_schema(db)
backend = get_fts_backend()
rows = db.query(Grant).limit(limit).all()
indexed = 0
if backend == "sqlite_fts5":
db.execute(text("DELETE FROM grants_fts"))
for row in rows:
doc = build_search_document(grant_dict_from_row(row))
if not doc.strip():
continue
db.execute(
text("INSERT INTO grants_fts(grant_id, document) VALUES (:gid, :doc)"),
{"gid": row.source_id, "doc": doc},
)
indexed += 1
db.commit()
return {"backend": backend, "indexed": indexed}
if backend == "postgresql_tsvector":
for row in rows:
doc = build_search_document(grant_dict_from_row(row))
row.search_document = doc
if doc.strip():
indexed += 1
db.commit()
return {"backend": backend, "indexed": indexed}
return {"backend": backend, "indexed": 0, "reason": "no_fts_backend"}
def postgres_fts_scores(
db: Session,
query: str,
candidate_ids: Optional[List[str]] = None,
*,
limit: int = 50,
) -> Dict[str, float]:
"""
Zwraca source_id -> score 0..1 z prawdziwego FTS (tsvector lub FTS5).
candidate_ids ogranicza wyniki do przefiltrowanego zbioru.
"""
if not query.strip() or not is_real_fts_available():
return {}
ensure_fts_schema(db)
backend = get_fts_backend()
allowed: Optional[Set[str]] = set(candidate_ids) if candidate_ids else None
scores: Dict[str, float] = {}
if backend == "sqlite_fts5":
fts_q = _fts5_query(query)
if not fts_q:
return {}
try:
rows = db.execute(
text(
"SELECT grant_id, bm25(grants_fts) AS rank "
"FROM grants_fts WHERE grants_fts MATCH :q "
"ORDER BY rank LIMIT :lim"
),
{"q": fts_q, "lim": max(limit * 4, 80)},
).fetchall()
except Exception as e:
logger.warning("[PostgresFTS] FTS5 query failed: %s", e)
return {}
if not rows:
return {}
raw_ranks = [float(r[1]) for r in rows]
min_r, max_r = min(raw_ranks), max(raw_ranks)
span = max(max_r - min_r, 1e-6)
for rank, (gid, bm) in enumerate(rows, start=1):
if allowed is not None and gid not in allowed:
continue
norm = (float(bm) - min_r) / span
scores[str(gid)] = max(scores.get(str(gid), 0), min(1.0, norm), 1.0 / rank)
if len(scores) >= limit:
break
return scores
if backend == "postgresql_tsvector":
ts_q = _postgres_tsquery(query)
if not ts_q:
return {}
try:
rows = db.execute(
text(
"SELECT source_id, ts_rank("
" to_tsvector('simple', coalesce(search_document, '')),"
" to_tsquery('simple', :q)"
") AS rank "
"FROM grants "
"WHERE search_document IS NOT NULL AND search_document != '' "
" AND to_tsvector('simple', search_document) @@ to_tsquery('simple', :q) "
"ORDER BY rank DESC LIMIT :lim"
),
{"q": ts_q, "lim": max(limit * 4, 80)},
).fetchall()
except Exception as e:
logger.warning("[PostgresFTS] tsvector query failed: %s", e)
return {}
if not rows:
return {}
max_rank = max(float(r[1]) for r in rows) or 1.0
for rank, (gid, ts_rank) in enumerate(rows, start=1):
if allowed is not None and gid not in allowed:
continue
scores[str(gid)] = max(
scores.get(str(gid), 0),
min(1.0, float(ts_rank) / max_rank),
1.0 / rank,
)
if len(scores) >= limit:
break
return scores
return {}