deploy: 59cac6b1dd28
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +43 -0
- .gitattributes +1 -0
- .gitignore +118 -0
- Dockerfile +106 -0
- LAPORAN.md +722 -0
- LICENSE +21 -0
- README.md +231 -5
- backend/.env.example +6 -0
- backend/alembic.ini +49 -0
- backend/alembic/env.py +78 -0
- backend/alembic/script.py.mako +26 -0
- backend/alembic/versions/001_initial_schema.py +129 -0
- backend/app/__init__.py +1 -0
- backend/app/api/__init__.py +1 -0
- backend/app/api/eval.py +170 -0
- backend/app/api/health.py +138 -0
- backend/app/api/insights.py +126 -0
- backend/app/api/listings.py +62 -0
- backend/app/api/search.py +232 -0
- backend/app/core/__init__.py +1 -0
- backend/app/core/config.py +65 -0
- backend/app/core/db.py +76 -0
- backend/app/evaluation/README.md +172 -0
- backend/app/evaluation/__init__.py +37 -0
- backend/app/evaluation/kappa.py +135 -0
- backend/app/evaluation/metrics.py +197 -0
- backend/app/evaluation/runner.py +195 -0
- backend/app/evaluation/statistical.py +171 -0
- backend/app/indexing/README.md +189 -0
- backend/app/indexing/__init__.py +30 -0
- backend/app/indexing/base.py +64 -0
- backend/app/indexing/bm25.py +84 -0
- backend/app/indexing/build.py +119 -0
- backend/app/indexing/hybrid.py +127 -0
- backend/app/indexing/indobert.py +178 -0
- backend/app/indexing/loader.py +68 -0
- backend/app/indexing/tfidf.py +94 -0
- backend/app/main.py +226 -0
- backend/app/models/__init__.py +11 -0
- backend/app/models/eval.py +144 -0
- backend/app/models/listing.py +98 -0
- backend/app/models/search.py +28 -0
- backend/app/preprocessing/README.md +161 -0
- backend/app/preprocessing/__init__.py +36 -0
- backend/app/preprocessing/jargon.py +205 -0
- backend/app/preprocessing/normalizer.py +74 -0
- backend/app/preprocessing/pipeline.py +187 -0
- backend/app/preprocessing/spelling.py +68 -0
- backend/app/preprocessing/stemmer.py +41 -0
- backend/app/preprocessing/stopwords.py +68 -0
.dockerignore
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hindari bloat build context (yang Docker daemon kirim ke builder)
|
| 2 |
+
|
| 3 |
+
# Python
|
| 4 |
+
**/__pycache__
|
| 5 |
+
**/*.pyc
|
| 6 |
+
**/*.pyo
|
| 7 |
+
**/.venv
|
| 8 |
+
**/venv
|
| 9 |
+
**/.pytest_cache
|
| 10 |
+
**/.coverage
|
| 11 |
+
**/htmlcov
|
| 12 |
+
**/coverage.xml
|
| 13 |
+
|
| 14 |
+
# Node
|
| 15 |
+
**/node_modules
|
| 16 |
+
**/dist
|
| 17 |
+
**/.vite
|
| 18 |
+
|
| 19 |
+
# Env files (NEVER ship)
|
| 20 |
+
**/.env
|
| 21 |
+
**/.env.local
|
| 22 |
+
|
| 23 |
+
# Git
|
| 24 |
+
.git
|
| 25 |
+
.gitignore
|
| 26 |
+
**/.gitkeep
|
| 27 |
+
|
| 28 |
+
# IDE / OS
|
| 29 |
+
**/.vscode
|
| 30 |
+
**/.idea
|
| 31 |
+
**/*.swp
|
| 32 |
+
**/.DS_Store
|
| 33 |
+
**/Thumbs.db
|
| 34 |
+
|
| 35 |
+
# Dev artifacts
|
| 36 |
+
**/.scrape_cache
|
| 37 |
+
**/notebooks/.ipynb_checkpoints
|
| 38 |
+
**/*.log
|
| 39 |
+
|
| 40 |
+
# Tests + docs (gak perlu di runtime image)
|
| 41 |
+
docs/
|
| 42 |
+
.github/
|
| 43 |
+
**/tests
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
data/indexes/indobert/faiss.index filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================================
|
| 2 |
+
# Python
|
| 3 |
+
# ============================================================================
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.py[cod]
|
| 6 |
+
*$py.class
|
| 7 |
+
*.so
|
| 8 |
+
.Python
|
| 9 |
+
build/
|
| 10 |
+
develop-eggs/
|
| 11 |
+
dist/
|
| 12 |
+
downloads/
|
| 13 |
+
eggs/
|
| 14 |
+
.eggs/
|
| 15 |
+
lib/
|
| 16 |
+
lib64/
|
| 17 |
+
parts/
|
| 18 |
+
sdist/
|
| 19 |
+
var/
|
| 20 |
+
wheels/
|
| 21 |
+
*.egg-info/
|
| 22 |
+
.installed.cfg
|
| 23 |
+
*.egg
|
| 24 |
+
MANIFEST
|
| 25 |
+
|
| 26 |
+
# Virtual environments
|
| 27 |
+
.venv/
|
| 28 |
+
venv/
|
| 29 |
+
env/
|
| 30 |
+
ENV/
|
| 31 |
+
|
| 32 |
+
# Jupyter
|
| 33 |
+
.ipynb_checkpoints/
|
| 34 |
+
|
| 35 |
+
# pytest / coverage
|
| 36 |
+
.coverage
|
| 37 |
+
htmlcov/
|
| 38 |
+
.pytest_cache/
|
| 39 |
+
.tox/
|
| 40 |
+
.nox/
|
| 41 |
+
coverage.xml
|
| 42 |
+
*.cover
|
| 43 |
+
|
| 44 |
+
# ============================================================================
|
| 45 |
+
# Node / Frontend
|
| 46 |
+
# ============================================================================
|
| 47 |
+
node_modules/
|
| 48 |
+
dist/
|
| 49 |
+
dist-ssr/
|
| 50 |
+
*.local
|
| 51 |
+
.vite/
|
| 52 |
+
|
| 53 |
+
# ============================================================================
|
| 54 |
+
# Environment variables (NEVER COMMIT)
|
| 55 |
+
# ============================================================================
|
| 56 |
+
.env
|
| 57 |
+
.env.local
|
| 58 |
+
.env.*.local
|
| 59 |
+
!.env.example
|
| 60 |
+
|
| 61 |
+
# ============================================================================
|
| 62 |
+
# Data files (large, re-buildable)
|
| 63 |
+
# ============================================================================
|
| 64 |
+
data/raw/*.json
|
| 65 |
+
data/raw/*.csv
|
| 66 |
+
data/raw/*.html
|
| 67 |
+
data/raw/*.jsonl
|
| 68 |
+
data/processed/*.json
|
| 69 |
+
data/processed/*.csv
|
| 70 |
+
data/processed/*.parquet
|
| 71 |
+
data/indexes/*.pkl
|
| 72 |
+
data/indexes/*.index
|
| 73 |
+
data/indexes/*.bin
|
| 74 |
+
data/indexes/*.faiss
|
| 75 |
+
data/indexes/*.npy
|
| 76 |
+
!data/raw/.gitkeep
|
| 77 |
+
!data/processed/.gitkeep
|
| 78 |
+
!data/indexes/.gitkeep
|
| 79 |
+
# Whitelist: real Mamikos corpus (source of truth) + built indexes untuk Docker COPY
|
| 80 |
+
!data/raw/mamikos_real_v2.jsonl
|
| 81 |
+
!data/raw/_discovered_slugs.txt
|
| 82 |
+
!data/processed/corpus.json
|
| 83 |
+
!data/indexes/tfidf.pkl
|
| 84 |
+
!data/indexes/bm25.pkl
|
| 85 |
+
!data/indexes/indobert/
|
| 86 |
+
|
| 87 |
+
# Scrape cache (during dev)
|
| 88 |
+
.scrape_cache/
|
| 89 |
+
|
| 90 |
+
# Database dumps
|
| 91 |
+
*.db
|
| 92 |
+
*.sqlite
|
| 93 |
+
*.sqlite3
|
| 94 |
+
*.dump
|
| 95 |
+
|
| 96 |
+
# ============================================================================
|
| 97 |
+
# Logs
|
| 98 |
+
# ============================================================================
|
| 99 |
+
*.log
|
| 100 |
+
logs/
|
| 101 |
+
|
| 102 |
+
# ============================================================================
|
| 103 |
+
# IDE / OS
|
| 104 |
+
# ============================================================================
|
| 105 |
+
.vscode/
|
| 106 |
+
.idea/
|
| 107 |
+
*.swp
|
| 108 |
+
*.swo
|
| 109 |
+
*~
|
| 110 |
+
.DS_Store
|
| 111 |
+
Thumbs.db
|
| 112 |
+
desktop.ini
|
| 113 |
+
|
| 114 |
+
# ============================================================================
|
| 115 |
+
# Misc
|
| 116 |
+
# ============================================================================
|
| 117 |
+
.render-buildlog
|
| 118 |
+
*.pid
|
Dockerfile
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================================
|
| 2 |
+
# KozyNear single-container deploy
|
| 3 |
+
# React frontend + FastAPI backend serve dari 1 service
|
| 4 |
+
#
|
| 5 |
+
# Multi-stage build:
|
| 6 |
+
# Stage 1 (Node) -> npm install + vite build -> /app/frontend/dist
|
| 7 |
+
# Stage 2 (Python) -> copy frontend dist + install Python deps + uvicorn
|
| 8 |
+
#
|
| 9 |
+
# Routes di runtime:
|
| 10 |
+
# GET / -> React SPA (index.html)
|
| 11 |
+
# GET /assets/* -> built JS/CSS chunks
|
| 12 |
+
# GET /favicon.ico, dll -> static files
|
| 13 |
+
# GET /health -> liveness probe (Render uses this)
|
| 14 |
+
# GET /api/search, /api/listings/{id}, /api/eval/* -> FastAPI endpoints
|
| 15 |
+
# GET /api/docs -> Swagger UI
|
| 16 |
+
# ============================================================================
|
| 17 |
+
|
| 18 |
+
# ----------------------------------------------------------------------------
|
| 19 |
+
# Stage 1: Build React frontend
|
| 20 |
+
# ----------------------------------------------------------------------------
|
| 21 |
+
FROM node:20-alpine AS frontend-builder
|
| 22 |
+
|
| 23 |
+
WORKDIR /app/frontend
|
| 24 |
+
|
| 25 |
+
# Layer caching: install deps dulu (gak invalidate kalau code berubah)
|
| 26 |
+
COPY frontend/package*.json ./
|
| 27 |
+
RUN npm install
|
| 28 |
+
|
| 29 |
+
# Build
|
| 30 |
+
COPY frontend ./
|
| 31 |
+
# VITE_API_URL kosong -> frontend pakai relative URL (same origin sebagai API)
|
| 32 |
+
ENV VITE_API_URL=""
|
| 33 |
+
RUN npm run build
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ----------------------------------------------------------------------------
|
| 37 |
+
# Stage 2: Python runtime
|
| 38 |
+
# ----------------------------------------------------------------------------
|
| 39 |
+
FROM python:3.11-slim
|
| 40 |
+
|
| 41 |
+
# System deps untuk lxml (parsing HTML scraper) + psycopg2 + faiss (libgomp1:
|
| 42 |
+
# wheel faiss-cpu link ke libgomp yang tidak ada di python-slim)
|
| 43 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 44 |
+
build-essential \
|
| 45 |
+
libxml2-dev \
|
| 46 |
+
libxslt-dev \
|
| 47 |
+
libpq-dev \
|
| 48 |
+
libgomp1 \
|
| 49 |
+
curl \
|
| 50 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 51 |
+
|
| 52 |
+
WORKDIR /app
|
| 53 |
+
|
| 54 |
+
# Install RUNTIME deps + NEURAL deps. Satu image untuk dua platform:
|
| 55 |
+
# - Render free 512MB: ENABLE_NEURAL=false -> fastembed/faiss tidak di-import,
|
| 56 |
+
# RAM aman (paket cuma duduk di disk).
|
| 57 |
+
# - HF Spaces 16GB ($0): ENABLE_NEURAL=true -> MiniLM + Hybrid live.
|
| 58 |
+
# Full set dev (scraping, pandas, dev tools) ada di requirements.txt.
|
| 59 |
+
COPY backend/requirements-runtime.txt backend/requirements-neural.txt ./
|
| 60 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 61 |
+
pip install --no-cache-dir -r requirements-runtime.txt \
|
| 62 |
+
-r requirements-neural.txt
|
| 63 |
+
|
| 64 |
+
# Pre-download model ONNX MiniLM ke cache yang dipakai runtime
|
| 65 |
+
# (FASTEMBED_CACHE_PATH) supaya startup dengan ENABLE_NEURAL=true tidak
|
| 66 |
+
# download ~120MB tiap cold start (HF Spaces disk-nya ephemeral).
|
| 67 |
+
ENV FASTEMBED_CACHE_PATH=/app/.fastembed_cache
|
| 68 |
+
RUN python -c "import os; from fastembed import TextEmbedding; \
|
| 69 |
+
TextEmbedding('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2', \
|
| 70 |
+
cache_dir=os.environ['FASTEMBED_CACHE_PATH'])"
|
| 71 |
+
|
| 72 |
+
# Copy backend code
|
| 73 |
+
COPY backend ./backend
|
| 74 |
+
# Copy data folder (mamikos_real_v2 source + corpus.json + pre-built indexes)
|
| 75 |
+
COPY data ./data
|
| 76 |
+
# Copy hasil evaluasi (CSV) — dibaca /api/eval/* (file-based dashboard)
|
| 77 |
+
COPY eval ./eval
|
| 78 |
+
|
| 79 |
+
# Copy built frontend dari stage 1
|
| 80 |
+
COPY --from=frontend-builder /app/frontend/dist ./static
|
| 81 |
+
|
| 82 |
+
# Working dir backend supaya alembic + relative imports berfungsi
|
| 83 |
+
WORKDIR /app/backend
|
| 84 |
+
|
| 85 |
+
# Render expose $PORT via env (default 10000 kalau gak set)
|
| 86 |
+
ENV PORT=10000
|
| 87 |
+
EXPOSE 10000
|
| 88 |
+
|
| 89 |
+
# Startup pipeline:
|
| 90 |
+
# 1. alembic upgrade head -- create/update schema (idempotent)
|
| 91 |
+
# 2. seed_db.py --truncate --skip-if-synced: reconcile listings table ke
|
| 92 |
+
# mamikos_real_v2.jsonl (227 real, sesudah filter deskripsi kosong).
|
| 93 |
+
# --skip-if-synced: kalau count + digest id DB == source, seed di-skip
|
| 94 |
+
# (cold start lebih cepat); kalau beda, TRUNCATE CASCADE + reseed atomic.
|
| 95 |
+
# 3. uvicorn start FastAPI -- lifespan load TF-IDF + BM25 + gazetteer.
|
| 96 |
+
# Neural (MiniLM) ikut di-load HANYA kalau ENABLE_NEURAL=true
|
| 97 |
+
# (HF Spaces 16GB); di Render free biarkan false. Default search
|
| 98 |
+
# model = "smart" (query understanding + geo + BM25).
|
| 99 |
+
# Catatan resiliency: alembic/seed TIDAK boleh mematikan container kalau
|
| 100 |
+
# DB unreachable (mis. Space baru yang DATABASE_URL-nya belum di-set).
|
| 101 |
+
# App tetap up: /health, frontend, /api/stats (fallback corpus), /api/eval,
|
| 102 |
+
# /api/preprocess jalan; /api/search yang butuh DB akan error jelas dan
|
| 103 |
+
# /api/status menampilkan database.connected=false untuk debugging.
|
| 104 |
+
CMD (alembic upgrade head || echo "[startup] alembic SKIPPED (DB unreachable? cek DATABASE_URL; continuing)") \
|
| 105 |
+
&& (python -m scripts.seed_db --input ../data/raw/mamikos_real_v2.jsonl --truncate --skip-if-synced || echo "[startup] seed_db SKIPPED (continuing)") \
|
| 106 |
+
&& uvicorn app.main:app --host 0.0.0.0 --port $PORT
|
LAPORAN.md
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LAPORAN AKHIR — KozyNear
|
| 2 |
+
|
| 3 |
+
**Mata Kuliah**: Temu Kembali Informasi (STKI, COM620321, 3 SKS)
|
| 4 |
+
**Universitas**: Lampung (UNILA), FMIPA
|
| 5 |
+
**Project**: Indonesian Kos-Kosan Search Engine — **seluruh Bandar Lampung untuk semua universitas**
|
| 6 |
+
**Submission**: 17 Juni 2026 (vClass)
|
| 7 |
+
**Repository**: https://github.com/DYmazeh/KozyNear
|
| 8 |
+
**Live Demo**: https://dymazeh-kozynear.hf.space (HF Spaces, neural ON) | backup: https://kozynear.onrender.com (Render, smart-only)
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## 1. Abstract
|
| 13 |
+
|
| 14 |
+
KozyNear adalah Information Retrieval (IR) system untuk listing kos-kosan
|
| 15 |
+
di **Bandar Lampung** (18 kecamatan dengan data real, 9 universitas: UNILA,
|
| 16 |
+
ITERA, Darmajaya, UBL, UIN Raden Intan, Teknokrat, Polinela, Malahayati, Saburai).
|
| 17 |
+
Sistem mendukung natural language query bahasa Indonesia dengan
|
| 18 |
+
domain-specific preprocessing pipeline dan membandingkan **lima model
|
| 19 |
+
retrieval**: TF-IDF, BM25, neural semantic (MiniLM multilingual, label
|
| 20 |
+
kode "IndoBERT"), hybrid (BM25 → MiniLM rerank), dan **Smart** — pipeline
|
| 21 |
+
produksi berbasis query understanding (parser rule-based) + geo ranking
|
| 22 |
+
(gazetteer kampus terverifikasi + haversine) + BM25 fusion.
|
| 23 |
+
|
| 24 |
+
Corpus terdiri dari **227 listing kos REAL** hasil scrape Mamikos.com
|
| 25 |
+
(deskripsi pemilik asli + koordinat asli, 18 kecamatan), bukan synthetic.
|
| 26 |
+
Evaluasi pada **30 query × 900 ground truth annotations** (AI-assisted
|
| 27 |
+
3-annotator simulation, Cohen's Kappa 0.58-0.87) menempatkan **Smart
|
| 28 |
+
sebagai best performer standard top-K by MAP** (MAP 0.301, P@5 0.580,
|
| 29 |
+
MRR 0.868) diikuti BM25 (MAP 0.270, P@5 0.553); signifikansi pairwise
|
| 30 |
+
diuji Wilcoxon signed-rank dengan koreksi **Holm-Bonferroni** (4 dari 10
|
| 31 |
+
pasangan signifikan; semua melibatkan model neural pada standard eval).
|
| 32 |
+
Pada lensa **Constraint Satisfaction@5** (proporsi top-5 yang memenuhi
|
| 33 |
+
semua kebutuhan eksplisit user: gender, budget, fasilitas, radius 3 km
|
| 34 |
+
dari kampus), Smart unggul jauh atas BM25 (**0.86 vs 0.45**, Wilcoxon
|
| 35 |
+
p<0.001). Hyperparameter dipilih lewat grid search: bobot fusion Smart
|
| 36 |
+
0.2/0.4/0.4 (teks/geo/atribut) dan alpha Hybrid 0.9.
|
| 37 |
+
|
| 38 |
+
Finding penting: **model neural terlihat underperform di standard top-K
|
| 39 |
+
eval (P@5 0.160)** semata-mata karena *pooling bias* — ground truth
|
| 40 |
+
di-pool dari kandidat BM25, sehingga hasil semantic yang unik tidak
|
| 41 |
+
ter-judge. Pada **pool-restricted evaluation** (fair, hanya judged docs),
|
| 42 |
+
neural justru kompetitif (MAP 0.557 vs BM25 0.583). Ini menegaskan
|
| 43 |
+
pentingnya melaporkan kedua angka untuk corpus IR yang di-pool secara
|
| 44 |
+
lexical. Koordinat real 226/227 listing dipakai untuk geo ranking Smart
|
| 45 |
+
dan integrasi Google Maps.
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## 2. Latar Belakang
|
| 50 |
+
|
| 51 |
+
### 2.1 Motivasi
|
| 52 |
+
|
| 53 |
+
Mahasiswa di Bandar Lampung — baik dari UNILA, ITERA, Darmajaya, UBL,
|
| 54 |
+
UIN, Teknokrat, Polinela, Malahayati, maupun Saburai — membutuhkan
|
| 55 |
+
informasi kos-kosan strategis sekitar kampus mereka. Platform existing
|
| 56 |
+
seperti Mamikos kurang optimal untuk Indonesian natural language karena
|
| 57 |
+
tidak mendukung sinonim domain (e.g., "kmd" vs "kamar mandi dalam") dan
|
| 58 |
+
tidak fokus geografis ke Bandar Lampung.
|
| 59 |
+
|
| 60 |
+
### 2.2 Tujuan
|
| 61 |
+
|
| 62 |
+
1. Membangun search engine khusus kos-kosan **seluruh Bandar Lampung**
|
| 63 |
+
untuk **semua universitas** (universitas-agnostic)
|
| 64 |
+
2. Membandingkan tiga paradigma IR utama + hybrid: lexical (TF-IDF),
|
| 65 |
+
probabilistic (BM25), neural/semantic (IndoBERT+FAISS)
|
| 66 |
+
3. Mengevaluasi performa quantitative via metric standar IR dengan
|
| 67 |
+
inter-annotator agreement protocol
|
| 68 |
+
4. Deploy sebagai web app yang accessible publik
|
| 69 |
+
|
| 70 |
+
### 2.3 Ruang Lingkup
|
| 71 |
+
|
| 72 |
+
**In scope**: text retrieval atas data REAL Mamikos, 18 kecamatan Bandar
|
| 73 |
+
Lampung, 9 universitas target, deskripsi pemilik asli, koordinat real
|
| 74 |
+
untuk Google Maps, deployment Render.com free tier.
|
| 75 |
+
|
| 76 |
+
**Out of scope**: image retrieval, real-time price updates, user auth,
|
| 77 |
+
booking, multi-city (kabupaten lain). Geo-spatial distance kini masuk
|
| 78 |
+
scope sebagai SALAH SATU sinyal fusion di model Smart (bobot 0.4
|
| 79 |
+
bersama teks 0.4 + atribut 0.2), bukan primary signal tunggal. Tipe
|
| 80 |
+
`pasutri` di luar scope — Mamikos hanya
|
| 81 |
+
mengklasifikasi gender 0/1/2 (campur/putra/putri); "pasutri" adalah
|
| 82 |
+
kategori marketing (campur yang boleh berdua), bukan tipe gender.
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## 3. Dataset
|
| 87 |
+
|
| 88 |
+
### 3.1 Acquisition Methodology — Real Mamikos Scrape
|
| 89 |
+
|
| 90 |
+
Corpus adalah **227 listing kos REAL** dari Mamikos.com, di-scrape via
|
| 91 |
+
pipeline 3-tahap (sumber: `data/raw/mamikos_real_v2.jsonl`):
|
| 92 |
+
|
| 93 |
+
1. **Discovery** (`scripts/discover_mamikos_slugs.py` + WebSearch): kumpulkan
|
| 94 |
+
URL halaman detail `/room/`. Halaman kategori Mamikos JS-rendered dengan
|
| 95 |
+
API listing ter-enkripsi (AES), jadi discovery dilakukan via ~50 query
|
| 96 |
+
`site:mamikos.com inurl:/room/` (per-kecamatan, per-universitas,
|
| 97 |
+
per-fasilitas, per-harga, street-level) → **312 URL unik** terkumpul
|
| 98 |
+
dalam 5 ronde.
|
| 99 |
+
2. **Detail extraction** (`scripts/extract_mamikos_detail.py`): setiap
|
| 100 |
+
halaman detail Mamikos meng-*inject* `var detail = {...}` (JSON 146
|
| 101 |
+
field) di static HTML — bisa di-fetch HTTP-only tanpa browser. Parse
|
| 102 |
+
field penting: `_id`, `room_title`, `description` (cerita pemilik asli),
|
| 103 |
+
`latitude`/`longitude`, `price_monthly`, `gender`, fasilitas
|
| 104 |
+
(fac_room/share/bath/park), booking_rules, owner_name, verification.
|
| 105 |
+
3. **Canonical build** (`scripts/rebuild_v2.py`):
|
| 106 |
+
normalisasi kecamatan, hitung `jarak_kampus_km` via haversine ke 9
|
| 107 |
+
universitas, drop deskripsi kosong → `kozynear_combined.jsonl`.
|
| 108 |
+
|
| 109 |
+
Success rate ekstraksi ~76% (sisanya listing inactive/removed). Field
|
| 110 |
+
gender Mamikos hanya 0/1/2 → tipe **campur/putra/putri** (tidak ada pasutri).
|
| 111 |
+
|
| 112 |
+
### 3.2 Honest Methodology Disclosure
|
| 113 |
+
|
| 114 |
+
Iterasi awal project memakai synthetic corpus (template-generated) untuk
|
| 115 |
+
mengejar volume. Namun karena web app ditujukan **production** (pengguna
|
| 116 |
+
mencari kos nyata + tampilan Google Maps), synthetic dibuang total:
|
| 117 |
+
koordinat synthetic yang fabricated akan menaruh pin di lokasi salah.
|
| 118 |
+
|
| 119 |
+
**Trade-off yang diterima secara sadar**: corpus real lebih kecil (227 vs
|
| 120 |
+
2000 synthetic) karena Mamikos Bandar Lampung hanya memiliki ~300-400
|
| 121 |
+
listing unik dan WebSearch hanya meng-surface yang ter-index Google. Kami
|
| 122 |
+
memilih **kualitas + keaslian** di atas volume. Semua deskripsi adalah
|
| 123 |
+
tulisan asli pemilik kos (terverifikasi 0 template-leak), bukan generated.
|
| 124 |
+
Setiap listing punya `url_source` untuk traceability balik ke Mamikos.
|
| 125 |
+
|
| 126 |
+
### 3.3 Statistik Corpus
|
| 127 |
+
|
| 128 |
+
| Metric | Value |
|
| 129 |
+
|--------|-------|
|
| 130 |
+
| Total listings | **227 (100% real Mamikos)** |
|
| 131 |
+
| Kecamatan covered | 18 unique (Bandar Lampung) |
|
| 132 |
+
| Tipe | campur 114 (50%), putri 85 (37%), putra 28 (13%) |
|
| 133 |
+
| Harga (per bulan) | min Rp 300k, median Rp 800k, max Rp 6jt |
|
| 134 |
+
| Deskripsi (kata) | median ~23 (cerita pemilik, terse — bukan padded) |
|
| 135 |
+
| Koordinat valid (Google Maps) | 226/227 (99.6%) |
|
| 136 |
+
| url_source / Mamikos-verified | 100% / 100% |
|
| 137 |
+
| owner_name present | 96% |
|
| 138 |
+
| File size | ~0.3 MB raw / ~0.6 MB processed |
|
| 139 |
+
|
| 140 |
+
**Distribusi listings per kecamatan** (top: Sukarame 40, Kedaton 37,
|
| 141 |
+
Rajabasa 32, Tanjung Karang Timur 14, Sukabumi 13):
|
| 142 |
+
|
| 143 |
+

|
| 144 |
+
|
| 145 |
+
**Karakteristik data real vs synthetic**: deskripsi pemilik asli jauh lebih
|
| 146 |
+
*terse* (median 23 kata) dibanding synthetic (110+ kata padded). Contoh
|
| 147 |
+
nyata: *"belakang UNILA (sangat dekat, jalan kaki 5mnt), dekat rel kereta"*.
|
| 148 |
+
Ini menurunkan absolute IR metric (lebih sedikit term overlap) tetapi
|
| 149 |
+
mencerminkan **realita yang sebenarnya** dihadapi sistem production.
|
| 150 |
+
|
| 151 |
+
**Distribusi tipe + harga** (lihat notebook 01_eda.ipynb untuk chart lain):
|
| 152 |
+
|
| 153 |
+

|
| 154 |
+
|
| 155 |
+

|
| 156 |
+
|
| 157 |
+
---
|
| 158 |
+
|
| 159 |
+
## 4. Preprocessing
|
| 160 |
+
|
| 161 |
+
### 4.1 Pipeline 9-Stage
|
| 162 |
+
|
| 163 |
+
```
|
| 164 |
+
raw text
|
| 165 |
+
→ 1. HTML strip (BeautifulSoup)
|
| 166 |
+
→ 2. Whitespace normalize
|
| 167 |
+
→ 3. Price extraction (SEBELUM lowercase — preserve Rp)
|
| 168 |
+
→ 4. Lowercase
|
| 169 |
+
→ 5. Custom jargon dictionary substitution (longest-first regex)
|
| 170 |
+
→ 6. Spelling correction (typo dictionary)
|
| 171 |
+
→ 7. Tokenize (whitespace + punctuation)
|
| 172 |
+
→ 8. Stopword removal (Sastrawi default + custom domain)
|
| 173 |
+
→ 9. Stem (Sastrawi StemmerFactory, LRU-cached)
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
**Anti-pattern**: lowercase sebelum extract harga → regex `[Rr][Pp]` masih
|
| 177 |
+
match, tapi edge case `Rp850k` (tanpa space) jadi rapuh.
|
| 178 |
+
|
| 179 |
+
### 4.2 Custom Jargon Dictionary
|
| 180 |
+
|
| 181 |
+
**~106 entries** (course requirement ≥100):
|
| 182 |
+
- ABBREVIATIONS: 43 (kmd, wc dlm, jt, rb, yg, krn, ...)
|
| 183 |
+
- LOCATIONS: 24 (gdg meneng, sumbro, unyila, ...)
|
| 184 |
+
- TYPE_SLANG: 11 (cowo, cewe, pria, wanita, ...) — entri `pasutri` dihapus
|
| 185 |
+
(bukan tipe gender valid di Mamikos)
|
| 186 |
+
- RULES: 5 (jamal, no smoking, ...)
|
| 187 |
+
- PAYMENT: 8 (dp, deposit, all in, ...)
|
| 188 |
+
- KOS_FORM: 15 (kostan, kontrakan, eksklusif, ...)
|
| 189 |
+
|
| 190 |
+
### 4.3 Pipeline Performance + Stage Impact
|
| 191 |
+
|
| 192 |
+
Run pada 227 listing real:
|
| 193 |
+
- **Latency**: ~5 detik (Sastrawi LRU cache, deskripsi real lebih pendek)
|
| 194 |
+
- **Token reduction**: deskripsi terse pemilik → median ~20 token setelah
|
| 195 |
+
stemming (real owner text jauh lebih ringkas dari synthetic padded)
|
| 196 |
+
|
| 197 |
+
**Benchmark MAP per pipeline variant** (notebook 02_preprocessing.ipynb):
|
| 198 |
+
|
| 199 |
+

|
| 200 |
+
|
| 201 |
+
Setiap stage contribution incremental dari BASELINE → FULL pipeline.
|
| 202 |
+
|
| 203 |
+
### 4.4 Real-World Insight
|
| 204 |
+
|
| 205 |
+
1. **"gedong meneng" → "gedong neng"**: Sastrawi treats "meneng" punya
|
| 206 |
+
prefix "me-" yang invalid (false positive stemming)
|
| 207 |
+
2. **"kamar mandi dalam" → "mandi"**: "kamar" jadi custom domain stopword,
|
| 208 |
+
sehingga sebagian besar signal hilang
|
| 209 |
+
3. **Jargon substitution sukses**: "kmd" → "kamar mandi dalam" → "mandi"
|
| 210 |
+
setelah stemming (signal preserved walau partial)
|
| 211 |
+
|
| 212 |
+
---
|
| 213 |
+
|
| 214 |
+
## 5. Model Comparison
|
| 215 |
+
|
| 216 |
+
### 5.1 Implementasi
|
| 217 |
+
|
| 218 |
+
| Model | Library | Hyperparameter |
|
| 219 |
+
|-------|---------|----------------|
|
| 220 |
+
| TF-IDF | scikit-learn TfidfVectorizer | ngram_range=(1,2), min_df=2, max_features=10k, sublinear_tf=True |
|
| 221 |
+
| BM25 | rank_bm25 BM25Okapi | k1=1.5, b=0.75 (literature standard) |
|
| 222 |
+
| Neural "IndoBERT" | fastembed (ONNX) + FAISS | paraphrase-multilingual-MiniLM-L12-v2, IndexFlatIP, normalize_embeddings. Catatan kejujuran: label kode `indobert` historis; model sebenarnya MiniLM multilingual |
|
| 223 |
+
| Hybrid | BM25 top-50 → MiniLM rerank | min-max normalize + alpha=0.9 (grid search pool-restricted, lihat §8.3; default lama 0.3 terbukti merugikan) |
|
| 224 |
+
| **Smart (live)** | parser rule-based + gazetteer + BM25 | fusion 0.2 teks + 0.4 geo (1/(1+km), anchor terverifikasi OSM/Wikipedia) + 0.4 fasilitas (grid search CS@5, lihat §8.3b); hard filter gender/harga dengan relaxation bertahap; geo-augment kandidat radius 3 km; filter UI = constraint eksplisit yang tidak pernah di-relax |
|
| 225 |
+
|
| 226 |
+
### 5.2 Index Build Performance
|
| 227 |
+
|
| 228 |
+
| Index | Build Time | Disk Size |
|
| 229 |
+
|-------|-----------|-----------|
|
| 230 |
+
| TF-IDF | 0.2s | 125 KB |
|
| 231 |
+
| BM25 | 0.1s | 53 KB |
|
| 232 |
+
| IndoBERT (227 docs encode) | ~10s | ~0.7 MB (embeddings + FAISS + meta) |
|
| 233 |
+
| Hybrid | runtime composition (no build) | — |
|
| 234 |
+
|
| 235 |
+
**Total**: <1 MB indexes committed ke repo. Corpus real yang lebih kecil
|
| 236 |
+
(227 docs) membuat footprint memory ringan — cocok untuk Render free tier
|
| 237 |
+
512MB RAM (encoding via `fastembed` ONNX ~150MB, bukan torch ~500MB).
|
| 238 |
+
|
| 239 |
+
---
|
| 240 |
+
|
| 241 |
+
## 6. Evaluation Protocol
|
| 242 |
+
|
| 243 |
+
### 6.1 Query Set (v1.1 — 30 queries)
|
| 244 |
+
|
| 245 |
+
**30 queries** (eval/queries.json): q01-q15 dari v1.0 (didesain untuk data
|
| 246 |
+
real), q16-q30 ditambah untuk menaikkan power statistik (n=15 underpowered
|
| 247 |
+
untuk Wilcoxon) + memperluas coverage: kampus Darmajaya/UBL, kecamatan
|
| 248 |
+
Tanjung Senang/Gedong Meneng/Labuhan Ratu, fasilitas kipas angin/tv/kasur,
|
| 249 |
+
landmark Mall Boemi Kedaton. Setiap query diverifikasi *answerable* oleh
|
| 250 |
+
corpus real (avg 14.4 relevant/query; 2 draft q16-q30 yang mati diganti
|
| 251 |
+
sebelum dipakai). Berbasis vocabulary yang benar-benar muncul di deskripsi
|
| 252 |
+
pemilik. Sampel pola v1.0:
|
| 253 |
+
|
| 254 |
+
- **Per-universitas**: q01 (UNILA+putra+wifi+ac), q04 (ITERA+sukarame),
|
| 255 |
+
q05 (Teknokrat+kedaton), q06 (Polinela+rajabasa), q11 (UNILA+putra+rajabasa),
|
| 256 |
+
q12 (UNILA+putri), q13 (UIN raden intan)
|
| 257 |
+
- **Per-tipe + fasilitas**: q02 (putri+kedaton), q03 (campur+kmd+parkir),
|
| 258 |
+
q07 (eksklusif+ac+wifi+shower), q14 (putri+way halim)
|
| 259 |
+
- **Kualitas / use-case**: q08 (putri+aman+cctv), q09 (dapur+parkir mobil),
|
| 260 |
+
q10 (transmart+way halim), q15 (campur+nyaman+strategis)
|
| 261 |
+
|
| 262 |
+
**Catatan desain penting**: BM25/IR meng-index **teks deskripsi**;
|
| 263 |
+
filter kecamatan/harga/tipe dilayani **metadata** terpisah di `/search`
|
| 264 |
+
endpoint. Karena itu query eval fokus pada konsep yang muncul di teks
|
| 265 |
+
(universitas, fasilitas, kualitas). Dari versi awal (18 query untuk corpus
|
| 266 |
+
synthetic), **8 query di-drop karena mati di data real** — yaitu yang
|
| 267 |
+
menguji karakteristik khas synthetic: `pasutri` (corpus real 0 pasutri),
|
| 268 |
+
"jam malam aturan ketat", "kerja dari rumah", "warung makan", "tahunan
|
| 269 |
+
bayar di muka", street-name spesifik. Deskripsi pemilik asli yang terse
|
| 270 |
+
tidak memuat frasa-frasa template tersebut.
|
| 271 |
+
|
| 272 |
+
### 6.2 Ground Truth — AI-Assisted 3-Annotator Simulation
|
| 273 |
+
|
| 274 |
+
**Pool**: BM25 top-30 per query = **900 (query, doc) pairs** (30 × 30).
|
| 275 |
+
|
| 276 |
+
**Annotator simulation** (rule-based, `scripts/generate_ground_truth.py`):
|
| 277 |
+
- A (strict): tipe match +1.5 / mismatch -2.0; kecamatan +1.5;
|
| 278 |
+
fasilitas +0.5; harga "murah" +1.0
|
| 279 |
+
- B (lenient): A + 0.5 score boost
|
| 280 |
+
- C (noisy): A + uniform(-0.5, 0.5) jitter (seed 42)
|
| 281 |
+
|
| 282 |
+
Score → relevance: ≥2.5 → 2, ≥1.0 → 1, else 0. Consensus majority vote
|
| 283 |
+
→ `eval/ground_truth.csv`.
|
| 284 |
+
|
| 285 |
+
**Disclosure**: AI-assisted bootstrap GT, BUKAN human annotation — di-pool
|
| 286 |
+
dari BM25 (lihat implikasi *pooling bias* di §7/§8). Untuk production-grade,
|
| 287 |
+
3-human annotator pass tetap recommended (`docs/ground_truth_protocol.md`).
|
| 288 |
+
|
| 289 |
+
### 6.3 Inter-Annotator Agreement
|
| 290 |
+
|
| 291 |
+
| Pair | Cohen's Kappa | Interpretation |
|
| 292 |
+
|------|---------------|----------------|
|
| 293 |
+
| A vs B | 0.700 | substantial |
|
| 294 |
+
| A vs C | 0.871 | almost perfect |
|
| 295 |
+
| B vs C | 0.578 | moderate |
|
| 296 |
+
|
| 297 |
+
Kappa pada data real sedikit lebih rendah dari iterasi synthetic karena
|
| 298 |
+
deskripsi terse memberi sinyal lebih ambigu untuk heuristik. Pasangan B-C
|
| 299 |
+
(lenient vs noisy) turun ke *moderate* (0.572); A-B dan A-C tetap
|
| 300 |
+
substantial–almost perfect.
|
| 301 |
+
|
| 302 |
+
### 6.4 Distribusi Consensus
|
| 303 |
+
|
| 304 |
+
| Relevance | Count | % |
|
| 305 |
+
|-----------|-------|---|
|
| 306 |
+
| 0 (not relevant) | 467 | 51.9% |
|
| 307 |
+
| 1 (somewhat) | 361 | 40.1% |
|
| 308 |
+
| 2 (highly) | 72 | 8.0% |
|
| 309 |
+
|
| 310 |
+
Proporsi rel=2 lebih kecil dari iterasi synthetic (template membuat banyak
|
| 311 |
+
"highly relevant" semu); data real lebih realistis — sedikit yang benar-benar
|
| 312 |
+
sangat relevan per query.
|
| 313 |
+
|
| 314 |
+
---
|
| 315 |
+
|
| 316 |
+
## 7. Results
|
| 317 |
+
|
| 318 |
+
Tiga lensa evaluasi dilaporkan karena GT di-pool secara lexical (BM25):
|
| 319 |
+
**(A) Standard top-K** — IR query atas seluruh corpus 227; **(B)
|
| 320 |
+
Pool-restricted** — model hanya me-ranking ulang dokumen yang sudah
|
| 321 |
+
ter-judge (fair untuk model non-lexical); **(C) Constraint
|
| 322 |
+
Satisfaction@5** — proporsi top-5 yang memenuhi semua kebutuhan eksplisit
|
| 323 |
+
user, tanpa qrels (bebas pooling bias). Eval Smart memakai fungsi
|
| 324 |
+
`smart_rank()` yang persis sama dengan serving (`scripts/eval_smart.py`).
|
| 325 |
+
|
| 326 |
+
### 7.1a Standard Top-K (30 queries × 5 models)
|
| 327 |
+
|
| 328 |
+
| Model | P@5 | P@10 | MAP | NDCG@10 | MRR |
|
| 329 |
+
|-------|-----|------|-----|---------|-----|
|
| 330 |
+
| **Smart (live)** | **0.580** | 0.480 | **0.301** | 0.548 | **0.868** |
|
| 331 |
+
| BM25 | 0.553 | **0.530** | 0.270 | **0.553** | 0.802 |
|
| 332 |
+
| Hybrid (α=0.9) | 0.553 | 0.527 | 0.267 | 0.551 | 0.804 |
|
| 333 |
+
| TF-IDF | 0.547 | 0.510 | 0.239 | 0.523 | 0.727 |
|
| 334 |
+
| Neural MiniLM | 0.160 | 0.153 | 0.057 | 0.155 | 0.280 |
|
| 335 |
+
|
| 336 |
+
**Ranking by MAP**: Smart > BM25 > Hybrid > TF-IDF > Neural. Catatan
|
| 337 |
+
pembacaan jujur: Smart unggul di MAP, P@5, dan terutama MRR (0.868:
|
| 338 |
+
hasil pertama hampir selalu relevan), tapi P@10-nya DI BAWAH BM25.
|
| 339 |
+
Penyebabnya struktural: hard filter memangkas kandidat yang melanggar
|
| 340 |
+
constraint dan dokumen hasil geo-augment yang belum ter-annotate dihitung
|
| 341 |
+
rel=0 (pooling bias juga menekan Smart). Selisih MAP Smart vs BM25 belum
|
| 342 |
+
signifikan (p=0.69, lihat §7.2); klaim utama Smart ada di lensa CS@5
|
| 343 |
+
(§7.4) yang bebas qrels.
|
| 344 |
+
|
| 345 |
+
### 7.1b Pool-Restricted (fair across models)
|
| 346 |
+
|
| 347 |
+
| Model | P@5 | P@10 | MAP | NDCG@10 | MRR |
|
| 348 |
+
|-------|-----|------|-----|---------|-----|
|
| 349 |
+
| **Smart (live)** | **0.927** | **0.800** | **0.865** | **0.857** | **1.000** |
|
| 350 |
+
| Hybrid (α=0.9) | 0.553 | 0.530 | 0.591 | 0.553 | 0.804 |
|
| 351 |
+
| BM25 | 0.553 | 0.520 | 0.583 | 0.546 | 0.802 |
|
| 352 |
+
| TF-IDF | 0.560 | 0.530 | 0.576 | 0.541 | 0.751 |
|
| 353 |
+
| Neural MiniLM | 0.487 | 0.493 | 0.557 | 0.487 | 0.708 |
|
| 354 |
+
|
| 355 |
+
**Insight kunci #1 (pooling bias)**: Neural melompat dari MAP 0.057
|
| 356 |
+
(standard) → **0.557** (pool-restricted). Gap besar ini adalah artefak
|
| 357 |
+
*pooling bias*: GT di-pool dari BM25 top-30, jadi dokumen yang dipilih
|
| 358 |
+
model semantic tapi tidak masuk pool otomatis dihitung tidak-relevan
|
| 359 |
+
(rel=0) di standard eval — bukan karena modelnya buruk, melainkan karena
|
| 360 |
+
belum ter-judge. Pada perbandingan fair, neural kompetitif (~0.03 MAP di
|
| 361 |
+
bawah BM25).
|
| 362 |
+
|
| 363 |
+
**Insight kunci #2 (smart di pool)**: MAP Smart 0.865 (MRR 1.000) di
|
| 364 |
+
pool-restricted sebagian besar datang dari **alignment constraint dengan
|
| 365 |
+
judgment**: annotator (simulasi) menghukum gender/harga mismatch, dan hard
|
| 366 |
+
filter Smart membuang dokumen yang sama sehingga dokumen relevan naik ke
|
| 367 |
+
atas. Ini by-design (sistem mengoptimalkan kebutuhan user yang sama), tapi
|
| 368 |
+
karena heuristik annotator dan filter Smart melihat sinyal yang mirip,
|
| 369 |
+
angka ini dibaca sebagai *upper bound* yang optimistic, bukan bukti
|
| 370 |
+
mutlak superioritas; karena itu lensa CS@5 yang bebas qrels dilaporkan
|
| 371 |
+
juga (§7.4).
|
| 372 |
+
|
| 373 |
+

|
| 374 |
+
|
| 375 |
+

|
| 376 |
+
|
| 377 |
+
### 7.2 Pairwise Statistical Significance (Wilcoxon Signed-Rank + Holm, n=30, α=0.05)
|
| 378 |
+
|
| 379 |
+
Pada AP standard top-K, 5 model = 10 pasangan diuji sekaligus. Menguji
|
| 380 |
+
banyak hipotesis pada α=0.05 tanpa koreksi menggelembungkan peluang false
|
| 381 |
+
positive (10 uji: ~40%), jadi signifikansi final memakai koreksi
|
| 382 |
+
**Holm-Bonferroni** (kontrol family-wise error rate); p-value mentah tetap
|
| 383 |
+
dilaporkan untuk transparansi. (Pada iterasi n=15 sebelumnya, BM25 vs
|
| 384 |
+
Hybrid lolos raw tapi gugur setelah Holm; pelajaran kenapa koreksi wajib.)
|
| 385 |
+
|
| 386 |
+
| Pair | Statistic | p-value | p-Holm | Sig (raw) | Sig (Holm) |
|
| 387 |
+
|------|-----------|---------|--------|-----------|------------|
|
| 388 |
+
| BM25 vs Hybrid | 108.0 | 0.5481 | 1.0000 | no | no |
|
| 389 |
+
| BM25 vs Neural | 31.0 | <0.0001 | <0.001 | yes | **YES** |
|
| 390 |
+
| BM25 vs Smart | 199.0 | 0.6891 | 1.0000 | no | no |
|
| 391 |
+
| BM25 vs TF-IDF | 176.0 | 0.2534 | 1.0000 | no | no |
|
| 392 |
+
| Hybrid vs Neural | 29.0 | <0.0001 | <0.001 | yes | **YES** |
|
| 393 |
+
| Hybrid vs Smart | 208.0 | 0.6263 | 1.0000 | no | no |
|
| 394 |
+
| Hybrid vs TF-IDF | 190.0 | 0.3931 | 1.0000 | no | no |
|
| 395 |
+
| Neural vs Smart | 17.0 | <0.0001 | 0.0001 | yes | **YES** |
|
| 396 |
+
| Neural vs TF-IDF | 24.0 | <0.0001 | <0.001 | yes | **YES** |
|
| 397 |
+
| Smart vs TF-IDF | 159.0 | 0.2059 | 1.0000 | no | no |
|
| 398 |
+
|
| 399 |
+
**4/10 signifikan, dan keempatnya BERTAHAN setelah Holm** (p sangat kecil,
|
| 400 |
+
semuanya pasangan vs Neural pada standard eval) — konsisten dengan cerita
|
| 401 |
+
pooling bias, bukan ranking model. Empat model non-neural (Smart, BM25,
|
| 402 |
+
Hybrid, TF-IDF) tidak saling berbeda signifikan pada n=30; hasil
|
| 403 |
+
non-signifikan dibaca *inconclusive*, bukan bukti dua model setara.
|
| 404 |
+
Detail: `eval/significance_map.csv`.
|
| 405 |
+
|
| 406 |
+
### 7.3 Per-Query Analysis Insights
|
| 407 |
+
|
| 408 |
+
- **BM25 dominan di queries lexical-heavy**: q01 (unila+putra+wifi+ac),
|
| 409 |
+
q03 (campur+kmd+parkir), q13 (uin raden intan) — exact-match nama
|
| 410 |
+
universitas + fasilitas yang muncul di deskripsi.
|
| 411 |
+
- **TF-IDF kompetitif**: pada corpus kecil 227 dengan term yang jarang,
|
| 412 |
+
TF-IDF sublinear_tf cukup dekat dengan BM25 (selisih tidak signifikan).
|
| 413 |
+
- **Query landmark lemah**: q10 (transmart way halim) hanya ~2 relevant —
|
| 414 |
+
sedikit deskripsi pemilik yang menyebut landmark eksplisit.
|
| 415 |
+
- **Neural MiniLM**: kuat di pool-restricted (semantic mengelompokkan kos
|
| 416 |
+
sejenis) tapi tertekan di standard karena GT lexical-pooled.
|
| 417 |
+
|
| 418 |
+
### 7.4 Constraint Satisfaction @5 (lensa bebas qrels)
|
| 419 |
+
|
| 420 |
+
CS@5 = proporsi top-5 yang memenuhi **semua** constraint query: gender
|
| 421 |
+
benar + harga ≤ budget + fasilitas ada + jarak ≤ 3 km dari anchor kampus/
|
| 422 |
+
landmark (30 query di `eval/queries_constraints.json`, koordinat anchor
|
| 423 |
+
terverifikasi OSM/Wikipedia). Metric ini mengukur langsung kebutuhan user
|
| 424 |
+
dan tidak bergantung ground truth (bebas pooling bias).
|
| 425 |
+
|
| 426 |
+
| Model | mean CS@5 (n=30) |
|
| 427 |
+
|-------|------------------|
|
| 428 |
+
| **Smart (live)** | **0.863** |
|
| 429 |
+
| BM25 | 0.447 |
|
| 430 |
+
|
| 431 |
+
Wilcoxon signed-rank smart vs BM25: **p<0.001 (signifikan)** — satu uji
|
| 432 |
+
terencana, tidak butuh koreksi keluarga. Interpretasi praktis: dari 5
|
| 433 |
+
hasil teratas, rata-rata 4.3 memenuhi semua kebutuhan user di Smart vs
|
| 434 |
+
2.2 di BM25. Inilah justifikasi kuantitatif Smart sebagai model live.
|
| 435 |
+
Detail per-query: `eval/results_constraints.csv`.
|
| 436 |
+
|
| 437 |
+
---
|
| 438 |
+
|
| 439 |
+
## 8. Discussion
|
| 440 |
+
|
| 441 |
+
### 8.1 Mengapa BM25 Dominan?
|
| 442 |
+
|
| 443 |
+
1. **Lexical-heavy query**: kebanyakan query mengandung exact terminology
|
| 444 |
+
(nama kecamatan, tipe kos, nama universitas, fasilitas). BM25 term
|
| 445 |
+
saturation handling lebih appropriate dari TF-IDF (sublinear_tf).
|
| 446 |
+
2. **Short documents**: deskripsi pemilik sangat pendek (median ~23 kata,
|
| 447 |
+
~20 token setelah stemming) — BM25 length normalization (b=0.75) efektif
|
| 448 |
+
untuk dokumen pendek.
|
| 449 |
+
3. **Domain-specific corpus**: minim ambiguity semantic — tidak ada
|
| 450 |
+
keuntungan dari embedding-based representation.
|
| 451 |
+
4. **GT lexical-pooled**: ground truth di-pool dari BM25, sehingga metric
|
| 452 |
+
standar memang menguntungkan model lexical (lihat §8.2 pooling bias).
|
| 453 |
+
|
| 454 |
+
### 8.2 Mengapa IndoBERT "Underperform" — Pooling Bias, Bukan Model
|
| 455 |
+
|
| 456 |
+
Temuan paling penting dari iterasi real-data: model neural **tidak**
|
| 457 |
+
benar-benar buruk. Gap MAP 0.057 (standard) vs 0.557 (pool-restricted)
|
| 458 |
+
hampir seluruhnya disebabkan **pooling bias**:
|
| 459 |
+
|
| 460 |
+
1. **Ground truth di-pool dari BM25**: hanya BM25 top-30 yang di-judge.
|
| 461 |
+
Dokumen yang IndoBERT ranking tinggi tapi tidak masuk pool BM25 otomatis
|
| 462 |
+
dihitung rel=0 — *unjudged ≠ irrelevant*. Ini bias klasik TREC-style
|
| 463 |
+
pooling ketika pool dibangun dari satu jenis model.
|
| 464 |
+
2. **Bukti pool-restricted**: saat semua model hanya me-ranking dokumen
|
| 465 |
+
yang sama-sama ter-judge, neural kompetitif (MAP 0.557, hanya ~0.03
|
| 466 |
+
di bawah BM25).
|
| 467 |
+
3. **Faktor sekunder** (tetap relevan): deskripsi pemilik sangat pendek
|
| 468 |
+
(median 23 kata) memberi sedikit konteks untuk embedding; MiniLM
|
| 469 |
+
multilingual kurang terspesialisasi untuk nuansa Indonesia.
|
| 470 |
+
|
| 471 |
+
**Implikasi metodologis**: untuk corpus yang di-pool lexical, melaporkan
|
| 472 |
+
HANYA standard top-K akan menyesatkan tentang kemampuan model semantic.
|
| 473 |
+
Karena itu kedua angka dilaporkan (§7.1a vs §7.1b).
|
| 474 |
+
|
| 475 |
+
### 8.3 Hybrid: Grid Search Alpha (0.3 → 0.9)
|
| 476 |
+
|
| 477 |
+
Iterasi awal memakai `alpha=0.3` (70% weight MiniLM) dan hybrid terseret
|
| 478 |
+
jatuh di standard eval (MAP 0.188 di n=30) karena skor neural tertekan
|
| 479 |
+
pooling bias. Grid search alpha ∈ [0, 1] step 0.1 dijalankan di dua lensa
|
| 480 |
+
(`eval/hybrid_alpha_grid.csv` + `hybrid_alpha_grid_pool.csv`):
|
| 481 |
+
|
| 482 |
+
- **Standard**: monoton naik sampai alpha=1.0 (murni BM25). Ini bukan
|
| 483 |
+
bukti MiniLM tidak berguna, melainkan artefak GT lexical-pooled: bobot
|
| 484 |
+
neural berapa pun "merusak" metric yang di-pool dari BM25.
|
| 485 |
+
- **Pool-restricted (fair, dasar keputusan)**: kurva nyaris datar di
|
| 486 |
+
0.6-0.9 dengan puncak **alpha=0.9** (MAP 0.5909 vs BM25 murni 0.5836,
|
| 487 |
+
alpha lama 0.3 = 0.5836). Selisihnya kecil dan tidak signifikan
|
| 488 |
+
(inconclusive), tapi alpha tinggi juga menghapus kerugian besar di
|
| 489 |
+
standard (MAP 0.267 vs 0.188).
|
| 490 |
+
|
| 491 |
+
Default produksi kini `alpha=0.9`: hybrid = BM25 + tie-breaking semantic
|
| 492 |
+
tipis. Interpretasi jujur: pada corpus 227 dengan deskripsi terse, sinyal
|
| 493 |
+
MiniLM hanya membantu di margin; nilai utamanya teredam pooling bias
|
| 494 |
+
(lihat pool-restricted: hybrid MAP 0.591, tertinggi di antara model
|
| 495 |
+
non-smart).
|
| 496 |
+
|
| 497 |
+
Mitigasi eval yang tersisa (future work):
|
| 498 |
+
- Pool GT dari union multi-model (BM25 ∪ MiniLM ∪ TF-IDF ∪ Smart) untuk
|
| 499 |
+
hilangkan pooling bias — perbaikan eval paling berdampak
|
| 500 |
+
- Human annotation pass untuk GT yang truly model-agnostic
|
| 501 |
+
|
| 502 |
+
### 8.3b Mengapa Smart Dipilih sebagai Model Live?
|
| 503 |
+
|
| 504 |
+
Smart menjawab kelemahan praktis semua model murni-teks: query kos riil
|
| 505 |
+
("kos putri deket unila max 800rb ada wifi") membawa **constraint
|
| 506 |
+
terstruktur** yang tidak terbaca BM25/TF-IDF sebagai constraint, hanya
|
| 507 |
+
sebagai token. Smart memecah query: parser menarik gender/harga/fasilitas/
|
| 508 |
+
anchor; BM25 menangani sisa teks (setelah preprocessing yang sama dengan
|
| 509 |
+
corpus); skor akhir = fusi teks + kedekatan geografis + kecocokan
|
| 510 |
+
fasilitas; hard filter dengan relaxation bertahap menjamin hasil tidak
|
| 511 |
+
kosong dan pelonggaran dilaporkan jujur ke user (chip "filter
|
| 512 |
+
dilonggarkan").
|
| 513 |
+
|
| 514 |
+
Tiga detail desain penting:
|
| 515 |
+
1. **Geo-augment kandidat**: saat anchor terdeteksi, kandidat BM25
|
| 516 |
+
di-union dengan semua listing radius 3 km. Tanpa ini, listing dekat
|
| 517 |
+
ITERA yang deskripsinya tidak menulis "itera" mustahil muncul (skor geo
|
| 518 |
+
hanya bisa menata ulang kandidat yang ada, tidak bisa menambah).
|
| 519 |
+
2. **Filter UI = constraint eksplisit**: tidak pernah di-relax. Relaxation
|
| 520 |
+
hanya untuk constraint hasil inferensi teks.
|
| 521 |
+
3. **Bobot fusion dari grid search, bukan tebakan**: simplex step 0.1
|
| 522 |
+
(55 kombinasi, `eval/smart_weights_grid.csv`) memilih **0.2 teks /
|
| 523 |
+
0.4 geo / 0.4 atribut**, menang atas default awal 0.4/0.4/0.2 di kedua
|
| 524 |
+
lensa (CS@5 0.863 vs 0.810, Wilcoxon p=0.023; MAP standard 0.301 vs
|
| 525 |
+
0.287). Kombinasi ber-CS tertinggi (mis. teks 0.1 / atribut 0.7)
|
| 526 |
+
ditolak sadar: CS@5 memang menghitung atribut, jadi bobot atribut
|
| 527 |
+
ekstrem = overfit metric; teks tetap dibutuhkan untuk query kualitas
|
| 528 |
+
("nyaman", "bersih") di luar pola constraint.
|
| 529 |
+
|
| 530 |
+
Bukti kuantitatif: CS@5 0.86 vs 0.45 (p<0.001, §7.4); standard MAP juga
|
| 531 |
+
tertinggi (0.301) meski selisihnya terhadap BM25 belum signifikan (n=30).
|
| 532 |
+
Biaya: nol model neural di runtime — aman untuk free tier 512MB.
|
| 533 |
+
|
| 534 |
+
### 8.4 Karakteristik Data Real vs Coverage
|
| 535 |
+
|
| 536 |
+
- **Deskripsi terse**: median 23 kata (vs synthetic 110+). Menurunkan
|
| 537 |
+
absolute IR metric tapi mencerminkan realita — pemilik kos menulis singkat
|
| 538 |
+
("dekat UNILA, jalan kaki 5 menit"). Sistem production harus handle ini.
|
| 539 |
+
- **Distribusi kecamatan tidak merata**: Sukarame/Kedaton/Rajabasa dominan
|
| 540 |
+
(dekat UNILA/UIN/ITERA/Polinela) karena memang di situ density kos Mamikos
|
| 541 |
+
tertinggi. Mencerminkan pasar nyata, bukan artefak sampling.
|
| 542 |
+
- **Koordinat real**: 226/227 listing punya lat/lng asli Mamikos →
|
| 543 |
+
langsung dipakai untuk pin Google Maps di web app. 1 listing dengan
|
| 544 |
+
koordinat korup (geocoding fallback ke Jakarta) di-null secara eksplisit.
|
| 545 |
+
|
| 546 |
+
### 8.5 Limitations
|
| 547 |
+
|
| 548 |
+
1. **Corpus size 227**: dibatasi ketersediaan listing Mamikos Bandar Lampung
|
| 549 |
+
yang ter-index Google (~300-400 total) + success rate ekstraksi ~76%.
|
| 550 |
+
2. **Pooling bias**: GT di-pool dari BM25 → bias terhadap model lexical
|
| 551 |
+
(dimitigasi sebagian via pool-restricted eval, belum via human/multi-model pool).
|
| 552 |
+
3. **AI-assisted GT**: heuristic scoring, bukan true human judgment.
|
| 553 |
+
4. **Sample size n=30 query** (dinaikkan dari 15): power lebih baik tapi
|
| 554 |
+
tetap moderat; semua hasil non-signifikan dibaca inconclusive, bukan
|
| 555 |
+
"setara". Hyperparameter (alpha, bobot fusion) dipilih pada data eval
|
| 556 |
+
yang sama dengan yang dilaporkan (tanpa held-out set) — di-disclose
|
| 557 |
+
sebagai mild selection bias.
|
| 558 |
+
5. **Single embedding model**: hanya MiniLM multilingual (fastembed ONNX);
|
| 559 |
+
`indobert-base-p2` belum diuji (RAM constraint Render free tier;
|
| 560 |
+
deploy HF Spaces 16GB membuka jalan untuk menguji ini).
|
| 561 |
+
6. **Sinyal annotator vs filter Smart tumpang tindih**: heuristik
|
| 562 |
+
annotator simulasi menghukum mismatch gender/harga, yang juga
|
| 563 |
+
merupakan hard filter Smart — angka pool-restricted Smart (MAP 0.865)
|
| 564 |
+
karenanya optimistic; lensa CS@5 yang bebas qrels dilaporkan sebagai
|
| 565 |
+
pengimbang.
|
| 566 |
+
|
| 567 |
+
---
|
| 568 |
+
|
| 569 |
+
## 9. System Architecture
|
| 570 |
+
|
| 571 |
+
### 9.1 Stack
|
| 572 |
+
|
| 573 |
+
| Layer | Tech |
|
| 574 |
+
|-------|------|
|
| 575 |
+
| Frontend | React 18 + Vite 5 + TypeScript — 4 tab: Pencarian, Evaluasi Model, Preprocessing (visualisasi 9-stage), Statistik |
|
| 576 |
+
| Backend | Python 3.11 + FastAPI + Uvicorn |
|
| 577 |
+
| Database | PostgreSQL 16 (asyncpg + SQLAlchemy 2.0 + Alembic) |
|
| 578 |
+
| IR Libraries | Sastrawi, rank_bm25, fastembed (ONNX MiniLM), faiss-cpu, scikit-learn |
|
| 579 |
+
| Deploy | Docker single-container: HF Spaces (16GB, neural ON) + Render.com backup (512MB, smart-only) |
|
| 580 |
+
|
| 581 |
+
### 9.2 API Endpoints
|
| 582 |
+
|
| 583 |
+
| Method | Path | Description |
|
| 584 |
+
|--------|------|-------------|
|
| 585 |
+
| GET | `/health` | Liveness probe |
|
| 586 |
+
| GET | `/api/search` | IR search dengan model & filter (`q`, `model`, `top_k`, `harga_min`, `harga_max`, `tipe`, `kecamatan`); filter berlaku untuk SEMUA model termasuk smart |
|
| 587 |
+
| GET | `/api/listings/{id}` | Listing detail |
|
| 588 |
+
| GET | `/api/eval/summary` | Aggregate eval (standard + pool-restricted + CS@5 + Wilcoxon/Holm), file-based dari CSV hasil eval |
|
| 589 |
+
| GET | `/api/eval/query/{query_id}` | Per-query breakdown |
|
| 590 |
+
| GET | `/api/preprocess` | Trace pipeline 9-stage per langkah (tab Preprocessing) |
|
| 591 |
+
| GET | `/api/stats` | Statistik corpus live (distribusi kecamatan/tipe, harga, vocab) |
|
| 592 |
+
| GET | `/api/status` | Diagnostics lengkap (index loaded, RAM, DB) |
|
| 593 |
+
| GET | `/api/docs` | Swagger UI |
|
| 594 |
+
| GET | `/` (catch-all) | React SPA index.html |
|
| 595 |
+
|
| 596 |
+
### 9.3 Deployment
|
| 597 |
+
|
| 598 |
+
Satu Dockerfile untuk dua platform (neural di-gate env `ENABLE_NEURAL`):
|
| 599 |
+
|
| 600 |
+
- **HF Spaces** (primary demo) → `https://dymazeh-kozynear.hf.space` —
|
| 601 |
+
CPU basic free 16GB RAM, `ENABLE_NEURAL=true`: kelima model live
|
| 602 |
+
termasuk Neural MiniLM + Hybrid. Model ONNX di-pre-download saat build.
|
| 603 |
+
- **Render.com** (backup) → `https://kozynear.onrender.com` — free 512MB,
|
| 604 |
+
`ENABLE_NEURAL=false`: smart/BM25/TF-IDF. `kozynear-db` PostgreSQL
|
| 605 |
+
managed dipakai kedua deploy.
|
| 606 |
+
- Cold start: `seed_db --truncate --skip-if-synced` (skip reseed kalau DB
|
| 607 |
+
sudah sinkron); GitHub Actions `keepalive.yml` ping /health tiap 12
|
| 608 |
+
menit supaya tidak spin-down saat demo.
|
| 609 |
+
|
| 610 |
+
---
|
| 611 |
+
|
| 612 |
+
## 10. Conclusion & Future Work
|
| 613 |
+
|
| 614 |
+
### 10.1 Conclusion
|
| 615 |
+
|
| 616 |
+
KozyNear berhasil membangun end-to-end IR system khusus kos-kosan
|
| 617 |
+
**seluruh Bandar Lampung** dengan corpus **227 listing REAL Mamikos**
|
| 618 |
+
(deskripsi pemilik + koordinat asli) dan 5 model retrieval. Finding utama:
|
| 619 |
+
|
| 620 |
+
1. **Smart (query understanding + geo + BM25 fusion) sebagai model live**:
|
| 621 |
+
MAP standard tertinggi (0.301, MRR 0.868) dan CS@5 0.86 vs BM25 0.45
|
| 622 |
+
(Wilcoxon p<0.001, n=30). Memenuhi kebutuhan terstruktur user (gender/
|
| 623 |
+
budget/fasilitas/jarak kampus) yang tidak terbaca model murni-teks,
|
| 624 |
+
tanpa model neural di runtime. Bobot fusion 0.2/0.4/0.4 hasil grid
|
| 625 |
+
search dua-lensa.
|
| 626 |
+
2. **BM25 best lexical baseline** (MAP 0.270, P@5 0.553) — setelah koreksi
|
| 627 |
+
Holm-Bonferroni, unggul signifikan hanya vs Neural (p-Holm<0.001);
|
| 628 |
+
selisih antar model non-neural (vs TF-IDF/Hybrid/Smart) inconclusive
|
| 629 |
+
pada n=30.
|
| 630 |
+
3. **Neural MiniLM bukan buruk — pooling bias**: MAP melompat 0.057 →
|
| 631 |
+
0.557 saat pool-restricted. Pelajaran metodologis tentang pelaporan
|
| 632 |
+
multi-lensa (standard + pool-restricted + CS@5).
|
| 633 |
+
4. **Hybrid butuh alpha tinggi** (grid search → 0.9): pada GT
|
| 634 |
+
lexical-pooled, bobot neural besar hanya merugikan; dengan alpha 0.9
|
| 635 |
+
hybrid jadi model non-smart terbaik di pool-restricted (MAP 0.591).
|
| 636 |
+
5. **Data real menuntut kejujuran**: deskripsi terse menurunkan absolute
|
| 637 |
+
metric, tapi sistem siap production (koordinat Maps terverifikasi,
|
| 638 |
+
traceable url_source, model identity disclosed).
|
| 639 |
+
|
| 640 |
+
### 10.2 Rubric Coverage
|
| 641 |
+
|
| 642 |
+
| Aspect | Weight | Coverage |
|
| 643 |
+
|--------|--------|----------|
|
| 644 |
+
| Theme | 15% | Kos search Bandar Lampung multi-universitas + integrasi Google Maps, under-represented di IR Indonesia |
|
| 645 |
+
| Dataset | 15% | **227 listing REAL Mamikos** (scrape detail-page, 18 kecamatan, koordinat asli), methodology + pipeline disclosed |
|
| 646 |
+
| Preprocessing | 15% | 9-stage pipeline + ~110 jargon entries (>100 req) + benchmark + **visualisasi live per-stage di tab Preprocessing** |
|
| 647 |
+
| Model | 20% | 5 model unified (TF-IDF/BM25/Neural MiniLM/Hybrid/**Smart live**) + honest comparison 3 lensa + hyperparameter via grid search (alpha 0.9, fusion 0.2/0.4/0.4) |
|
| 648 |
+
| Evaluation | 10% | n=30 query, 900 annotations; P@K + MAP + NDCG + MRR + Cohen's Kappa + Wilcoxon **dengan koreksi Holm-Bonferroni** (10 pasangan, 4 signifikan) + Constraint-Satisfaction@5 + dashboard eval live di UI |
|
| 649 |
+
| System | 25% | FastAPI + React 4-tab + PG + Docker; deploy ganda HF Spaces (neural ON) + Render backup + keepalive |
|
| 650 |
+
|
| 651 |
+
### 10.3 Future Work
|
| 652 |
+
|
| 653 |
+
1. **Perluas corpus real**: scrape kompetitor (Cove, Rukita, sewakost.com)
|
| 654 |
+
atau Google Places untuk listing di luar Mamikos → tembus >300 real.
|
| 655 |
+
2. **Hilangkan pooling bias**: pool GT dari union multi-model (BM25 ∪
|
| 656 |
+
MiniLM ∪ TF-IDF ∪ Smart) atau human annotation — perbaikan eval paling
|
| 657 |
+
berdampak.
|
| 658 |
+
3. **Indonesian-specialized embedding**: comparison `indobenchmark/indobert-base-p2`
|
| 659 |
+
(kini feasible di HF Spaces 16GB).
|
| 660 |
+
4. **Human annotation pass**: replace AI-assisted GT (protocol di docs).
|
| 661 |
+
5. **Held-out query set**: hyperparameter (alpha, bobot fusion) saat ini
|
| 662 |
+
dipilih di data eval yang sama; pisahkan dev/test set di iterasi
|
| 663 |
+
berikutnya. (Selesai di iterasi ini: query 15→30, grid search alpha
|
| 664 |
+
dan bobot fusion.)
|
| 665 |
+
|
| 666 |
+
---
|
| 667 |
+
|
| 668 |
+
## 11. Repository Structure
|
| 669 |
+
|
| 670 |
+
```
|
| 671 |
+
KozyNear/
|
| 672 |
+
├── LAPORAN.md # ini, single-doc report
|
| 673 |
+
├── README.md # quick start + project overview
|
| 674 |
+
├── Dockerfile # multi-stage build (Node + Python)
|
| 675 |
+
├── render.yaml # Render infra-as-code
|
| 676 |
+
├── docker-compose.yml # local PG dev
|
| 677 |
+
├── docs/
|
| 678 |
+
│ ├── architecture.md
|
| 679 |
+
│ ├── deploy_guide.md
|
| 680 |
+
│ └── ground_truth_protocol.md
|
| 681 |
+
├── data/
|
| 682 |
+
│ ├── raw/mamikos_real_v2.jsonl # 227 listing REAL Mamikos (committed)
|
| 683 |
+
│ ├── raw/_discovered_slugs.txt # 312 URL discovery (committed)
|
| 684 |
+
│ ├── processed/corpus.json # post-preprocessing (committed)
|
| 685 |
+
│ └── indexes/ # TF-IDF + BM25 + IndoBERT (committed)
|
| 686 |
+
├── eval/
|
| 687 |
+
│ ├── queries.json # 15 test queries (v1.0 real-data)
|
| 688 |
+
│ ├── ground_truth.csv # 900 consensus annotations
|
| 689 |
+
│ ├── kappa_report.md # inter-annotator agreement
|
| 690 |
+
│ ├── annotations_annotator_{A,B,C}.csv
|
| 691 |
+
│ ├── results.csv # per-model per-query (standard)
|
| 692 |
+
│ ├── results_pool_restricted.csv # pool-restricted (fair) eval
|
| 693 |
+
│ ├── results.withsynth.csv # arsip: eval era synthetic (perbandingan)
|
| 694 |
+
│ ├── _audit_dataset.py # data quality audit (reusable)
|
| 695 |
+
│ └── eval_summary.md # aggregate + Wilcoxon + analysis
|
| 696 |
+
├── backend/
|
| 697 |
+
│ ├── app/ # FastAPI app
|
| 698 |
+
│ ├── alembic/ # DB migrations
|
| 699 |
+
│ ├── scripts/ # generate, preprocess, gen_GT, eval_report
|
| 700 |
+
│ └── tests/ # pytest suite
|
| 701 |
+
└── frontend/
|
| 702 |
+
└── src/
|
| 703 |
+
├── api/client.ts # typed API client
|
| 704 |
+
├── components/ # FilterPanel, ResultCard
|
| 705 |
+
└── App.tsx # main SPA
|
| 706 |
+
```
|
| 707 |
+
|
| 708 |
+
---
|
| 709 |
+
|
| 710 |
+
## 12. Acknowledgments
|
| 711 |
+
|
| 712 |
+
Project ini didevelop dengan AI agent assistance (Claude) sebagai
|
| 713 |
+
implementation executor, dengan user sebagai project manager / reviewer.
|
| 714 |
+
Methodology disclosure jujur tentang scrape data real Mamikos, pooling
|
| 715 |
+
bias pada GT yang AI-assisted, dan keputusan drop synthetic demi keaslian
|
| 716 |
+
data — untuk maintain integrity research findings. Source code + data
|
| 717 |
+
public di GitHub untuk reproducibility.
|
| 718 |
+
|
| 719 |
+
---
|
| 720 |
+
|
| 721 |
+
**Submitted**: 17 Juni 2026 (vClass) — *placeholder, submission setelah final review*
|
| 722 |
+
**License**: MIT (lihat `LICENSE`)
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 TKI-KOS Team — Universitas Lampung
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,11 +1,237 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: KozyNear
|
| 3 |
+
emoji: 🏠
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 10000
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
+
short_description: Indonesian Kos-Kosan Search Engine Bandar Lampung
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# KozyNear — Indonesian Kos-Kosan Search Engine
|
| 14 |
+
|
| 15 |
+
> Final project Mata Kuliah **Temu Kembali Informasi** (COM620321, 3 SKS) — Universitas Lampung (UNILA)
|
| 16 |
+
> Search engine kos-kosan **seluruh Bandar Lampung** — 20 kecamatan, 9 universitas (UNILA, ITERA, Darmajaya, UBL, UIN, Teknokrat, Polinela, Malahayati, Saburai)
|
| 17 |
+
|
| 18 |
+
🌐 **Live**: deploy via [HuggingFace Spaces](https://huggingface.co/spaces) (lihat [docs/deploy_huggingface.md](docs/deploy_huggingface.md))
|
| 19 |
+
📖 **Laporan**: [LAPORAN.md](LAPORAN.md)
|
| 20 |
+
|
| 21 |
+

|
| 22 |
+

|
| 23 |
+

|
| 24 |
+

|
| 25 |
+

|
| 26 |
+
|
| 27 |
+
## Overview
|
| 28 |
+
|
| 29 |
+
KozyNear adalah Information Retrieval system untuk listing kos-kosan di **seluruh Bandar Lampung** — berguna untuk mahasiswa dari **9 universitas** (UNILA, ITERA, Darmajaya, UBL, UIN Raden Intan, Teknokrat, Polinela, Malahayati, Saburai). User cari kos dengan natural language query bahasa Indonesia, dengan **5 model retrieval**:
|
| 30 |
+
|
| 31 |
+
1. **Lexical baseline** — TF-IDF + cosine similarity (scikit-learn)
|
| 32 |
+
2. **Probabilistic lexical** — BM25 (`rank_bm25`)
|
| 33 |
+
3. **Neural / semantic** — MiniLM multilingual sentence embeddings (via fastembed ONNX; label kode `indobert` historis) + FAISS
|
| 34 |
+
4. **Hybrid** — BM25 top-50 candidate → MiniLM rerank top-10
|
| 35 |
+
5. **Smart (default live)** — query understanding (parser gender/harga/fasilitas/anchor) + geo ranking (gazetteer kampus terverifikasi + haversine) + BM25 fusion; hard filter dengan relaxation jujur
|
| 36 |
+
|
| 37 |
+
UI punya 4 tab demo: **Pencarian**, **Evaluasi Model** (metrik live + signifikansi), **Preprocessing** (visualisasi 9-stage per langkah), **Statistik** (distribusi corpus).
|
| 38 |
+
|
| 39 |
+
## Tech Stack
|
| 40 |
+
|
| 41 |
+
| Layer | Tech |
|
| 42 |
+
|-------|------|
|
| 43 |
+
| Frontend | React 18 + Vite 5 + TypeScript |
|
| 44 |
+
| Backend | Python 3.11 + FastAPI + Uvicorn |
|
| 45 |
+
| Database | PostgreSQL 16 (asyncpg + SQLAlchemy 2.0) |
|
| 46 |
+
| Migrations | Alembic |
|
| 47 |
+
| IR Libraries | Sastrawi, rank_bm25, fastembed (ONNX MiniLM), faiss-cpu, scikit-learn |
|
| 48 |
+
| Eval | scipy (Wilcoxon + Holm-Bonferroni), custom Kappa + IR metrics + CS@K |
|
| 49 |
+
| Deploy | HF Spaces (16GB, neural ON) + Render.com backup; satu Dockerfile, gated `ENABLE_NEURAL` |
|
| 50 |
+
|
| 51 |
+
## Quick Start (Local Dev)
|
| 52 |
+
|
| 53 |
+
### Prerequisites
|
| 54 |
+
|
| 55 |
+
- Python 3.11+, Node 20+, Docker Desktop, Git
|
| 56 |
+
|
| 57 |
+
### Setup
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
# 1. Clone repo
|
| 61 |
+
git clone https://github.com/DYmazeh/TKI-KOS.git
|
| 62 |
+
cd TKI-KOS
|
| 63 |
+
|
| 64 |
+
# 2. Start local PostgreSQL via Docker
|
| 65 |
+
docker compose up -d
|
| 66 |
+
|
| 67 |
+
# 3. Backend setup
|
| 68 |
+
cd backend
|
| 69 |
+
python -m venv .venv
|
| 70 |
+
.venv\Scripts\activate # Windows
|
| 71 |
+
# source .venv/bin/activate # macOS/Linux
|
| 72 |
+
pip install -r requirements.txt
|
| 73 |
+
cp .env.example .env
|
| 74 |
+
|
| 75 |
+
# 4. Run migrations
|
| 76 |
+
alembic upgrade head
|
| 77 |
+
|
| 78 |
+
# 5. (Optional, kalau scrape data sudah ada) Seed DB + preprocess
|
| 79 |
+
python -m scripts.seed_db --input ../data/raw/mamikos.jsonl \
|
| 80 |
+
--preprocess --corpus-output ../data/processed/corpus.json
|
| 81 |
+
|
| 82 |
+
# 6. (Optional, kalau corpus.json sudah ada) Build indexes
|
| 83 |
+
python -m app.indexing.build --corpus ../data/processed/corpus.json \
|
| 84 |
+
--output-dir ../data/indexes
|
| 85 |
+
|
| 86 |
+
# 7. Run backend
|
| 87 |
+
uvicorn app.main:app --reload --port 8000
|
| 88 |
+
# Backend: http://localhost:8000 -- docs: http://localhost:8000/docs
|
| 89 |
+
|
| 90 |
+
# 8. Frontend (terminal baru)
|
| 91 |
+
cd frontend
|
| 92 |
+
npm install
|
| 93 |
+
cp .env.example .env
|
| 94 |
+
npm run dev
|
| 95 |
+
# Frontend: http://localhost:5173
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
## API Endpoints
|
| 99 |
+
|
| 100 |
+
| Method | Path | Description |
|
| 101 |
+
|--------|------|-------------|
|
| 102 |
+
| GET | `/` | Root info |
|
| 103 |
+
| GET | `/health` | Liveness probe (Render uses this) |
|
| 104 |
+
| GET | `/api/search` | IR search dengan filter (`q`, `model`, `top_k`, `harga_min`, `harga_max`, `tipe`, `kecamatan`); filter berlaku untuk semua model termasuk smart |
|
| 105 |
+
| GET | `/api/listings/{id}` | Detail kos by ID |
|
| 106 |
+
| GET | `/api/eval/summary` | Aggregate metrics (standard + pool-restricted + CS@5 + Wilcoxon/Holm), file-based |
|
| 107 |
+
| GET | `/api/eval/query/{query_id}` | Per-query metric breakdown |
|
| 108 |
+
| GET | `/api/preprocess` | Trace pipeline 9-stage per langkah |
|
| 109 |
+
| GET | `/api/stats` | Statistik corpus live |
|
| 110 |
+
| GET | `/api/status` | Diagnostics (index loaded, RAM, DB) |
|
| 111 |
+
| GET | `/api/docs` | Swagger UI (auto) |
|
| 112 |
+
|
| 113 |
+
## Project Structure
|
| 114 |
+
|
| 115 |
+
```
|
| 116 |
+
TKI-KOS/
|
| 117 |
+
├── frontend/ # React Vite SPA (TypeScript)
|
| 118 |
+
│ ├── src/
|
| 119 |
+
│ │ ├── api/ # API client
|
| 120 |
+
│ │ ├── components/ # FilterPanel, ResultCard
|
| 121 |
+
│ │ └── App.tsx
|
| 122 |
+
│ └── package.json
|
| 123 |
+
├── backend/ # Python FastAPI
|
| 124 |
+
│ ��── app/
|
| 125 |
+
│ │ ├── api/ # Routes: health, search, listings, eval
|
| 126 |
+
│ │ ├── core/ # config, db
|
| 127 |
+
│ │ ├── models/ # ORM + Pydantic schemas
|
| 128 |
+
│ │ ├── scraper/ # Mamikos + OLX + base + utils
|
| 129 |
+
│ │ ├── preprocessing/ # 9-stage pipeline + 108 jargon dict
|
| 130 |
+
│ │ ├── indexing/ # TF-IDF + BM25 + IndoBERT + Hybrid
|
| 131 |
+
│ │ ├── search/ # query handling
|
| 132 |
+
│ │ ├── evaluation/ # P@K, MAP, NDCG, Kappa, stat tests
|
| 133 |
+
│ │ └── main.py # FastAPI entry + lifespan
|
| 134 |
+
│ ├── alembic/ # Database migrations
|
| 135 |
+
│ ├── scripts/ # seed_db.py
|
| 136 |
+
│ ├── tests/ # pytest suite
|
| 137 |
+
│ └── requirements.txt
|
| 138 |
+
├── data/
|
| 139 |
+
│ ├── raw/ # Scraped JSONL (gitignored)
|
| 140 |
+
│ ├── processed/ # corpus.json setelah preprocessing
|
| 141 |
+
│ └── indexes/ # TF-IDF/BM25/FAISS serialized
|
| 142 |
+
├── notebooks/ # EDA, preprocessing experiment, eval
|
| 143 |
+
├── eval/ # queries.json, ground_truth.csv, results.csv
|
| 144 |
+
├── docs/ # architecture, deploy guide, GT protocol
|
| 145 |
+
├── docker-compose.yml # Local PG
|
| 146 |
+
├── render.yaml # Render infrastructure-as-code
|
| 147 |
+
└── README.md
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
## Roles & Team (5 anggota)
|
| 151 |
+
|
| 152 |
+
| Role | Bidang | Module |
|
| 153 |
+
|------|--------|--------|
|
| 154 |
+
| A — Lead / Scraper | Koordinasi tim + Mamikos scraper + data quality | `backend/app/scraper/` |
|
| 155 |
+
| B — Preprocessing | Custom jargon dictionary + pipeline tuning | `backend/app/preprocessing/` |
|
| 156 |
+
| C — IR Baseline | TF-IDF + BM25 hyperparameter tuning | `backend/app/indexing/` |
|
| 157 |
+
| D — IR Neural | IndoBERT + FAISS + hybrid tuning | `backend/app/indexing/` |
|
| 158 |
+
| E — Frontend + Eval | React UI + ground truth coord + metrics | `frontend/` + `backend/app/evaluation/` |
|
| 159 |
+
|
| 160 |
+
## Timeline (4-week sprint)
|
| 161 |
+
|
| 162 |
+
| Week | Dates | Milestone |
|
| 163 |
+
|------|-------|-----------|
|
| 164 |
+
| 1 | 20–26 Mei 2026 | Repo setup, Mamikos scraper, ≥1500 listings |
|
| 165 |
+
| 2 | 27 Mei–2 Jun | Preprocessing pipeline, 3 IR indexes, FastAPI scaffold |
|
| 166 |
+
| 3 | 3–9 Jun | React UI live, ground truth annotation (Kappa ≥0.7), deploy Render |
|
| 167 |
+
| 4 | 10–16 Jun | Evaluation, statistical tests, report finalization |
|
| 168 |
+
| **Submit** | **17 Jun 23:59 WIB** | Upload ke vClass UNILA |
|
| 169 |
+
|
| 170 |
+
## Documentation
|
| 171 |
+
|
| 172 |
+
| Topic | File |
|
| 173 |
+
|-------|------|
|
| 174 |
+
| Architecture (komponen, data flow, deploy strategy) | [docs/architecture.md](docs/architecture.md) |
|
| 175 |
+
| Deploy guide step-by-step (Render.com) | [docs/deploy_guide.md](docs/deploy_guide.md) |
|
| 176 |
+
| Ground truth annotation protocol (3-annotator + Kappa) | [docs/ground_truth_protocol.md](docs/ground_truth_protocol.md) |
|
| 177 |
+
| Scraper usage + TODOs | [backend/app/scraper/README.md](backend/app/scraper/README.md) |
|
| 178 |
+
| Preprocessing pipeline + jargon dict | [backend/app/preprocessing/README.md](backend/app/preprocessing/README.md) |
|
| 179 |
+
| IR indexes (TF-IDF/BM25/IndoBERT/Hybrid) | [backend/app/indexing/README.md](backend/app/indexing/README.md) |
|
| 180 |
+
| Evaluation metrics + Kappa + stat tests | [backend/app/evaluation/README.md](backend/app/evaluation/README.md) |
|
| 181 |
+
| Data schema + acquisition methodology | [data/README.md](data/README.md) |
|
| 182 |
+
| Notebooks convention | [notebooks/README.md](notebooks/README.md) |
|
| 183 |
+
|
| 184 |
+
## Course Rubric Coverage
|
| 185 |
+
|
| 186 |
+
| Aspect | Weight | Status |
|
| 187 |
+
|--------|--------|--------|
|
| 188 |
+
| Theme | 15% | Kos-kosan **seluruh Bandar Lampung**, multi-university (UNILA, ITERA, Darmajaya, UBL, UIN, Teknokrat, Polinela, Malahayati, Saburai) |
|
| 189 |
+
| Dataset | 15% | **227 listing REAL Mamikos** (scrape detail-page, deskripsi pemilik + koordinat asli, 18 kecamatan), methodology + pipeline disclosed |
|
| 190 |
+
| Preprocessing | 15% | 9-stage pipeline + ~110 domain jargon entries (>=100 rubric req) + visualisasi live per-stage |
|
| 191 |
+
| Model | 20% | 5 model (TF-IDF/BM25/Neural MiniLM/Hybrid/**Smart live**) — Smart teratas standard MAP 0.301 + CS@5 0.86 vs BM25 0.45 (p<0.001, n=30); hyperparameter via grid search; pooling-bias dibahas jujur |
|
| 192 |
+
| Evaluation | 10% | 30 query, 900 annotations; P@K + MAP + NDCG + MRR + Cohen's Kappa (all pair >=0.58) + Wilcoxon + **Holm-Bonferroni** + Constraint-Satisfaction@5 |
|
| 193 |
+
| System | 25% | FastAPI + React 4-tab + Postgres single Docker; HF Spaces (neural ON) + Render backup |
|
| 194 |
+
|
| 195 |
+
## Deploy URLs
|
| 196 |
+
|
| 197 |
+
- **Live App (primary)**: https://dymazeh-kozynear.hf.space (HF Spaces 16GB, `ENABLE_NEURAL=true`: kelima model live) — lihat [docs/deploy_huggingface.md](docs/deploy_huggingface.md)
|
| 198 |
+
- **Backup**: https://kozynear.onrender.com (Render free 512MB, smart/BM25/TF-IDF)
|
| 199 |
+
- **API Docs**: `<base-url>/api/docs` (Swagger UI)
|
| 200 |
+
- **Health**: `<base-url>/health`
|
| 201 |
+
|
| 202 |
+
⚠️ Render free spin down 15 menit idle (cold start ~30-60s); HF Spaces sleep setelah 48 jam idle. Workflow [keepalive.yml](.github/workflows/keepalive.yml) ping keduanya tiap 12 menit.
|
| 203 |
+
|
| 204 |
+
## Live Results Summary (n=30 queries × 5 models, corpus real 227)
|
| 205 |
+
|
| 206 |
+
**Standard top-K** (IR atas seluruh corpus):
|
| 207 |
+
|
| 208 |
+
| Model | P@5 | P@10 | MAP | NDCG@10 | MRR |
|
| 209 |
+
|-------|-----|------|-----|---------|-----|
|
| 210 |
+
| **Smart (live)** | **0.580** | 0.480 | **0.301** | 0.548 | **0.868** |
|
| 211 |
+
| BM25 | 0.553 | **0.530** | 0.270 | **0.553** | 0.802 |
|
| 212 |
+
| Hybrid (α=0.9) | 0.553 | 0.527 | 0.267 | 0.551 | 0.804 |
|
| 213 |
+
| TF-IDF | 0.547 | 0.510 | 0.239 | 0.523 | 0.727 |
|
| 214 |
+
| Neural MiniLM | 0.160 | 0.153 | 0.057 | 0.155 | 0.280 |
|
| 215 |
+
|
| 216 |
+
**Pool-restricted** (fair — Neural MAP melompat 0.057 → **0.557**, membuktikan skor rendah di atas adalah *pooling bias*, bukan model buruk; angka Smart di lensa ini optimistic karena sinyal filter overlap dengan heuristik annotator, lihat LAPORAN §7.1b):
|
| 217 |
+
|
| 218 |
+
| Model | P@5 | MAP |
|
| 219 |
+
|-------|-----|-----|
|
| 220 |
+
| Smart (live) | 0.927 | 0.865 |
|
| 221 |
+
| Hybrid (α=0.9) | 0.553 | 0.591 |
|
| 222 |
+
| **BM25** | 0.553 | 0.583 |
|
| 223 |
+
| TF-IDF | 0.560 | 0.576 |
|
| 224 |
+
| Neural MiniLM | 0.487 | 0.557 |
|
| 225 |
+
|
| 226 |
+
**Constraint Satisfaction @5** (lensa bebas qrels: % top-5 yang memenuhi SEMUA kebutuhan user — gender, budget, fasilitas, radius 3 km kampus):
|
| 227 |
+
|
| 228 |
+
| Model | mean CS@5 (n=30) |
|
| 229 |
+
|-------|------------------|
|
| 230 |
+
| **Smart (live)** | **0.863** |
|
| 231 |
+
| BM25 | 0.447 |
|
| 232 |
+
|
| 233 |
+
Smart vs BM25 signifikan (Wilcoxon p<0.001). Signifikansi pairwise MAP memakai koreksi **Holm-Bonferroni**: **4/10 signifikan** (semuanya pasangan vs Neural di standard eval; bertahan setelah Holm). Hyperparameter via grid search: alpha hybrid 0.9, bobot fusion smart 0.2/0.4/0.4. Detail di [LAPORAN.md](LAPORAN.md) + [eval/eval_summary.md](eval/eval_summary.md).
|
| 234 |
+
|
| 235 |
+
## License
|
| 236 |
+
|
| 237 |
+
MIT — lihat [LICENSE](LICENSE)
|
backend/.env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ENVIRONMENT=development
|
| 2 |
+
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/tki_kos
|
| 3 |
+
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
| 4 |
+
EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
| 5 |
+
INDEXES_DIR=../data/indexes
|
| 6 |
+
LOG_LEVEL=INFO
|
backend/alembic.ini
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Alembic config untuk TKI-KOS backend
|
| 2 |
+
# URL di-override dari app.core.config.settings di env.py
|
| 3 |
+
|
| 4 |
+
[alembic]
|
| 5 |
+
script_location = alembic
|
| 6 |
+
prepend_sys_path = .
|
| 7 |
+
version_path_separator = os
|
| 8 |
+
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/tki_kos
|
| 9 |
+
|
| 10 |
+
[post_write_hooks]
|
| 11 |
+
# Optional: auto-format migration files setelah generate
|
| 12 |
+
# hooks = black
|
| 13 |
+
# black.type = console_scripts
|
| 14 |
+
# black.entrypoint = black
|
| 15 |
+
# black.options = REVISION_SCRIPT_FILENAME
|
| 16 |
+
|
| 17 |
+
[loggers]
|
| 18 |
+
keys = root,sqlalchemy,alembic
|
| 19 |
+
|
| 20 |
+
[handlers]
|
| 21 |
+
keys = console
|
| 22 |
+
|
| 23 |
+
[formatters]
|
| 24 |
+
keys = generic
|
| 25 |
+
|
| 26 |
+
[logger_root]
|
| 27 |
+
level = WARN
|
| 28 |
+
handlers = console
|
| 29 |
+
qualname =
|
| 30 |
+
|
| 31 |
+
[logger_sqlalchemy]
|
| 32 |
+
level = WARN
|
| 33 |
+
handlers =
|
| 34 |
+
qualname = sqlalchemy.engine
|
| 35 |
+
|
| 36 |
+
[logger_alembic]
|
| 37 |
+
level = INFO
|
| 38 |
+
handlers =
|
| 39 |
+
qualname = alembic
|
| 40 |
+
|
| 41 |
+
[handler_console]
|
| 42 |
+
class = StreamHandler
|
| 43 |
+
args = (sys.stderr,)
|
| 44 |
+
level = NOTSET
|
| 45 |
+
formatter = generic
|
| 46 |
+
|
| 47 |
+
[formatter_generic]
|
| 48 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 49 |
+
datefmt = %H:%M:%S
|
backend/alembic/env.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Alembic environment script untuk async SQLAlchemy 2.0.
|
| 2 |
+
|
| 3 |
+
Migration commands (dari backend/):
|
| 4 |
+
alembic revision --autogenerate -m "create listings table"
|
| 5 |
+
alembic upgrade head
|
| 6 |
+
alembic downgrade -1
|
| 7 |
+
alembic history
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
from logging.config import fileConfig
|
| 14 |
+
|
| 15 |
+
from sqlalchemy import pool
|
| 16 |
+
from sqlalchemy.engine import Connection
|
| 17 |
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
| 18 |
+
|
| 19 |
+
from alembic import context
|
| 20 |
+
|
| 21 |
+
from app.core.config import settings
|
| 22 |
+
from app.core.db import Base
|
| 23 |
+
|
| 24 |
+
# Import ALL models di sini supaya Alembic detect untuk autogenerate
|
| 25 |
+
from app.models import listing # noqa: F401
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
config = context.config
|
| 29 |
+
if config.config_file_name is not None:
|
| 30 |
+
fileConfig(config.config_file_name)
|
| 31 |
+
|
| 32 |
+
# Override URL dari env-driven settings
|
| 33 |
+
config.set_main_option("sqlalchemy.url", settings.database_url)
|
| 34 |
+
|
| 35 |
+
target_metadata = Base.metadata
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def run_migrations_offline() -> None:
|
| 39 |
+
"""Run migrations dalam offline mode (generate SQL ke stdout)."""
|
| 40 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 41 |
+
context.configure(
|
| 42 |
+
url=url,
|
| 43 |
+
target_metadata=target_metadata,
|
| 44 |
+
literal_binds=True,
|
| 45 |
+
dialect_opts={"paramstyle": "named"},
|
| 46 |
+
)
|
| 47 |
+
with context.begin_transaction():
|
| 48 |
+
context.run_migrations()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def do_run_migrations(connection: Connection) -> None:
|
| 52 |
+
context.configure(connection=connection, target_metadata=target_metadata)
|
| 53 |
+
with context.begin_transaction():
|
| 54 |
+
context.run_migrations()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def run_async_migrations() -> None:
|
| 58 |
+
"""Run migrations dengan async engine."""
|
| 59 |
+
connectable = async_engine_from_config(
|
| 60 |
+
config.get_section(config.config_ini_section, {}),
|
| 61 |
+
prefix="sqlalchemy.",
|
| 62 |
+
poolclass=pool.NullPool,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
async with connectable.connect() as connection:
|
| 66 |
+
await connection.run_sync(do_run_migrations)
|
| 67 |
+
|
| 68 |
+
await connectable.dispose()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def run_migrations_online() -> None:
|
| 72 |
+
asyncio.run(run_async_migrations())
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if context.is_offline_mode():
|
| 76 |
+
run_migrations_offline()
|
| 77 |
+
else:
|
| 78 |
+
run_migrations_online()
|
backend/alembic/script.py.mako
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
${imports if imports else ""}
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = ${repr(up_revision)}
|
| 16 |
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
${upgrades if upgrades else "pass"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def downgrade() -> None:
|
| 26 |
+
${downgrades if downgrades else "pass"}
|
backend/alembic/versions/001_initial_schema.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""initial schema: listings + queries + ground_truth + eval_results
|
| 2 |
+
|
| 3 |
+
Revision ID: 001
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2026-05-26 00:00:00
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Sequence, Union
|
| 11 |
+
|
| 12 |
+
import sqlalchemy as sa
|
| 13 |
+
from alembic import op
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# revision identifiers, used by Alembic
|
| 17 |
+
revision: str = "001"
|
| 18 |
+
down_revision: Union[str, None] = None
|
| 19 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 20 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def upgrade() -> None:
|
| 24 |
+
# ------------------------------------------------------------------
|
| 25 |
+
# listings: raw + processed kos data
|
| 26 |
+
# ------------------------------------------------------------------
|
| 27 |
+
op.create_table(
|
| 28 |
+
"listings",
|
| 29 |
+
sa.Column("id", sa.String(), primary_key=True),
|
| 30 |
+
sa.Column("judul", sa.Text(), nullable=False),
|
| 31 |
+
sa.Column("deskripsi", sa.Text(), nullable=False),
|
| 32 |
+
sa.Column("deskripsi_processed", sa.Text(), nullable=True),
|
| 33 |
+
sa.Column("harga_per_bulan", sa.Integer(), nullable=True),
|
| 34 |
+
sa.Column("tipe", sa.String(length=20), nullable=True),
|
| 35 |
+
sa.Column("fasilitas", sa.ARRAY(sa.String()), nullable=True),
|
| 36 |
+
sa.Column("alamat", sa.Text(), nullable=True),
|
| 37 |
+
sa.Column("kecamatan", sa.String(length=100), nullable=True),
|
| 38 |
+
sa.Column("koordinat_lat", sa.Numeric(precision=8, scale=5), nullable=True),
|
| 39 |
+
sa.Column("koordinat_lng", sa.Numeric(precision=8, scale=5), nullable=True),
|
| 40 |
+
sa.Column("jarak_kampus_km", sa.Numeric(precision=5, scale=2), nullable=True),
|
| 41 |
+
sa.Column("url_source", sa.Text(), nullable=True),
|
| 42 |
+
sa.Column("scrape_date", sa.Date(), nullable=True),
|
| 43 |
+
sa.Column("source", sa.String(length=20), nullable=True),
|
| 44 |
+
sa.Column(
|
| 45 |
+
"inserted_at",
|
| 46 |
+
sa.DateTime(),
|
| 47 |
+
server_default=sa.func.now(),
|
| 48 |
+
nullable=True,
|
| 49 |
+
),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Indexes umum
|
| 53 |
+
op.create_index("ix_listings_tipe", "listings", ["tipe"])
|
| 54 |
+
op.create_index("ix_listings_kecamatan", "listings", ["kecamatan"])
|
| 55 |
+
op.create_index("ix_listings_harga", "listings", ["harga_per_bulan"])
|
| 56 |
+
|
| 57 |
+
# ------------------------------------------------------------------
|
| 58 |
+
# queries: query set untuk eval
|
| 59 |
+
# ------------------------------------------------------------------
|
| 60 |
+
op.create_table(
|
| 61 |
+
"queries",
|
| 62 |
+
sa.Column("id", sa.String(), primary_key=True),
|
| 63 |
+
sa.Column("query_text", sa.Text(), nullable=False),
|
| 64 |
+
sa.Column("context", sa.Text(), nullable=True),
|
| 65 |
+
sa.Column("expected_tipe", sa.String(length=20), nullable=True),
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# ------------------------------------------------------------------
|
| 69 |
+
# ground_truth: raw annotation per annotator (3-annotator setup)
|
| 70 |
+
# ------------------------------------------------------------------
|
| 71 |
+
op.create_table(
|
| 72 |
+
"ground_truth",
|
| 73 |
+
sa.Column("query_id", sa.String(), nullable=False),
|
| 74 |
+
sa.Column("listing_id", sa.String(), nullable=False),
|
| 75 |
+
sa.Column("annotator", sa.String(length=50), nullable=False),
|
| 76 |
+
sa.Column("relevance", sa.SmallInteger(), nullable=False),
|
| 77 |
+
sa.Column("notes", sa.Text(), nullable=True),
|
| 78 |
+
sa.CheckConstraint("relevance IN (0, 1, 2)", name="ck_relevance_0_1_2"),
|
| 79 |
+
sa.ForeignKeyConstraint(["query_id"], ["queries.id"]),
|
| 80 |
+
sa.ForeignKeyConstraint(["listing_id"], ["listings.id"]),
|
| 81 |
+
sa.PrimaryKeyConstraint("query_id", "listing_id", "annotator"),
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# ------------------------------------------------------------------
|
| 85 |
+
# ground_truth_consensus: hasil consensus 3 annotator (input ke eval runner)
|
| 86 |
+
# ------------------------------------------------------------------
|
| 87 |
+
op.create_table(
|
| 88 |
+
"ground_truth_consensus",
|
| 89 |
+
sa.Column("query_id", sa.String(), nullable=False),
|
| 90 |
+
sa.Column("listing_id", sa.String(), nullable=False),
|
| 91 |
+
sa.Column("relevance", sa.SmallInteger(), nullable=False),
|
| 92 |
+
sa.CheckConstraint("relevance IN (0, 1, 2)", name="ck_consensus_relevance"),
|
| 93 |
+
sa.ForeignKeyConstraint(["query_id"], ["queries.id"]),
|
| 94 |
+
sa.ForeignKeyConstraint(["listing_id"], ["listings.id"]),
|
| 95 |
+
sa.PrimaryKeyConstraint("query_id", "listing_id"),
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# ------------------------------------------------------------------
|
| 99 |
+
# eval_results: per-model per-query metrics
|
| 100 |
+
# ------------------------------------------------------------------
|
| 101 |
+
op.create_table(
|
| 102 |
+
"eval_results",
|
| 103 |
+
sa.Column("model", sa.String(length=20), nullable=False),
|
| 104 |
+
sa.Column("query_id", sa.String(), nullable=False),
|
| 105 |
+
sa.Column("precision_at_5", sa.Numeric(precision=5, scale=4), nullable=True),
|
| 106 |
+
sa.Column("precision_at_10", sa.Numeric(precision=5, scale=4), nullable=True),
|
| 107 |
+
sa.Column("average_precision", sa.Numeric(precision=5, scale=4), nullable=True),
|
| 108 |
+
sa.Column("ndcg_at_10", sa.Numeric(precision=5, scale=4), nullable=True),
|
| 109 |
+
sa.Column("reciprocal_rank", sa.Numeric(precision=5, scale=4), nullable=True),
|
| 110 |
+
sa.Column(
|
| 111 |
+
"computed_at",
|
| 112 |
+
sa.DateTime(),
|
| 113 |
+
server_default=sa.func.now(),
|
| 114 |
+
nullable=True,
|
| 115 |
+
),
|
| 116 |
+
sa.ForeignKeyConstraint(["query_id"], ["queries.id"]),
|
| 117 |
+
sa.PrimaryKeyConstraint("model", "query_id"),
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def downgrade() -> None:
|
| 122 |
+
op.drop_table("eval_results")
|
| 123 |
+
op.drop_table("ground_truth_consensus")
|
| 124 |
+
op.drop_table("ground_truth")
|
| 125 |
+
op.drop_table("queries")
|
| 126 |
+
op.drop_index("ix_listings_harga", table_name="listings")
|
| 127 |
+
op.drop_index("ix_listings_kecamatan", table_name="listings")
|
| 128 |
+
op.drop_index("ix_listings_tipe", table_name="listings")
|
| 129 |
+
op.drop_table("listings")
|
backend/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""TKI-KOS application package."""
|
backend/app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""HTTP API routes."""
|
backend/app/api/eval.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation dashboard endpoints (file-based).
|
| 2 |
+
|
| 3 |
+
Sumber data: CSV hasil eval yang di-COMMIT di repo (eval/results.csv,
|
| 4 |
+
results_pool_restricted.csv, results_constraints.csv, significance_map.csv).
|
| 5 |
+
Sebelumnya endpoint ini membaca tabel `eval_results` yang TIDAK PERNAH diisi
|
| 6 |
+
oleh siapa pun (runner hanya menulis CSV) sehingga selalu kosong di production.
|
| 7 |
+
File-based = nol langkah deploy ekstra dan hasil identik dengan laporan.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import csv
|
| 13 |
+
from collections import defaultdict
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from fastapi import APIRouter, HTTPException
|
| 17 |
+
from pydantic import BaseModel
|
| 18 |
+
|
| 19 |
+
router = APIRouter(prefix="/eval", tags=["evaluation"])
|
| 20 |
+
|
| 21 |
+
# repo root: backend/app/api/eval.py -> parents[3]
|
| 22 |
+
_EVAL_DIR = Path(__file__).resolve().parents[3] / "eval"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ModelMetrics(BaseModel):
|
| 26 |
+
model: str
|
| 27 |
+
p_at_5: float
|
| 28 |
+
p_at_10: float
|
| 29 |
+
map: float
|
| 30 |
+
ndcg_at_10: float
|
| 31 |
+
mrr: float
|
| 32 |
+
n_queries: int
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ConstraintSummary(BaseModel):
|
| 36 |
+
n_queries: int
|
| 37 |
+
mean_cs_at_5: dict[str, float]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class SignificanceRow(BaseModel):
|
| 41 |
+
pair: str
|
| 42 |
+
p_value: float
|
| 43 |
+
p_holm: float
|
| 44 |
+
significant_raw: bool
|
| 45 |
+
significant_holm: bool
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class EvalSummary(BaseModel):
|
| 49 |
+
standard: list[ModelMetrics]
|
| 50 |
+
pool_restricted: list[ModelMetrics]
|
| 51 |
+
constraints: ConstraintSummary | None
|
| 52 |
+
significance: list[SignificanceRow]
|
| 53 |
+
total_queries: int
|
| 54 |
+
note: str
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class QueryResult(BaseModel):
|
| 58 |
+
query_id: str
|
| 59 |
+
query_text: str
|
| 60 |
+
model: str
|
| 61 |
+
p_at_5: float
|
| 62 |
+
p_at_10: float
|
| 63 |
+
average_precision: float
|
| 64 |
+
ndcg_at_10: float
|
| 65 |
+
reciprocal_rank: float
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _aggregate_results_csv(path: Path) -> tuple[list[ModelMetrics], int]:
|
| 69 |
+
"""results.csv -> aggregate per model + jumlah query."""
|
| 70 |
+
if not path.exists():
|
| 71 |
+
return [], 0
|
| 72 |
+
per_model: dict[str, list[dict]] = defaultdict(list)
|
| 73 |
+
with open(path, encoding="utf-8") as f:
|
| 74 |
+
for row in csv.DictReader(f):
|
| 75 |
+
per_model[row["model"]].append(row)
|
| 76 |
+
out: list[ModelMetrics] = []
|
| 77 |
+
n_queries = 0
|
| 78 |
+
for model, rows in sorted(per_model.items()):
|
| 79 |
+
n = len(rows)
|
| 80 |
+
n_queries = max(n_queries, n)
|
| 81 |
+
out.append(ModelMetrics(
|
| 82 |
+
model=model,
|
| 83 |
+
p_at_5=sum(float(r["p_at_5"]) for r in rows) / n,
|
| 84 |
+
p_at_10=sum(float(r["p_at_10"]) for r in rows) / n,
|
| 85 |
+
map=sum(float(r["ap"]) for r in rows) / n,
|
| 86 |
+
ndcg_at_10=sum(float(r["ndcg_at_10"]) for r in rows) / n,
|
| 87 |
+
mrr=sum(float(r["rr"]) for r in rows) / n,
|
| 88 |
+
n_queries=n,
|
| 89 |
+
))
|
| 90 |
+
out.sort(key=lambda m: -m.map)
|
| 91 |
+
return out, n_queries
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _load_constraints(path: Path) -> ConstraintSummary | None:
|
| 95 |
+
if not path.exists():
|
| 96 |
+
return None
|
| 97 |
+
with open(path, encoding="utf-8") as f:
|
| 98 |
+
rows = list(csv.DictReader(f))
|
| 99 |
+
if not rows:
|
| 100 |
+
return None
|
| 101 |
+
return ConstraintSummary(
|
| 102 |
+
n_queries=len(rows),
|
| 103 |
+
mean_cs_at_5={
|
| 104 |
+
"smart": sum(float(r["cs_at_5_smart"]) for r in rows) / len(rows),
|
| 105 |
+
"bm25": sum(float(r["cs_at_5_bm25"]) for r in rows) / len(rows),
|
| 106 |
+
},
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _load_significance(path: Path) -> list[SignificanceRow]:
|
| 111 |
+
if not path.exists():
|
| 112 |
+
return []
|
| 113 |
+
with open(path, encoding="utf-8") as f:
|
| 114 |
+
return [
|
| 115 |
+
SignificanceRow(
|
| 116 |
+
pair=r["pair"],
|
| 117 |
+
p_value=float(r["p_value"]),
|
| 118 |
+
p_holm=float(r["p_holm"]),
|
| 119 |
+
significant_raw=r["significant_raw"] == "yes",
|
| 120 |
+
significant_holm=r["significant_holm"] == "yes",
|
| 121 |
+
)
|
| 122 |
+
for r in csv.DictReader(f)
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@router.get("/summary", response_model=EvalSummary)
|
| 127 |
+
async def eval_summary() -> EvalSummary:
|
| 128 |
+
"""Aggregate metrics per IR model: standard, pool-restricted, CS@5, Wilcoxon+Holm."""
|
| 129 |
+
standard, n_queries = _aggregate_results_csv(_EVAL_DIR / "results.csv")
|
| 130 |
+
pool, _ = _aggregate_results_csv(_EVAL_DIR / "results_pool_restricted.csv")
|
| 131 |
+
return EvalSummary(
|
| 132 |
+
standard=standard,
|
| 133 |
+
pool_restricted=pool,
|
| 134 |
+
constraints=_load_constraints(_EVAL_DIR / "results_constraints.csv"),
|
| 135 |
+
significance=_load_significance(_EVAL_DIR / "significance_map.csv"),
|
| 136 |
+
total_queries=n_queries,
|
| 137 |
+
note=(
|
| 138 |
+
"Standard top-K memakai qrels yang di-pool dari kandidat lexical "
|
| 139 |
+
"(pooling bias menekan model semantic & smart geo-augment); "
|
| 140 |
+
"pool-restricted = ranking di dalam pool annotated (fair); "
|
| 141 |
+
"CS@5 = % top-5 yang memenuhi semua constraint user (bebas qrels). "
|
| 142 |
+
"Signifikansi: Wilcoxon signed-rank + koreksi Holm-Bonferroni."
|
| 143 |
+
),
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@router.get("/query/{query_id}", response_model=list[QueryResult])
|
| 148 |
+
async def eval_per_query(query_id: str) -> list[QueryResult]:
|
| 149 |
+
"""Per-model metric untuk satu query (drill-down dari results.csv)."""
|
| 150 |
+
path = _EVAL_DIR / "results.csv"
|
| 151 |
+
if not path.exists():
|
| 152 |
+
raise HTTPException(404, "results.csv belum ada — jalankan evaluasi dulu")
|
| 153 |
+
out: list[QueryResult] = []
|
| 154 |
+
with open(path, encoding="utf-8") as f:
|
| 155 |
+
for r in csv.DictReader(f):
|
| 156 |
+
if r["query_id"] == query_id:
|
| 157 |
+
out.append(QueryResult(
|
| 158 |
+
query_id=r["query_id"],
|
| 159 |
+
query_text=r["query"],
|
| 160 |
+
model=r["model"],
|
| 161 |
+
p_at_5=float(r["p_at_5"]),
|
| 162 |
+
p_at_10=float(r["p_at_10"]),
|
| 163 |
+
average_precision=float(r["ap"]),
|
| 164 |
+
ndcg_at_10=float(r["ndcg_at_10"]),
|
| 165 |
+
reciprocal_rank=float(r["rr"]),
|
| 166 |
+
))
|
| 167 |
+
if not out:
|
| 168 |
+
raise HTTPException(404, f"Query '{query_id}' tidak ada di results.csv")
|
| 169 |
+
out.sort(key=lambda x: x.model)
|
| 170 |
+
return out
|
backend/app/api/health.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health check + status diagnostics endpoints.
|
| 2 |
+
|
| 3 |
+
- /health: simple liveness probe untuk Render
|
| 4 |
+
- /api/info: basic version info
|
| 5 |
+
- /api/status: comprehensive diagnostics (indexes, DB, model warm state)
|
| 6 |
+
Penting untuk debug user-reported issues karena user copy-paste output ini.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import platform
|
| 12 |
+
import sys
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
|
| 15 |
+
from fastapi import APIRouter, Depends, Request
|
| 16 |
+
from sqlalchemy import func, select
|
| 17 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 18 |
+
|
| 19 |
+
from app.core.db import get_session
|
| 20 |
+
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/health")
|
| 25 |
+
async def health():
|
| 26 |
+
"""Liveness probe untuk Render.com."""
|
| 27 |
+
return {"status": "ok", "service": "kozynear"}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.get("/api/info")
|
| 31 |
+
async def api_info():
|
| 32 |
+
"""Basic API info."""
|
| 33 |
+
return {
|
| 34 |
+
"name": "KozyNear API",
|
| 35 |
+
"version": "0.2.0",
|
| 36 |
+
"scope": "Bandar Lampung full coverage (20 kecamatan, 9 universitas)",
|
| 37 |
+
"docs": "/api/docs",
|
| 38 |
+
"health": "/health",
|
| 39 |
+
"status": "/api/status",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.get("/api/status")
|
| 44 |
+
async def status(
|
| 45 |
+
request: Request,
|
| 46 |
+
session: AsyncSession = Depends(get_session),
|
| 47 |
+
):
|
| 48 |
+
"""Comprehensive diagnostics — paste output ini saat report bug.
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
- Service: name, version, environment, uptime
|
| 52 |
+
- Indexes loaded (tfidf, bm25, indobert, hybrid)
|
| 53 |
+
- Preprocessing pipeline loaded
|
| 54 |
+
- Database: listings count, connection ok
|
| 55 |
+
- Runtime: Python version, platform
|
| 56 |
+
"""
|
| 57 |
+
from app.core.config import settings
|
| 58 |
+
|
| 59 |
+
# Index availability checks
|
| 60 |
+
state = request.app.state
|
| 61 |
+
indexes_status = {
|
| 62 |
+
"tfidf": state.tfidf is not None,
|
| 63 |
+
"bm25": state.bm25 is not None,
|
| 64 |
+
"indobert": state.indobert is not None,
|
| 65 |
+
"hybrid": state.hybrid is not None,
|
| 66 |
+
}
|
| 67 |
+
indobert_ready = getattr(state, "indobert_ready", False)
|
| 68 |
+
|
| 69 |
+
# Index sizes (kalau loaded)
|
| 70 |
+
indexes_size = {}
|
| 71 |
+
for name in ("tfidf", "bm25", "indobert"):
|
| 72 |
+
idx = getattr(state, name, None)
|
| 73 |
+
if idx is not None and hasattr(idx, "size"):
|
| 74 |
+
try:
|
| 75 |
+
indexes_size[name] = idx.size()
|
| 76 |
+
except Exception:
|
| 77 |
+
indexes_size[name] = None
|
| 78 |
+
|
| 79 |
+
# DB row counts
|
| 80 |
+
db_ok = True
|
| 81 |
+
db_error = None
|
| 82 |
+
listings_count = None
|
| 83 |
+
try:
|
| 84 |
+
from app.models.listing import Listing
|
| 85 |
+
|
| 86 |
+
result = await session.execute(select(func.count(Listing.id)))
|
| 87 |
+
listings_count = int(result.scalar() or 0)
|
| 88 |
+
except Exception as e:
|
| 89 |
+
db_ok = False
|
| 90 |
+
db_error = str(e)
|
| 91 |
+
|
| 92 |
+
# Memory usage (kalau psutil ada)
|
| 93 |
+
memory_info = None
|
| 94 |
+
try:
|
| 95 |
+
import psutil
|
| 96 |
+
|
| 97 |
+
process = psutil.Process()
|
| 98 |
+
mem = process.memory_info()
|
| 99 |
+
memory_info = {
|
| 100 |
+
"rss_mb": round(mem.rss / 1024 / 1024, 1),
|
| 101 |
+
"vms_mb": round(mem.vms / 1024 / 1024, 1),
|
| 102 |
+
}
|
| 103 |
+
except ImportError:
|
| 104 |
+
pass
|
| 105 |
+
|
| 106 |
+
return {
|
| 107 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 108 |
+
"service": {
|
| 109 |
+
"name": "kozynear",
|
| 110 |
+
"version": "0.2.0",
|
| 111 |
+
"environment": settings.environment,
|
| 112 |
+
},
|
| 113 |
+
"preprocessing": {
|
| 114 |
+
"loaded": state.preprocessing_pipeline is not None,
|
| 115 |
+
},
|
| 116 |
+
"indexes": {
|
| 117 |
+
"loaded": indexes_status,
|
| 118 |
+
"sizes": indexes_size,
|
| 119 |
+
"indexes_dir": settings.indexes_dir,
|
| 120 |
+
"indobert_model_ready": indobert_ready,
|
| 121 |
+
},
|
| 122 |
+
"database": {
|
| 123 |
+
"connected": db_ok,
|
| 124 |
+
"listings_count": listings_count,
|
| 125 |
+
"error": db_error,
|
| 126 |
+
},
|
| 127 |
+
"runtime": {
|
| 128 |
+
"python": sys.version.split()[0],
|
| 129 |
+
"platform": platform.platform(),
|
| 130 |
+
},
|
| 131 |
+
"memory": memory_info,
|
| 132 |
+
"settings": {
|
| 133 |
+
"embedding_model": settings.embedding_model,
|
| 134 |
+
"default_top_k": settings.default_top_k,
|
| 135 |
+
"bm25_k1": settings.bm25_k1,
|
| 136 |
+
"bm25_b": settings.bm25_b,
|
| 137 |
+
},
|
| 138 |
+
}
|
backend/app/api/insights.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Endpoint demo/insight: visualisasi preprocessing + statistik corpus.
|
| 2 |
+
|
| 3 |
+
Diporting dari konsep prototype Flask `proyek stki` (tab Preprocessing +
|
| 4 |
+
/api/stats) — fitur paling berguna untuk demo rubric Preprocessing & Sistem.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
| 13 |
+
from sqlalchemy import func, select
|
| 14 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 15 |
+
|
| 16 |
+
from app.core.db import get_session
|
| 17 |
+
from app.models.listing import Listing
|
| 18 |
+
|
| 19 |
+
router = APIRouter()
|
| 20 |
+
|
| 21 |
+
# Fallback statistik kalau DB down: corpus.json ikut ter-copy di image.
|
| 22 |
+
_CORPUS_JSON = Path(__file__).resolve().parents[3] / "data" / "processed" / "corpus.json"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("/preprocess", tags=["insights"])
|
| 26 |
+
async def preprocess_trace(
|
| 27 |
+
request: Request,
|
| 28 |
+
text: str = Query(..., min_length=1, max_length=1000,
|
| 29 |
+
description="Teks untuk dilewatkan pipeline 9-stage"),
|
| 30 |
+
):
|
| 31 |
+
"""Jalankan pipeline preprocessing dengan trace per-stage (untuk UI demo)."""
|
| 32 |
+
pipeline = getattr(request.app.state, "preprocessing_pipeline", None)
|
| 33 |
+
if pipeline is None:
|
| 34 |
+
raise HTTPException(503, detail={"error": "preprocessing pipeline belum loaded"})
|
| 35 |
+
result = pipeline.process(text, trace=True)
|
| 36 |
+
return {
|
| 37 |
+
"raw": result.raw,
|
| 38 |
+
"processed": result.processed,
|
| 39 |
+
"tokens": result.tokens,
|
| 40 |
+
"extracted_prices": result.extracted_prices,
|
| 41 |
+
"stages": result.trace,
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _corpus_fallback_stats() -> dict:
|
| 46 |
+
"""Statistik dari corpus.json saat DB tidak tersedia (mis. test lokal)."""
|
| 47 |
+
docs = json.loads(_CORPUS_JSON.read_text(encoding="utf-8"))
|
| 48 |
+
kecamatan: dict[str, int] = {}
|
| 49 |
+
tipe: dict[str, int] = {}
|
| 50 |
+
hargas: list[int] = []
|
| 51 |
+
for d in docs:
|
| 52 |
+
meta = d.get("metadata", d)
|
| 53 |
+
kec = meta.get("kecamatan") or "?"
|
| 54 |
+
kecamatan[kec] = kecamatan.get(kec, 0) + 1
|
| 55 |
+
t = meta.get("tipe") or "?"
|
| 56 |
+
tipe[t] = tipe.get(t, 0) + 1
|
| 57 |
+
h = meta.get("harga_per_bulan")
|
| 58 |
+
if isinstance(h, int):
|
| 59 |
+
hargas.append(h)
|
| 60 |
+
return {
|
| 61 |
+
"total_listings": len(docs),
|
| 62 |
+
"kecamatan": dict(sorted(kecamatan.items(), key=lambda kv: -kv[1])),
|
| 63 |
+
"tipe": tipe,
|
| 64 |
+
"harga_min": min(hargas) if hargas else None,
|
| 65 |
+
"harga_max": max(hargas) if hargas else None,
|
| 66 |
+
"harga_avg": int(sum(hargas) / len(hargas)) if hargas else None,
|
| 67 |
+
"source": "corpus.json (DB tidak tersedia)",
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@router.get("/stats", tags=["insights"])
|
| 72 |
+
async def corpus_stats(
|
| 73 |
+
request: Request,
|
| 74 |
+
session: AsyncSession = Depends(get_session),
|
| 75 |
+
):
|
| 76 |
+
"""Statistik corpus live: jumlah listing, distribusi kecamatan/tipe, harga,
|
| 77 |
+
ukuran vocabulary index. Untuk tab Statistik + bahan slide."""
|
| 78 |
+
stats: dict
|
| 79 |
+
try:
|
| 80 |
+
total = (await session.execute(select(func.count(Listing.id)))).scalar() or 0
|
| 81 |
+
kec_rows = (await session.execute(
|
| 82 |
+
select(Listing.kecamatan, func.count())
|
| 83 |
+
.group_by(Listing.kecamatan)
|
| 84 |
+
.order_by(func.count().desc())
|
| 85 |
+
)).all()
|
| 86 |
+
tipe_rows = (await session.execute(
|
| 87 |
+
select(Listing.tipe, func.count()).group_by(Listing.tipe)
|
| 88 |
+
)).all()
|
| 89 |
+
harga_row = (await session.execute(
|
| 90 |
+
select(
|
| 91 |
+
func.min(Listing.harga_per_bulan),
|
| 92 |
+
func.max(Listing.harga_per_bulan),
|
| 93 |
+
func.avg(Listing.harga_per_bulan),
|
| 94 |
+
)
|
| 95 |
+
)).one()
|
| 96 |
+
stats = {
|
| 97 |
+
"total_listings": int(total),
|
| 98 |
+
"kecamatan": {k or "?": int(c) for k, c in kec_rows},
|
| 99 |
+
"tipe": {t or "?": int(c) for t, c in tipe_rows},
|
| 100 |
+
"harga_min": int(harga_row[0]) if harga_row[0] is not None else None,
|
| 101 |
+
"harga_max": int(harga_row[1]) if harga_row[1] is not None else None,
|
| 102 |
+
"harga_avg": int(harga_row[2]) if harga_row[2] is not None else None,
|
| 103 |
+
"source": "database",
|
| 104 |
+
}
|
| 105 |
+
except Exception:
|
| 106 |
+
# DB down / belum seed -> fallback corpus committed (read-only stats)
|
| 107 |
+
try:
|
| 108 |
+
stats = _corpus_fallback_stats()
|
| 109 |
+
except Exception as e: # corpus juga tidak ada
|
| 110 |
+
raise HTTPException(503, detail={"error": f"stats tidak tersedia: {e}"})
|
| 111 |
+
|
| 112 |
+
# Vocabulary size dari index yang loaded (BM25 idf vocab)
|
| 113 |
+
bm25 = getattr(request.app.state, "bm25", None)
|
| 114 |
+
vocab_size = None
|
| 115 |
+
if bm25 is not None and getattr(bm25, "bm25", None) is not None:
|
| 116 |
+
try:
|
| 117 |
+
vocab_size = len(bm25.bm25.idf)
|
| 118 |
+
except Exception:
|
| 119 |
+
vocab_size = None
|
| 120 |
+
stats["vocab_size"] = vocab_size
|
| 121 |
+
stats["models_loaded"] = [
|
| 122 |
+
m for m in ("tfidf", "bm25", "indobert", "hybrid")
|
| 123 |
+
if getattr(request.app.state, m, None) is not None
|
| 124 |
+
] + (["smart"] if getattr(request.app.state, "bm25", None) is not None
|
| 125 |
+
and getattr(request.app.state, "gazetteer", None) is not None else [])
|
| 126 |
+
return stats
|
backend/app/api/listings.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Listing detail endpoint: GET /listings/{id}."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
from sqlalchemy import select
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
|
| 9 |
+
from app.core.db import get_session
|
| 10 |
+
from app.models.listing import Listing, ListingDetail
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get(
|
| 17 |
+
"/listings/{listing_id}",
|
| 18 |
+
response_model=ListingDetail,
|
| 19 |
+
tags=["listings"],
|
| 20 |
+
)
|
| 21 |
+
async def get_listing(
|
| 22 |
+
listing_id: str,
|
| 23 |
+
session: AsyncSession = Depends(get_session),
|
| 24 |
+
) -> ListingDetail:
|
| 25 |
+
"""Get detail listing by ID. Return 404 kalau gak ditemukan."""
|
| 26 |
+
result = await session.execute(
|
| 27 |
+
select(Listing).where(Listing.id == listing_id)
|
| 28 |
+
)
|
| 29 |
+
listing = result.scalar_one_or_none()
|
| 30 |
+
if not listing:
|
| 31 |
+
raise HTTPException(
|
| 32 |
+
status_code=404,
|
| 33 |
+
detail=f"Listing '{listing_id}' not found",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
coordinates = None
|
| 37 |
+
if listing.koordinat_lat is not None and listing.koordinat_lng is not None:
|
| 38 |
+
coordinates = [
|
| 39 |
+
float(listing.koordinat_lat),
|
| 40 |
+
float(listing.koordinat_lng),
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
return ListingDetail(
|
| 44 |
+
id=listing.id,
|
| 45 |
+
judul=listing.judul,
|
| 46 |
+
deskripsi=listing.deskripsi,
|
| 47 |
+
deskripsi_processed=listing.deskripsi_processed,
|
| 48 |
+
harga_per_bulan=listing.harga_per_bulan,
|
| 49 |
+
tipe=listing.tipe,
|
| 50 |
+
fasilitas=listing.fasilitas,
|
| 51 |
+
alamat=listing.alamat,
|
| 52 |
+
kecamatan=listing.kecamatan,
|
| 53 |
+
koordinat=coordinates,
|
| 54 |
+
jarak_kampus_km=(
|
| 55 |
+
float(listing.jarak_kampus_km)
|
| 56 |
+
if listing.jarak_kampus_km is not None
|
| 57 |
+
else None
|
| 58 |
+
),
|
| 59 |
+
url_source=listing.url_source,
|
| 60 |
+
scrape_date=listing.scrape_date,
|
| 61 |
+
source=listing.source,
|
| 62 |
+
)
|
backend/app/api/search.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Search endpoint: GET /search?q=X&model=Y&top_k=N + optional filters.
|
| 2 |
+
|
| 3 |
+
Pipeline:
|
| 4 |
+
1. Pilih IR index dari app.state berdasar param `model`
|
| 5 |
+
2. Preprocess query (same as corpus preprocessing saat indexing)
|
| 6 |
+
3. IR index query top-K * overshoot (3x) — supaya filter setelahnya
|
| 7 |
+
gak return < top_k
|
| 8 |
+
4. Hydrate dari DB dengan filter (harga, tipe, kecamatan)
|
| 9 |
+
5. Take top-K filtered, preserve ranking, return SearchResponse
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import time
|
| 15 |
+
from typing import Literal, Optional
|
| 16 |
+
|
| 17 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
| 18 |
+
from loguru import logger
|
| 19 |
+
from sqlalchemy import select
|
| 20 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 21 |
+
|
| 22 |
+
from app.core.db import get_session
|
| 23 |
+
from app.models.listing import Listing, ListingRead
|
| 24 |
+
from app.models.search import SearchResponse
|
| 25 |
+
from app.search.pipeline import SmartFilters, smart_search
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
router = APIRouter()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# Overshoot factor: index query ambil top_k * X supaya filter gak terlalu
|
| 32 |
+
# memotong hasil. 3 cukup untuk filter ringan; bisa naik kalau filter ketat.
|
| 33 |
+
OVERSHOOT_FACTOR = 3
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("/search", response_model=SearchResponse, tags=["search"])
|
| 37 |
+
async def search(
|
| 38 |
+
request: Request,
|
| 39 |
+
q: str = Query(..., min_length=1, max_length=500, description="Query string"),
|
| 40 |
+
model: Literal["tfidf", "bm25", "indobert", "hybrid", "smart"] = Query(
|
| 41 |
+
"smart", description="IR model pilihan (default: smart = query understanding + geo + BM25)"
|
| 42 |
+
),
|
| 43 |
+
top_k: int = Query(10, ge=1, le=50, description="Jumlah hasil"),
|
| 44 |
+
# ---- Optional filters (metadata-based) ----
|
| 45 |
+
harga_min: Optional[int] = Query(
|
| 46 |
+
None, ge=0, description="Min harga per bulan (IDR)"
|
| 47 |
+
),
|
| 48 |
+
harga_max: Optional[int] = Query(
|
| 49 |
+
None, ge=0, description="Max harga per bulan (IDR)"
|
| 50 |
+
),
|
| 51 |
+
tipe: Optional[Literal["putra", "putri", "campur"]] = Query(
|
| 52 |
+
None, description="Filter tipe kos"
|
| 53 |
+
),
|
| 54 |
+
kecamatan: Optional[str] = Query(
|
| 55 |
+
None, description="Filter kecamatan (substring match)"
|
| 56 |
+
),
|
| 57 |
+
session: AsyncSession = Depends(get_session),
|
| 58 |
+
) -> SearchResponse:
|
| 59 |
+
"""Cari kos dengan IR model + optional metadata filters.
|
| 60 |
+
|
| 61 |
+
Returns 503 kalau index belum di-load di backend.
|
| 62 |
+
"""
|
| 63 |
+
# 0. Smart pipeline (default): query understanding + geo + BM25 (no neural).
|
| 64 |
+
if model == "smart":
|
| 65 |
+
gz = getattr(request.app.state, "gazetteer", None)
|
| 66 |
+
bm25_index = getattr(request.app.state, "bm25", None)
|
| 67 |
+
if bm25_index is None or gz is None:
|
| 68 |
+
raise HTTPException(
|
| 69 |
+
status_code=503,
|
| 70 |
+
detail={"error": "smart pipeline belum siap (butuh bm25 + gazetteer)"},
|
| 71 |
+
)
|
| 72 |
+
# Residual query harus lewat preprocessing yang sama dengan corpus
|
| 73 |
+
# saat indexing (stem + jargon), kalau tidak token gak match index.
|
| 74 |
+
pipeline = getattr(request.app.state, "preprocessing_pipeline", None)
|
| 75 |
+
preprocess = (
|
| 76 |
+
(lambda s: pipeline.process(s).processed) if pipeline else None
|
| 77 |
+
)
|
| 78 |
+
# Filter UI eksplisit ikut ke smart sebagai hard constraint
|
| 79 |
+
# (sebelumnya diabaikan diam-diam di mode smart).
|
| 80 |
+
smart_filters = SmartFilters(
|
| 81 |
+
harga_min=harga_min, harga_max=harga_max,
|
| 82 |
+
tipe=tipe, kecamatan=kecamatan,
|
| 83 |
+
)
|
| 84 |
+
t0 = time.perf_counter()
|
| 85 |
+
results, understood, relaxed = await smart_search(
|
| 86 |
+
q, bm25_index, session, gz, top_k=top_k,
|
| 87 |
+
preprocess=preprocess, filters=smart_filters,
|
| 88 |
+
)
|
| 89 |
+
return SearchResponse(
|
| 90 |
+
query=q, model=model, top_k=top_k,
|
| 91 |
+
took_ms=int((time.perf_counter() - t0) * 1000),
|
| 92 |
+
results=results, understood=understood, relaxed=relaxed,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# 1. Get index dari app.state
|
| 96 |
+
index = getattr(request.app.state, model, None)
|
| 97 |
+
if index is None:
|
| 98 |
+
available = [
|
| 99 |
+
m for m in ("tfidf", "bm25", "indobert", "hybrid")
|
| 100 |
+
if getattr(request.app.state, m, None) is not None
|
| 101 |
+
]
|
| 102 |
+
raise HTTPException(
|
| 103 |
+
status_code=503,
|
| 104 |
+
detail={
|
| 105 |
+
"error": f"Index '{model}' belum di-load di backend",
|
| 106 |
+
"model_requested": model,
|
| 107 |
+
"available_models": available,
|
| 108 |
+
"preprocessing_loaded": getattr(
|
| 109 |
+
request.app.state, "preprocessing_pipeline", None
|
| 110 |
+
) is not None,
|
| 111 |
+
"hint": (
|
| 112 |
+
"Kemungkinan OOM saat prewarm IndoBERT di Render free tier "
|
| 113 |
+
"(512MB RAM). Check Render logs untuk detail. Untuk "
|
| 114 |
+
"diagnostics lengkap GET /api/status."
|
| 115 |
+
),
|
| 116 |
+
},
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
# 1b. Check IndoBERT/Hybrid model warm state (background prewarm)
|
| 120 |
+
if model in ("indobert", "hybrid"):
|
| 121 |
+
if not getattr(request.app.state, "indobert_ready", False):
|
| 122 |
+
failed = getattr(request.app.state, "indobert_failed", False)
|
| 123 |
+
if failed:
|
| 124 |
+
raise HTTPException(
|
| 125 |
+
status_code=503,
|
| 126 |
+
detail={
|
| 127 |
+
"error": "IndoBERT model gagal load (prewarm permanent failure)",
|
| 128 |
+
"model_requested": model,
|
| 129 |
+
"ready_models": [
|
| 130 |
+
m for m in ("tfidf", "bm25")
|
| 131 |
+
if getattr(request.app.state, m, None) is not None
|
| 132 |
+
],
|
| 133 |
+
"hint": (
|
| 134 |
+
"IndoBERT prewarm gagal permanen (kemungkinan OOM di "
|
| 135 |
+
"Render free tier 512MB). Restart deployment atau gunakan "
|
| 136 |
+
"TF-IDF/BM25. Retry TIDAK akan membantu tanpa restart."
|
| 137 |
+
),
|
| 138 |
+
},
|
| 139 |
+
)
|
| 140 |
+
raise HTTPException(
|
| 141 |
+
status_code=503,
|
| 142 |
+
detail={
|
| 143 |
+
"error": "IndoBERT model masih loading (background prewarm)",
|
| 144 |
+
"model_requested": model,
|
| 145 |
+
"ready_models": [
|
| 146 |
+
m for m in ("tfidf", "bm25")
|
| 147 |
+
if getattr(request.app.state, m, None) is not None
|
| 148 |
+
],
|
| 149 |
+
"hint": (
|
| 150 |
+
"Background prewarm jalan di startup, butuh ~10-30 detik "
|
| 151 |
+
"di Render free tier. Coba TF-IDF/BM25 dulu yang sudah "
|
| 152 |
+
"ready, atau retry IndoBERT/Hybrid setelah ~30 detik."
|
| 153 |
+
),
|
| 154 |
+
"retry_after_sec": 30,
|
| 155 |
+
},
|
| 156 |
+
headers={"Retry-After": "30"},
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
t0 = time.perf_counter()
|
| 160 |
+
|
| 161 |
+
# 2. Preprocess query (untuk BM25/TF-IDF). IndoBERT & Hybrid pakai raw
|
| 162 |
+
# query karena sentence-transformers ekspektasi natural language.
|
| 163 |
+
pipeline = getattr(request.app.state, "preprocessing_pipeline", None)
|
| 164 |
+
processed_q = pipeline.process(q).processed if pipeline else q
|
| 165 |
+
search_q = q if model in ("indobert", "hybrid") else processed_q
|
| 166 |
+
logger.debug(
|
| 167 |
+
f"[search] q='{q}' processed='{processed_q}' model={model} "
|
| 168 |
+
f"using={'raw' if model in ('indobert', 'hybrid') else 'processed'}"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# 3. IR query — overshoot kalau ada filter (mitigate filter cutting hits)
|
| 172 |
+
has_filter = any([harga_min is not None, harga_max is not None, tipe is not None, kecamatan is not None])
|
| 173 |
+
fetch_k = top_k * OVERSHOOT_FACTOR if has_filter else top_k
|
| 174 |
+
hits = index.query(search_q, top_k=fetch_k)
|
| 175 |
+
if not hits:
|
| 176 |
+
return SearchResponse(
|
| 177 |
+
query=q, model=model, top_k=top_k, took_ms=0, results=[]
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
doc_ids = [h.doc_id for h in hits]
|
| 181 |
+
score_map = {h.doc_id: h.score for h in hits}
|
| 182 |
+
|
| 183 |
+
# 4. Hydrate from DB dengan filters
|
| 184 |
+
stmt = select(Listing).where(Listing.id.in_(doc_ids))
|
| 185 |
+
if harga_min is not None:
|
| 186 |
+
stmt = stmt.where(Listing.harga_per_bulan >= harga_min)
|
| 187 |
+
if harga_max is not None:
|
| 188 |
+
stmt = stmt.where(Listing.harga_per_bulan <= harga_max)
|
| 189 |
+
if tipe:
|
| 190 |
+
stmt = stmt.where(Listing.tipe == tipe)
|
| 191 |
+
if kecamatan:
|
| 192 |
+
stmt = stmt.where(Listing.kecamatan.ilike(f"%{kecamatan}%"))
|
| 193 |
+
|
| 194 |
+
db_result = await session.execute(stmt)
|
| 195 |
+
listings_map = {row.id: row for row in db_result.scalars().all()}
|
| 196 |
+
|
| 197 |
+
# 5. Preserve IR ranking, take top_k filtered
|
| 198 |
+
results: list[ListingRead] = []
|
| 199 |
+
for doc_id in doc_ids:
|
| 200 |
+
if len(results) >= top_k:
|
| 201 |
+
break
|
| 202 |
+
listing = listings_map.get(doc_id)
|
| 203 |
+
if not listing:
|
| 204 |
+
continue # filtered out atau gak ada di DB
|
| 205 |
+
results.append(
|
| 206 |
+
ListingRead(
|
| 207 |
+
id=listing.id,
|
| 208 |
+
judul=listing.judul,
|
| 209 |
+
deskripsi=listing.deskripsi,
|
| 210 |
+
harga_per_bulan=listing.harga_per_bulan,
|
| 211 |
+
tipe=listing.tipe,
|
| 212 |
+
fasilitas=listing.fasilitas,
|
| 213 |
+
alamat=listing.alamat,
|
| 214 |
+
kecamatan=listing.kecamatan,
|
| 215 |
+
score=score_map[doc_id],
|
| 216 |
+
koordinat=(
|
| 217 |
+
[float(listing.koordinat_lat), float(listing.koordinat_lng)]
|
| 218 |
+
if listing.koordinat_lat is not None
|
| 219 |
+
and listing.koordinat_lng is not None
|
| 220 |
+
else None
|
| 221 |
+
),
|
| 222 |
+
)
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
took_ms = int((time.perf_counter() - t0) * 1000)
|
| 226 |
+
logger.info(
|
| 227 |
+
f"[search] q='{q}' model={model} filter={has_filter} "
|
| 228 |
+
f"hits={len(results)}/{top_k} took_ms={took_ms}"
|
| 229 |
+
)
|
| 230 |
+
return SearchResponse(
|
| 231 |
+
query=q, model=model, top_k=top_k, took_ms=took_ms, results=results
|
| 232 |
+
)
|
backend/app/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Core utilities: config, DB connection, logging."""
|
backend/app/core/config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application configuration via Pydantic Settings (env-driven).
|
| 2 |
+
|
| 3 |
+
Analogi Laravel: ini setara dengan `config/app.php` + `config/database.php`
|
| 4 |
+
yang baca dari `.env`. Pydantic Settings otomatis cast type (mis. CORS_ORIGINS
|
| 5 |
+
JSON array → Python list).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from pydantic import field_validator
|
| 9 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Settings(BaseSettings):
|
| 13 |
+
model_config = SettingsConfigDict(
|
| 14 |
+
env_file=".env",
|
| 15 |
+
env_file_encoding="utf-8",
|
| 16 |
+
extra="ignore",
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# General
|
| 20 |
+
environment: str = "development"
|
| 21 |
+
log_level: str = "INFO"
|
| 22 |
+
|
| 23 |
+
# Database
|
| 24 |
+
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/tki_kos"
|
| 25 |
+
|
| 26 |
+
# CORS — list domain frontend yang boleh akses
|
| 27 |
+
cors_origins: list[str] = [
|
| 28 |
+
"http://localhost:5173",
|
| 29 |
+
"http://localhost:3000",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
# IR config
|
| 33 |
+
indexes_dir: str = "../data/indexes"
|
| 34 |
+
embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
| 35 |
+
default_top_k: int = 10
|
| 36 |
+
bm25_k1: float = 1.5
|
| 37 |
+
bm25_b: float = 0.75
|
| 38 |
+
|
| 39 |
+
# Neural (IndoBERT) di-OFF di production (hemat RAM Render free 512MB).
|
| 40 |
+
# Hidup di notebook/Colab untuk eval. Set ENABLE_NEURAL=true untuk aktif.
|
| 41 |
+
enable_neural: bool = False
|
| 42 |
+
|
| 43 |
+
@field_validator("database_url", mode="after")
|
| 44 |
+
@classmethod
|
| 45 |
+
def normalize_database_url(cls, v: str) -> str:
|
| 46 |
+
"""Normalize URL Postgres ke `postgresql+asyncpg://` (SQLAlchemy 2.0 async).
|
| 47 |
+
|
| 48 |
+
Render kasih env var DATABASE_URL dengan scheme `postgres://`, sementara
|
| 49 |
+
SQLAlchemy 2.0 minta `postgresql://` minimum, dan untuk async pakai
|
| 50 |
+
`postgresql+asyncpg://`. Validator ini handle 3 case:
|
| 51 |
+
|
| 52 |
+
- `postgres://...` -> `postgresql+asyncpg://...` (Render default)
|
| 53 |
+
- `postgresql://...` -> `postgresql+asyncpg://...` (bare)
|
| 54 |
+
- `postgresql+asyncpg://...` -> unchanged (sudah benar)
|
| 55 |
+
- `postgresql+<other>://...` -> unchanged (explicit driver)
|
| 56 |
+
"""
|
| 57 |
+
if v.startswith("postgres://"):
|
| 58 |
+
return v.replace("postgres://", "postgresql+asyncpg://", 1)
|
| 59 |
+
# Match bare `postgresql://` (no driver suffix)
|
| 60 |
+
if v.startswith("postgresql://"):
|
| 61 |
+
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
|
| 62 |
+
return v
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
settings = Settings()
|
backend/app/core/db.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async SQLAlchemy engine + session factory.
|
| 2 |
+
|
| 3 |
+
Pattern: yield AsyncSession per-request via FastAPI dependency.
|
| 4 |
+
|
| 5 |
+
Analogi Laravel: ini setara dengan Database Manager + DB facade.
|
| 6 |
+
- Eloquent ORM = SQLAlchemy ORM
|
| 7 |
+
- DB::transaction = async with session.begin()
|
| 8 |
+
- DB::table('x') = select(X)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import AsyncGenerator
|
| 14 |
+
|
| 15 |
+
from sqlalchemy.ext.asyncio import (
|
| 16 |
+
AsyncSession,
|
| 17 |
+
async_sessionmaker,
|
| 18 |
+
create_async_engine,
|
| 19 |
+
)
|
| 20 |
+
from sqlalchemy.orm import DeclarativeBase
|
| 21 |
+
|
| 22 |
+
from app.core.config import settings
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Base(DeclarativeBase):
|
| 26 |
+
"""SQLAlchemy declarative base untuk semua ORM models.
|
| 27 |
+
|
| 28 |
+
Subclass ini di setiap model file:
|
| 29 |
+
from app.core.db import Base
|
| 30 |
+
class Listing(Base): ...
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
engine = create_async_engine(
|
| 35 |
+
settings.database_url,
|
| 36 |
+
echo=settings.environment == "development",
|
| 37 |
+
pool_pre_ping=True, # validate connection before use (avoid stale conns)
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async_session_factory = async_sessionmaker(
|
| 42 |
+
engine,
|
| 43 |
+
class_=AsyncSession,
|
| 44 |
+
expire_on_commit=False, # objects accessible after commit
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
| 49 |
+
"""FastAPI dependency: yield session per request, auto cleanup + rollback.
|
| 50 |
+
|
| 51 |
+
Usage di route:
|
| 52 |
+
@router.get("/x")
|
| 53 |
+
async def handler(session: AsyncSession = Depends(get_session)):
|
| 54 |
+
...
|
| 55 |
+
"""
|
| 56 |
+
async with async_session_factory() as session:
|
| 57 |
+
try:
|
| 58 |
+
yield session
|
| 59 |
+
except Exception:
|
| 60 |
+
await session.rollback()
|
| 61 |
+
raise
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def init_db() -> None:
|
| 65 |
+
"""Create tables dari metadata. Untuk DEV ONLY.
|
| 66 |
+
|
| 67 |
+
Production: pakai Alembic migrations (lihat backend/alembic/).
|
| 68 |
+
Tim TODO: setup Alembic Week 2:
|
| 69 |
+
cd backend
|
| 70 |
+
alembic init alembic
|
| 71 |
+
# edit alembic/env.py untuk import Base
|
| 72 |
+
alembic revision --autogenerate -m "initial schema"
|
| 73 |
+
alembic upgrade head
|
| 74 |
+
"""
|
| 75 |
+
async with engine.begin() as conn:
|
| 76 |
+
await conn.run_sync(Base.metadata.create_all)
|
backend/app/evaluation/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Evaluation Module
|
| 2 |
+
|
| 3 |
+
IR evaluation metrics, inter-annotator agreement (Cohen's Kappa), dan
|
| 4 |
+
statistical significance tests untuk rubric Evaluation 10%.
|
| 5 |
+
|
| 6 |
+
## Metrics
|
| 7 |
+
|
| 8 |
+
| Metric | Definisi | Range | Best |
|
| 9 |
+
|--------|----------|-------|------|
|
| 10 |
+
| P@K (K=5,10) | proportion of top-K predicted yang relevant | [0,1] | higher |
|
| 11 |
+
| MAP | Mean Average Precision across queries | [0,1] | higher |
|
| 12 |
+
| NDCG@10 | Normalized Discounted Cumulative Gain (graded relevance) | [0,1] | higher |
|
| 13 |
+
| MRR | Mean Reciprocal Rank (1/rank of first relevant) | [0,1] | higher |
|
| 14 |
+
|
| 15 |
+
## Inter-Annotator Agreement
|
| 16 |
+
|
| 17 |
+
- **`cohen_kappa`**: untuk 2 annotator, nominal labels
|
| 18 |
+
- **`weighted_kappa`**: untuk ordinal labels (0/1/2 relevance) — direkomendasikan
|
| 19 |
+
|
| 20 |
+
**Target rubric**: Kappa >= 0.7 across all annotator pairs. Kalau di bawah,
|
| 21 |
+
**dokumentasikan + lakuin consensus resolution** sebelum compute IR metrics.
|
| 22 |
+
|
| 23 |
+
Interpretation (Landis & Koch 1977):
|
| 24 |
+
- < 0.20: slight | 0.21-0.40: fair | 0.41-0.60: moderate
|
| 25 |
+
- 0.61-0.80: substantial | 0.81-1.00: almost perfect
|
| 26 |
+
|
| 27 |
+
## Statistical Significance
|
| 28 |
+
|
| 29 |
+
Untuk compare 2 model (e.g., BM25 vs IndoBERT) berdasar per-query metric:
|
| 30 |
+
|
| 31 |
+
- **`paired_ttest`**: kalau distribusi roughly normal (lebih powerful)
|
| 32 |
+
- **`wilcoxon_signed_rank`**: non-parametric, **direkomendasikan** untuk IR
|
| 33 |
+
(metric in [0,1] sering skewed)
|
| 34 |
+
|
| 35 |
+
H0: "Model A == Model B". alpha = 0.05. p < alpha => significant difference.
|
| 36 |
+
|
| 37 |
+
## Workflow Penuh
|
| 38 |
+
|
| 39 |
+
### 1. Annotation Phase (Week 3)
|
| 40 |
+
|
| 41 |
+
Persiapan:
|
| 42 |
+
1. Tim Anggota E koordinasi 3 annotator (anggota tim, bukan AI)
|
| 43 |
+
2. Pre-annotation calibration: discuss 3-5 sample queries bareng, agree on
|
| 44 |
+
what "relevant" / "somewhat relevant" / "not relevant" means
|
| 45 |
+
3. Distribute task: tiap annotator label SEMUA top-30 hits dari BM25 untuk
|
| 46 |
+
setiap query (300+ judgments per annotator, ~12 queries x 30 docs)
|
| 47 |
+
|
| 48 |
+
Output: `eval/annotations_annotator_{A,B,C}.csv`
|
| 49 |
+
```csv
|
| 50 |
+
query_id,doc_id,relevance
|
| 51 |
+
q01,d12345,2
|
| 52 |
+
q01,d12346,1
|
| 53 |
+
...
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### 2. Compute Kappa + Consensus
|
| 57 |
+
|
| 58 |
+
```python
|
| 59 |
+
import csv
|
| 60 |
+
from app.evaluation import cohen_kappa, weighted_kappa, interpret_kappa
|
| 61 |
+
|
| 62 |
+
# Load annotations
|
| 63 |
+
def load(path):
|
| 64 |
+
with open(path) as f:
|
| 65 |
+
return {(row["query_id"], row["doc_id"]): int(row["relevance"])
|
| 66 |
+
for row in csv.DictReader(f)}
|
| 67 |
+
|
| 68 |
+
ann_a = load("eval/annotations_annotator_A.csv")
|
| 69 |
+
ann_b = load("eval/annotations_annotator_B.csv")
|
| 70 |
+
ann_c = load("eval/annotations_annotator_C.csv")
|
| 71 |
+
|
| 72 |
+
# Align by shared (query, doc) keys
|
| 73 |
+
shared = set(ann_a.keys()) & set(ann_b.keys()) & set(ann_c.keys())
|
| 74 |
+
|
| 75 |
+
for pair_name, (pa, pb) in [("A-B", (ann_a, ann_b)), ("A-C", (ann_a, ann_c)),
|
| 76 |
+
("B-C", (ann_b, ann_c))]:
|
| 77 |
+
a_vals = [pa[k] for k in shared]
|
| 78 |
+
b_vals = [pb[k] for k in shared]
|
| 79 |
+
k = weighted_kappa(a_vals, b_vals)
|
| 80 |
+
print(f"{pair_name}: kappa={k:.3f} ({interpret_kappa(k)})")
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
Target: semua pair >= 0.7. Kalau ada yang <0.7:
|
| 84 |
+
1. Identify queries dengan disagreement tertinggi
|
| 85 |
+
2. Discussion session: 3 annotator bareng, agree pada labeling
|
| 86 |
+
3. Re-annotate problematic queries
|
| 87 |
+
4. Recompute kappa
|
| 88 |
+
5. Generate consensus: majority vote, atau (kalau 2-2 split) tertinggi/median
|
| 89 |
+
|
| 90 |
+
Output: `eval/ground_truth.csv` (consensus labels)
|
| 91 |
+
```csv
|
| 92 |
+
query_id,doc_id,relevance
|
| 93 |
+
q01,d12345,2
|
| 94 |
+
...
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
### 3. Run Evaluation (Week 4)
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
cd backend
|
| 101 |
+
python -m app.evaluation.runner \
|
| 102 |
+
--queries ../eval/queries.json \
|
| 103 |
+
--ground-truth ../eval/ground_truth.csv \
|
| 104 |
+
--indexes-dir ../data/indexes \
|
| 105 |
+
--output ../eval/results.csv
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
Output:
|
| 109 |
+
- `eval/results.csv`: per-model per-query metrics
|
| 110 |
+
- Console: aggregate MAP / P@5 / P@10 / NDCG@10 / MRR per model + pairwise
|
| 111 |
+
Wilcoxon significance
|
| 112 |
+
|
| 113 |
+
### 4. Generate Report (Notebook)
|
| 114 |
+
|
| 115 |
+
File: `notebooks/04_evaluation.ipynb` (Anggota E buat).
|
| 116 |
+
|
| 117 |
+
Wajib content untuk laporan:
|
| 118 |
+
- Table aggregate metrics (4 model x 5 metrics)
|
| 119 |
+
- Per-query analysis: queries mana yang hard untuk each model + kenapa
|
| 120 |
+
- Statistical significance table (pairwise Wilcoxon p-values)
|
| 121 |
+
- Visualizations:
|
| 122 |
+
- Bar chart MAP per model
|
| 123 |
+
- Scatter plot: query difficulty (avg performance) vs query length/complexity
|
| 124 |
+
- Heatmap: model x query (cell = MAP per query)
|
| 125 |
+
- Interpretation: kenapa Model X menang/kalah di query Y
|
| 126 |
+
|
| 127 |
+
## Files in Module
|
| 128 |
+
|
| 129 |
+
| File | Purpose |
|
| 130 |
+
|------|---------|
|
| 131 |
+
| `metrics.py` | P@K, MAP, NDCG@K, MRR |
|
| 132 |
+
| `kappa.py` | Cohen's & Weighted Kappa untuk inter-annotator |
|
| 133 |
+
| `statistical.py` | Paired t-test, Wilcoxon signed-rank |
|
| 134 |
+
| `runner.py` | CLI: run all-models x all-queries, output CSV |
|
| 135 |
+
| `README.md` | this file |
|
| 136 |
+
|
| 137 |
+
## What Tim Anggota E (Frontend + Eval Lead) WAJIB Do
|
| 138 |
+
|
| 139 |
+
### Priority 1 — Coordinate annotation (Week 3 start)
|
| 140 |
+
|
| 141 |
+
- Kick off 3-annotator process minggu pertama Week 3
|
| 142 |
+
- Use BM25 top-30 sebagai pool kandidat (cepat, lexical baseline)
|
| 143 |
+
- Provide annotators dengan: query text, listing snippet (judul + 200 char
|
| 144 |
+
deskripsi), full deskripsi link
|
| 145 |
+
- Calibration session sebelum mulai (1 jam, walk through 5 sample queries)
|
| 146 |
+
|
| 147 |
+
### Priority 2 — Build evaluation notebook (Week 4)
|
| 148 |
+
|
| 149 |
+
File: `notebooks/04_evaluation.ipynb`. Template structure di README di atas.
|
| 150 |
+
|
| 151 |
+
### Priority 3 — Build /eval dashboard (optional, kalau ada budget waktu)
|
| 152 |
+
|
| 153 |
+
FastAPI route `/eval/summary` yang return aggregate metrics + per-query
|
| 154 |
+
breakdown sebagai JSON. Frontend tab di React app untuk dosen lihat eval
|
| 155 |
+
langsung di web (bonus poin System 25%).
|
| 156 |
+
|
| 157 |
+
## Anti-Patterns
|
| 158 |
+
|
| 159 |
+
- [BAD] Compute MAP tanpa Kappa check dulu — kalau Kappa < 0.7, ground truth
|
| 160 |
+
unreliable, MAP angka gak meaningful
|
| 161 |
+
- [BAD] Annotator labels berbeda interpretation "relevant" — selalu kalibrasi
|
| 162 |
+
dulu
|
| 163 |
+
- [BAD] Cuma 1 annotator — gak ada agreement check, gak reproducible
|
| 164 |
+
- [BAD] Ground truth dari AI/dosen tanpa human annotator independen
|
| 165 |
+
- [BAD] Report MAP without significance test — perbedaan 0.05 bisa noise
|
| 166 |
+
|
| 167 |
+
## Testing
|
| 168 |
+
|
| 169 |
+
```bash
|
| 170 |
+
cd backend
|
| 171 |
+
pytest tests/test_evaluation.py -v
|
| 172 |
+
```
|
backend/app/evaluation/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation metrics + statistical tests untuk IR experiment.
|
| 2 |
+
|
| 3 |
+
Public API:
|
| 4 |
+
from app.evaluation import (
|
| 5 |
+
precision_at_k, average_precision, mean_average_precision,
|
| 6 |
+
ndcg_at_k, reciprocal_rank, mean_reciprocal_rank,
|
| 7 |
+
cohen_kappa, weighted_kappa,
|
| 8 |
+
paired_ttest, wilcoxon_signed_rank,
|
| 9 |
+
)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from .kappa import cohen_kappa, weighted_kappa
|
| 13 |
+
from .metrics import (
|
| 14 |
+
average_precision,
|
| 15 |
+
mean_average_precision,
|
| 16 |
+
mean_reciprocal_rank,
|
| 17 |
+
ndcg_at_k,
|
| 18 |
+
precision_at_k,
|
| 19 |
+
reciprocal_rank,
|
| 20 |
+
)
|
| 21 |
+
from .statistical import paired_ttest, wilcoxon_signed_rank
|
| 22 |
+
|
| 23 |
+
__all__ = [
|
| 24 |
+
# Metrics
|
| 25 |
+
"precision_at_k",
|
| 26 |
+
"average_precision",
|
| 27 |
+
"mean_average_precision",
|
| 28 |
+
"ndcg_at_k",
|
| 29 |
+
"reciprocal_rank",
|
| 30 |
+
"mean_reciprocal_rank",
|
| 31 |
+
# Inter-annotator agreement
|
| 32 |
+
"cohen_kappa",
|
| 33 |
+
"weighted_kappa",
|
| 34 |
+
# Statistical significance
|
| 35 |
+
"paired_ttest",
|
| 36 |
+
"wilcoxon_signed_rank",
|
| 37 |
+
]
|
backend/app/evaluation/kappa.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cohen's Kappa untuk inter-annotator agreement.
|
| 2 |
+
|
| 3 |
+
Course requirement (rubric Evaluation 10%): target Kappa >= 0.7 across
|
| 4 |
+
all annotator pairs. Kalau di bawah 0.7, dokumentasikan + lakuin
|
| 5 |
+
consensus resolution sebelum compute metric IR.
|
| 6 |
+
|
| 7 |
+
Interpretation (Landis & Koch 1977):
|
| 8 |
+
- < 0.20: slight agreement
|
| 9 |
+
- 0.21 - 0.40: fair
|
| 10 |
+
- 0.41 - 0.60: moderate
|
| 11 |
+
- 0.61 - 0.80: substantial
|
| 12 |
+
- 0.81 - 1.00: almost perfect
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def cohen_kappa(
|
| 21 |
+
annotator_a: list[int],
|
| 22 |
+
annotator_b: list[int],
|
| 23 |
+
labels: list[int] | None = None,
|
| 24 |
+
) -> float:
|
| 25 |
+
"""Cohen's Kappa untuk 2 annotator pada same items.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
annotator_a: labels dari annotator A (urutan harus match B)
|
| 29 |
+
annotator_b: labels dari annotator B
|
| 30 |
+
labels: list of possible label values (default: {0, 1, 2})
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Kappa value in [-1, 1]. 1 = perfect agreement, 0 = chance level.
|
| 34 |
+
"""
|
| 35 |
+
if len(annotator_a) != len(annotator_b):
|
| 36 |
+
raise ValueError(
|
| 37 |
+
f"Length mismatch: A={len(annotator_a)}, B={len(annotator_b)}"
|
| 38 |
+
)
|
| 39 |
+
n = len(annotator_a)
|
| 40 |
+
if n == 0:
|
| 41 |
+
return 0.0
|
| 42 |
+
|
| 43 |
+
if labels is None:
|
| 44 |
+
labels = sorted(set(annotator_a) | set(annotator_b))
|
| 45 |
+
|
| 46 |
+
# Confusion matrix
|
| 47 |
+
label_to_idx = {label: i for i, label in enumerate(labels)}
|
| 48 |
+
k = len(labels)
|
| 49 |
+
matrix = np.zeros((k, k))
|
| 50 |
+
for a, b in zip(annotator_a, annotator_b):
|
| 51 |
+
matrix[label_to_idx[a]][label_to_idx[b]] += 1
|
| 52 |
+
|
| 53 |
+
# Observed agreement (Po)
|
| 54 |
+
po = np.trace(matrix) / n
|
| 55 |
+
|
| 56 |
+
# Expected agreement by chance (Pe)
|
| 57 |
+
row_marginals = matrix.sum(axis=1) / n
|
| 58 |
+
col_marginals = matrix.sum(axis=0) / n
|
| 59 |
+
pe = float(np.sum(row_marginals * col_marginals))
|
| 60 |
+
|
| 61 |
+
if pe >= 1.0:
|
| 62 |
+
return 1.0 if po >= 1.0 else 0.0
|
| 63 |
+
|
| 64 |
+
return float((po - pe) / (1 - pe))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def weighted_kappa(
|
| 68 |
+
annotator_a: list[int],
|
| 69 |
+
annotator_b: list[int],
|
| 70 |
+
labels: list[int] | None = None,
|
| 71 |
+
weight_type: str = "linear",
|
| 72 |
+
) -> float:
|
| 73 |
+
"""Weighted Cohen's Kappa untuk ordinal labels (0/1/2 relevance).
|
| 74 |
+
|
| 75 |
+
Lebih appropriate dari plain kappa untuk graded relevance: disagreement
|
| 76 |
+
1 vs 2 di-penalize lebih ringan dari 0 vs 2.
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
weight_type: 'linear' atau 'quadratic' (lebih tegas)
|
| 80 |
+
"""
|
| 81 |
+
if len(annotator_a) != len(annotator_b):
|
| 82 |
+
raise ValueError("Length mismatch")
|
| 83 |
+
n = len(annotator_a)
|
| 84 |
+
if n == 0:
|
| 85 |
+
return 0.0
|
| 86 |
+
|
| 87 |
+
if labels is None:
|
| 88 |
+
labels = sorted(set(annotator_a) | set(annotator_b))
|
| 89 |
+
k = len(labels)
|
| 90 |
+
label_to_idx = {label: i for i, label in enumerate(labels)}
|
| 91 |
+
|
| 92 |
+
# Confusion matrix
|
| 93 |
+
matrix = np.zeros((k, k))
|
| 94 |
+
for a, b in zip(annotator_a, annotator_b):
|
| 95 |
+
matrix[label_to_idx[a]][label_to_idx[b]] += 1
|
| 96 |
+
|
| 97 |
+
# Weight matrix
|
| 98 |
+
weights = np.zeros((k, k))
|
| 99 |
+
for i in range(k):
|
| 100 |
+
for j in range(k):
|
| 101 |
+
diff = abs(i - j)
|
| 102 |
+
if weight_type == "linear":
|
| 103 |
+
weights[i][j] = diff / (k - 1) if k > 1 else 0
|
| 104 |
+
elif weight_type == "quadratic":
|
| 105 |
+
weights[i][j] = (diff / (k - 1)) ** 2 if k > 1 else 0
|
| 106 |
+
else:
|
| 107 |
+
raise ValueError(f"Unknown weight_type: {weight_type}")
|
| 108 |
+
|
| 109 |
+
# Expected matrix (outer product of marginals)
|
| 110 |
+
row_sum = matrix.sum(axis=1)
|
| 111 |
+
col_sum = matrix.sum(axis=0)
|
| 112 |
+
expected = np.outer(row_sum, col_sum) / n
|
| 113 |
+
|
| 114 |
+
weighted_observed = np.sum(weights * matrix)
|
| 115 |
+
weighted_expected = np.sum(weights * expected)
|
| 116 |
+
|
| 117 |
+
if weighted_expected == 0:
|
| 118 |
+
return 1.0 if weighted_observed == 0 else 0.0
|
| 119 |
+
return float(1 - weighted_observed / weighted_expected)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def interpret_kappa(kappa: float) -> str:
|
| 123 |
+
"""Kategorisasi kappa per Landis & Koch (1977)."""
|
| 124 |
+
if kappa < 0.0:
|
| 125 |
+
return "less than chance"
|
| 126 |
+
elif kappa < 0.20:
|
| 127 |
+
return "slight"
|
| 128 |
+
elif kappa < 0.40:
|
| 129 |
+
return "fair"
|
| 130 |
+
elif kappa < 0.60:
|
| 131 |
+
return "moderate"
|
| 132 |
+
elif kappa < 0.80:
|
| 133 |
+
return "substantial"
|
| 134 |
+
else:
|
| 135 |
+
return "almost perfect"
|
backend/app/evaluation/metrics.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""IR evaluation metrics: P@K, MAP, NDCG@K, MRR.
|
| 2 |
+
|
| 3 |
+
Convention:
|
| 4 |
+
- `predicted` = ordered list of doc_ids dari index query (rank 1 = first)
|
| 5 |
+
- `relevant` = set of doc_ids yang TRULY relevant (dari ground truth)
|
| 6 |
+
- `relevance_scores` = dict {doc_id: int} untuk graded relevance (0/1/2)
|
| 7 |
+
|
| 8 |
+
Semua metric in [0, 1] dengan 1 = perfect.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import math
|
| 14 |
+
from collections.abc import Iterable
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# =============================================================================
|
| 18 |
+
# Precision @ K
|
| 19 |
+
# =============================================================================
|
| 20 |
+
def precision_at_k(
|
| 21 |
+
predicted: list[str], relevant: Iterable[str], k: int
|
| 22 |
+
) -> float:
|
| 23 |
+
"""P@K = proportion of top-K predicted yang relevant.
|
| 24 |
+
|
| 25 |
+
Example:
|
| 26 |
+
predicted = ['a', 'b', 'c', 'd', 'e']
|
| 27 |
+
relevant = {'a', 'c', 'x'}
|
| 28 |
+
k = 5 -> 2/5 = 0.4
|
| 29 |
+
"""
|
| 30 |
+
if k <= 0:
|
| 31 |
+
return 0.0
|
| 32 |
+
relevant_set = set(relevant)
|
| 33 |
+
top_k = predicted[:k]
|
| 34 |
+
if not top_k:
|
| 35 |
+
return 0.0
|
| 36 |
+
hits = sum(1 for doc_id in top_k if doc_id in relevant_set)
|
| 37 |
+
return hits / k
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# =============================================================================
|
| 41 |
+
# Average Precision (AP) per query
|
| 42 |
+
# =============================================================================
|
| 43 |
+
def average_precision(
|
| 44 |
+
predicted: list[str], relevant: Iterable[str]
|
| 45 |
+
) -> float:
|
| 46 |
+
"""Average Precision per query.
|
| 47 |
+
|
| 48 |
+
AP = mean of P@k untuk setiap relevant doc yang muncul di predicted.
|
| 49 |
+
|
| 50 |
+
Example:
|
| 51 |
+
predicted = ['a', 'b', 'c', 'd']
|
| 52 |
+
relevant = {'a', 'c'}
|
| 53 |
+
-> P@1 = 1/1 (a hit), P@3 = 2/3 (c hit)
|
| 54 |
+
-> AP = (1.0 + 0.667) / 2 = 0.833
|
| 55 |
+
"""
|
| 56 |
+
relevant_set = set(relevant)
|
| 57 |
+
if not relevant_set:
|
| 58 |
+
return 0.0
|
| 59 |
+
|
| 60 |
+
precisions: list[float] = []
|
| 61 |
+
hits = 0
|
| 62 |
+
for i, doc_id in enumerate(predicted, start=1):
|
| 63 |
+
if doc_id in relevant_set:
|
| 64 |
+
hits += 1
|
| 65 |
+
precisions.append(hits / i)
|
| 66 |
+
|
| 67 |
+
if not precisions:
|
| 68 |
+
return 0.0
|
| 69 |
+
# Divide by number of relevant docs (kalau gak semua relevant ada di predicted,
|
| 70 |
+
# bagi tetap pake count of relevant — penalti untuk missed relevant)
|
| 71 |
+
return sum(precisions) / len(relevant_set)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def mean_average_precision(
|
| 75 |
+
predicted_per_query: dict[str, list[str]],
|
| 76 |
+
relevant_per_query: dict[str, Iterable[str]],
|
| 77 |
+
) -> float:
|
| 78 |
+
"""MAP = mean of AP across queries."""
|
| 79 |
+
if not predicted_per_query:
|
| 80 |
+
return 0.0
|
| 81 |
+
aps: list[float] = []
|
| 82 |
+
for query_id, predicted in predicted_per_query.items():
|
| 83 |
+
relevant = relevant_per_query.get(query_id, [])
|
| 84 |
+
aps.append(average_precision(predicted, relevant))
|
| 85 |
+
return sum(aps) / len(aps)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# =============================================================================
|
| 89 |
+
# NDCG @ K (Normalized Discounted Cumulative Gain)
|
| 90 |
+
# =============================================================================
|
| 91 |
+
def _dcg_at_k(gains: list[float], k: int) -> float:
|
| 92 |
+
"""DCG@K = sum gain_i / log2(i+1), 1-indexed rank."""
|
| 93 |
+
return sum(g / math.log2(i + 1) for i, g in enumerate(gains[:k], start=1))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def ndcg_at_k(
|
| 97 |
+
predicted: list[str],
|
| 98 |
+
relevance_scores: dict[str, int],
|
| 99 |
+
k: int = 10,
|
| 100 |
+
) -> float:
|
| 101 |
+
"""NDCG@K dengan graded relevance.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
predicted: ordered doc_ids dari IR model
|
| 105 |
+
relevance_scores: {doc_id: int} -- typical 0/1/2 atau 0-3 scale
|
| 106 |
+
k: cutoff rank
|
| 107 |
+
|
| 108 |
+
Score in [0, 1]. Penalize relevant docs di posisi rendah.
|
| 109 |
+
"""
|
| 110 |
+
if k <= 0 or not predicted:
|
| 111 |
+
return 0.0
|
| 112 |
+
|
| 113 |
+
# Actual DCG: pakai relevance_scores
|
| 114 |
+
gains = [relevance_scores.get(doc_id, 0) for doc_id in predicted[:k]]
|
| 115 |
+
dcg = _dcg_at_k(gains, k)
|
| 116 |
+
|
| 117 |
+
# Ideal DCG: sort all relevant docs by score desc
|
| 118 |
+
ideal_gains = sorted(relevance_scores.values(), reverse=True)
|
| 119 |
+
idcg = _dcg_at_k(ideal_gains, k)
|
| 120 |
+
|
| 121 |
+
if idcg == 0:
|
| 122 |
+
return 0.0
|
| 123 |
+
return dcg / idcg
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# =============================================================================
|
| 127 |
+
# Mean Reciprocal Rank (MRR)
|
| 128 |
+
# =============================================================================
|
| 129 |
+
def reciprocal_rank(
|
| 130 |
+
predicted: list[str], relevant: Iterable[str]
|
| 131 |
+
) -> float:
|
| 132 |
+
"""1 / rank dari relevant pertama di predicted. 0 kalau gak ada relevant."""
|
| 133 |
+
relevant_set = set(relevant)
|
| 134 |
+
for i, doc_id in enumerate(predicted, start=1):
|
| 135 |
+
if doc_id in relevant_set:
|
| 136 |
+
return 1.0 / i
|
| 137 |
+
return 0.0
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def mean_reciprocal_rank(
|
| 141 |
+
predicted_per_query: dict[str, list[str]],
|
| 142 |
+
relevant_per_query: dict[str, Iterable[str]],
|
| 143 |
+
) -> float:
|
| 144 |
+
"""MRR = mean of reciprocal_rank across queries."""
|
| 145 |
+
if not predicted_per_query:
|
| 146 |
+
return 0.0
|
| 147 |
+
rrs: list[float] = []
|
| 148 |
+
for query_id, predicted in predicted_per_query.items():
|
| 149 |
+
relevant = relevant_per_query.get(query_id, [])
|
| 150 |
+
rrs.append(reciprocal_rank(predicted, relevant))
|
| 151 |
+
return sum(rrs) / len(rrs)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# =============================================================================
|
| 155 |
+
# Constraint Satisfaction @ K (lensa kedua: ukur kebutuhan user, bukan teks)
|
| 156 |
+
# =============================================================================
|
| 157 |
+
def constraint_satisfaction_at_k(
|
| 158 |
+
results: list[dict],
|
| 159 |
+
constraints: dict,
|
| 160 |
+
k: int = 5,
|
| 161 |
+
max_km: float = 3.0,
|
| 162 |
+
) -> float:
|
| 163 |
+
"""Rasio top-K hasil yang memenuhi SEMUA konstrain query yang ada.
|
| 164 |
+
|
| 165 |
+
Berbeda dari P@K/MAP (yang mengukur relevansi teks dari qrels), metric ini
|
| 166 |
+
mengukur hal yang dioptimalkan smart pipeline: gender benar + harga sesuai +
|
| 167 |
+
fasilitas ada + dalam radius dari anchor. Tidak butuh qrels.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
results: list dict listing TERURUT, key: tipe, harga_per_bulan,
|
| 171 |
+
fasilitas (list[str]), lat, lng.
|
| 172 |
+
constraints: {gender, harga_max, fasilitas: list[str], anchor: (lat, lng)|None}.
|
| 173 |
+
k: cutoff. max_km: ambang jarak "dekat" dari anchor.
|
| 174 |
+
"""
|
| 175 |
+
from app.search.gazetteer import haversine_km
|
| 176 |
+
|
| 177 |
+
topk = results[:k]
|
| 178 |
+
if not topk:
|
| 179 |
+
return 0.0
|
| 180 |
+
|
| 181 |
+
ok = 0
|
| 182 |
+
for r in topk:
|
| 183 |
+
good = True
|
| 184 |
+
if constraints.get("gender") and r.get("tipe") != constraints["gender"]:
|
| 185 |
+
good = False
|
| 186 |
+
if constraints.get("harga_max") and (r.get("harga_per_bulan") or 0) > constraints["harga_max"]:
|
| 187 |
+
good = False
|
| 188 |
+
have = [str(f).lower() for f in (r.get("fasilitas") or [])]
|
| 189 |
+
for kw in constraints.get("fasilitas", []):
|
| 190 |
+
if not any(kw in f for f in have):
|
| 191 |
+
good = False
|
| 192 |
+
anchor = constraints.get("anchor")
|
| 193 |
+
if anchor and r.get("lat") is not None and r.get("lng") is not None:
|
| 194 |
+
if haversine_km(float(r["lat"]), float(r["lng"]), anchor[0], anchor[1]) > max_km:
|
| 195 |
+
good = False
|
| 196 |
+
ok += int(good)
|
| 197 |
+
return ok / len(topk)
|
backend/app/evaluation/runner.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLI runner untuk evaluation: run semua model x query, compute metrics.
|
| 2 |
+
|
| 3 |
+
Workflow:
|
| 4 |
+
# 1. Pastikan: corpus + indexes + ground truth siap
|
| 5 |
+
# - data/processed/corpus.json
|
| 6 |
+
# - data/indexes/{tfidf.pkl, bm25.pkl, indobert/}
|
| 7 |
+
# - eval/queries.json
|
| 8 |
+
# - eval/ground_truth.csv
|
| 9 |
+
|
| 10 |
+
# 2. Run evaluation
|
| 11 |
+
python -m app.evaluation.runner \\
|
| 12 |
+
--queries ../eval/queries.json \\
|
| 13 |
+
--ground-truth ../eval/ground_truth.csv \\
|
| 14 |
+
--indexes-dir ../data/indexes \\
|
| 15 |
+
--output ../eval/results.csv
|
| 16 |
+
|
| 17 |
+
# 3. Output:
|
| 18 |
+
# eval/results.csv -- per-model per-query metrics
|
| 19 |
+
# Console summary: aggregate MAP, P@5/10, NDCG@10, MRR per model
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import csv
|
| 26 |
+
import json
|
| 27 |
+
import sys
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Any
|
| 30 |
+
|
| 31 |
+
from loguru import logger
|
| 32 |
+
|
| 33 |
+
from .kappa import interpret_kappa
|
| 34 |
+
from .metrics import (
|
| 35 |
+
mean_average_precision,
|
| 36 |
+
mean_reciprocal_rank,
|
| 37 |
+
ndcg_at_k,
|
| 38 |
+
precision_at_k,
|
| 39 |
+
)
|
| 40 |
+
from .statistical import paired_ttest, wilcoxon_signed_rank
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def load_queries(path: Path) -> list[dict[str, Any]]:
|
| 44 |
+
"""Load eval/queries.json -> list of {id, query, context, ...}."""
|
| 45 |
+
with open(path, encoding="utf-8") as f:
|
| 46 |
+
data = json.load(f)
|
| 47 |
+
return data.get("queries", data) # support raw list atau {queries: [...]}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def load_ground_truth(path: Path) -> dict[str, dict[str, int]]:
|
| 51 |
+
"""Load ground_truth.csv -> {query_id: {doc_id: consensus_relevance}}.
|
| 52 |
+
|
| 53 |
+
Expected columns: query_id, doc_id, relevance (consensus)
|
| 54 |
+
"""
|
| 55 |
+
gt: dict[str, dict[str, int]] = {}
|
| 56 |
+
with open(path, encoding="utf-8") as f:
|
| 57 |
+
reader = csv.DictReader(f)
|
| 58 |
+
for row in reader:
|
| 59 |
+
qid = row["query_id"]
|
| 60 |
+
did = row["doc_id"]
|
| 61 |
+
rel = int(row["relevance"])
|
| 62 |
+
gt.setdefault(qid, {})[did] = rel
|
| 63 |
+
return gt
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def relevant_set(rel_dict: dict[str, int], threshold: int = 1) -> set[str]:
|
| 67 |
+
"""Convert graded relevance ke binary relevant set (rel >= threshold)."""
|
| 68 |
+
return {doc_id for doc_id, rel in rel_dict.items() if rel >= threshold}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def main() -> int:
|
| 72 |
+
parser = argparse.ArgumentParser(description="Run IR evaluation")
|
| 73 |
+
parser.add_argument("--queries", type=Path, required=True)
|
| 74 |
+
parser.add_argument("--ground-truth", type=Path, required=True)
|
| 75 |
+
parser.add_argument("--indexes-dir", type=Path, required=True)
|
| 76 |
+
parser.add_argument("--output", type=Path, required=True)
|
| 77 |
+
parser.add_argument("--top-k", type=int, default=10)
|
| 78 |
+
parser.add_argument(
|
| 79 |
+
"--models",
|
| 80 |
+
nargs="+",
|
| 81 |
+
default=["tfidf", "bm25", "indobert", "hybrid"],
|
| 82 |
+
)
|
| 83 |
+
args = parser.parse_args()
|
| 84 |
+
|
| 85 |
+
# Load eval data
|
| 86 |
+
queries = load_queries(args.queries)
|
| 87 |
+
ground_truth = load_ground_truth(args.ground_truth)
|
| 88 |
+
logger.info(
|
| 89 |
+
f"[eval] {len(queries)} queries, "
|
| 90 |
+
f"{sum(len(v) for v in ground_truth.values())} GT annotations"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# Load preprocessing pipeline (untuk preprocess queries)
|
| 94 |
+
from app.preprocessing import PreprocessingPipeline
|
| 95 |
+
|
| 96 |
+
pipeline = PreprocessingPipeline()
|
| 97 |
+
|
| 98 |
+
# Load indexes
|
| 99 |
+
from app.indexing.hybrid import HybridIndex
|
| 100 |
+
from app.indexing.loader import load_all_indexes
|
| 101 |
+
|
| 102 |
+
indexes = load_all_indexes(args.indexes_dir)
|
| 103 |
+
if "bm25" in indexes and "indobert" in indexes and "hybrid" in args.models:
|
| 104 |
+
indexes["hybrid"] = HybridIndex(
|
| 105 |
+
indexes["bm25"],
|
| 106 |
+
indexes["indobert"],
|
| 107 |
+
query_preprocessor=lambda q: pipeline.process(q).processed,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Run all model x all queries
|
| 111 |
+
# Structure: results[model][query_id] = {p5, p10, ap, ndcg10, rr}
|
| 112 |
+
results: dict[str, dict[str, dict[str, float]]] = {}
|
| 113 |
+
|
| 114 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 115 |
+
with open(args.output, "w", encoding="utf-8", newline="") as out_f:
|
| 116 |
+
writer = csv.writer(out_f)
|
| 117 |
+
writer.writerow(
|
| 118 |
+
["model", "query_id", "query", "p_at_5", "p_at_10", "ap",
|
| 119 |
+
"ndcg_at_10", "rr"]
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
for model_name in args.models:
|
| 123 |
+
if model_name not in indexes:
|
| 124 |
+
logger.warning(f"[skip] index {model_name} gak ditemukan")
|
| 125 |
+
continue
|
| 126 |
+
index = indexes[model_name]
|
| 127 |
+
results[model_name] = {}
|
| 128 |
+
|
| 129 |
+
for q in queries:
|
| 130 |
+
qid = q["id"]
|
| 131 |
+
q_text = q["query"]
|
| 132 |
+
processed = pipeline.process(q_text).processed
|
| 133 |
+
# BM25/TF-IDF: lexical, butuh ter-stem. IndoBERT/Hybrid:
|
| 134 |
+
# semantic, perlu natural language (raw query).
|
| 135 |
+
if model_name in ("indobert", "hybrid"):
|
| 136 |
+
search_q = q_text
|
| 137 |
+
else:
|
| 138 |
+
search_q = processed
|
| 139 |
+
hits = index.query(search_q, top_k=args.top_k)
|
| 140 |
+
predicted = [h.doc_id for h in hits]
|
| 141 |
+
|
| 142 |
+
rel_dict = ground_truth.get(qid, {})
|
| 143 |
+
rel_set = relevant_set(rel_dict, threshold=1)
|
| 144 |
+
|
| 145 |
+
p5 = precision_at_k(predicted, rel_set, 5)
|
| 146 |
+
p10 = precision_at_k(predicted, rel_set, 10)
|
| 147 |
+
from .metrics import average_precision, reciprocal_rank
|
| 148 |
+
|
| 149 |
+
ap = average_precision(predicted, rel_set)
|
| 150 |
+
nd10 = ndcg_at_k(predicted, rel_dict, 10)
|
| 151 |
+
rr = reciprocal_rank(predicted, rel_set)
|
| 152 |
+
|
| 153 |
+
results[model_name][qid] = {
|
| 154 |
+
"p5": p5, "p10": p10, "ap": ap, "ndcg10": nd10, "rr": rr,
|
| 155 |
+
}
|
| 156 |
+
writer.writerow([model_name, qid, q_text, p5, p10, ap, nd10, rr])
|
| 157 |
+
|
| 158 |
+
logger.info(f"[done] model={model_name}")
|
| 159 |
+
|
| 160 |
+
# Console summary
|
| 161 |
+
logger.info("\n=== AGGREGATE METRICS ===")
|
| 162 |
+
for model_name, per_query in results.items():
|
| 163 |
+
n = len(per_query)
|
| 164 |
+
if n == 0:
|
| 165 |
+
continue
|
| 166 |
+
avg_p5 = sum(v["p5"] for v in per_query.values()) / n
|
| 167 |
+
avg_p10 = sum(v["p10"] for v in per_query.values()) / n
|
| 168 |
+
avg_ap = sum(v["ap"] for v in per_query.values()) / n
|
| 169 |
+
avg_ndcg = sum(v["ndcg10"] for v in per_query.values()) / n
|
| 170 |
+
avg_rr = sum(v["rr"] for v in per_query.values()) / n
|
| 171 |
+
logger.info(
|
| 172 |
+
f"{model_name:10s} P@5={avg_p5:.4f} P@10={avg_p10:.4f} "
|
| 173 |
+
f"MAP={avg_ap:.4f} NDCG@10={avg_ndcg:.4f} MRR={avg_rr:.4f}"
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# Pairwise statistical significance (Wilcoxon)
|
| 177 |
+
model_names = [m for m in args.models if m in results]
|
| 178 |
+
if len(model_names) >= 2:
|
| 179 |
+
logger.info("\n=== PAIRWISE WILCOXON (MAP) ===")
|
| 180 |
+
for i, ma in enumerate(model_names):
|
| 181 |
+
for mb in model_names[i + 1 :]:
|
| 182 |
+
a_aps = [results[ma][qid]["ap"] for qid in results[ma]]
|
| 183 |
+
b_aps = [results[mb][qid]["ap"] for qid in results[mb]]
|
| 184 |
+
try:
|
| 185 |
+
test = wilcoxon_signed_rank(a_aps, b_aps)
|
| 186 |
+
logger.info(f"{ma} vs {mb}: {test}")
|
| 187 |
+
except Exception as e:
|
| 188 |
+
logger.warning(f"{ma} vs {mb}: {e}")
|
| 189 |
+
|
| 190 |
+
logger.info(f"\n[done] per-query metrics saved to {args.output}")
|
| 191 |
+
return 0
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
if __name__ == "__main__":
|
| 195 |
+
sys.exit(main())
|
backend/app/evaluation/statistical.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Statistical significance tests untuk compare IR models.
|
| 2 |
+
|
| 3 |
+
Untuk per-query metric (e.g., AP per query untuk Model A vs Model B):
|
| 4 |
+
- **paired_ttest**: kalau distribusi roughly normal
|
| 5 |
+
- **wilcoxon_signed_rank**: non-parametric, lebih robust untuk metric bounded [0,1]
|
| 6 |
+
|
| 7 |
+
Convention: H0 = "Model A = Model B", alpha = 0.05.
|
| 8 |
+
Kalau p-value < 0.05, reject H0 => significant difference.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from typing import Literal
|
| 15 |
+
|
| 16 |
+
from scipy import stats
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class StatTestResult:
|
| 21 |
+
"""Hasil statistical test."""
|
| 22 |
+
test_name: str
|
| 23 |
+
statistic: float
|
| 24 |
+
p_value: float
|
| 25 |
+
n: int # jumlah pair
|
| 26 |
+
significant: bool # p < alpha
|
| 27 |
+
alpha: float = 0.05
|
| 28 |
+
effect_size: float | None = None # cohen's d untuk t-test
|
| 29 |
+
|
| 30 |
+
def __str__(self) -> str:
|
| 31 |
+
sig = "SIGNIFICANT" if self.significant else "not significant"
|
| 32 |
+
return (
|
| 33 |
+
f"{self.test_name}: stat={self.statistic:.4f}, "
|
| 34 |
+
f"p={self.p_value:.4f}, n={self.n}, {sig} (alpha={self.alpha})"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def paired_ttest(
|
| 39 |
+
model_a_scores: list[float],
|
| 40 |
+
model_b_scores: list[float],
|
| 41 |
+
alpha: float = 0.05,
|
| 42 |
+
alternative: Literal["two-sided", "less", "greater"] = "two-sided",
|
| 43 |
+
) -> StatTestResult:
|
| 44 |
+
"""Paired t-test untuk per-query metric.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
model_a_scores: metric per query untuk Model A (e.g., AP@10)
|
| 48 |
+
model_b_scores: same query order, Model B
|
| 49 |
+
alpha: significance threshold (default 0.05)
|
| 50 |
+
alternative: 'two-sided' / 'less' / 'greater' (one-sided)
|
| 51 |
+
|
| 52 |
+
Returns StatTestResult dengan Cohen's d effect size.
|
| 53 |
+
"""
|
| 54 |
+
if len(model_a_scores) != len(model_b_scores):
|
| 55 |
+
raise ValueError(
|
| 56 |
+
f"Length mismatch: A={len(model_a_scores)}, B={len(model_b_scores)}"
|
| 57 |
+
)
|
| 58 |
+
if len(model_a_scores) < 2:
|
| 59 |
+
raise ValueError("Butuh minimal 2 query untuk t-test")
|
| 60 |
+
|
| 61 |
+
result = stats.ttest_rel(
|
| 62 |
+
model_a_scores, model_b_scores, alternative=alternative
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Cohen's d effect size (paired)
|
| 66 |
+
import numpy as np
|
| 67 |
+
|
| 68 |
+
diffs = np.array(model_a_scores) - np.array(model_b_scores)
|
| 69 |
+
effect_size = float(np.mean(diffs) / np.std(diffs, ddof=1)) if np.std(diffs, ddof=1) > 0 else 0.0
|
| 70 |
+
|
| 71 |
+
return StatTestResult(
|
| 72 |
+
test_name="paired_ttest",
|
| 73 |
+
statistic=float(result.statistic),
|
| 74 |
+
p_value=float(result.pvalue),
|
| 75 |
+
n=len(model_a_scores),
|
| 76 |
+
significant=result.pvalue < alpha,
|
| 77 |
+
alpha=alpha,
|
| 78 |
+
effect_size=effect_size,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@dataclass
|
| 83 |
+
class HolmEntry:
|
| 84 |
+
"""Hasil koreksi Holm-Bonferroni untuk satu uji dalam keluarga uji."""
|
| 85 |
+
label: str
|
| 86 |
+
p_value: float
|
| 87 |
+
p_adjusted: float # Holm step-down adjusted p-value (monotone)
|
| 88 |
+
threshold: float # ambang alpha/(m - rank) untuk uji ini
|
| 89 |
+
significant: bool # p_adjusted < alpha
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def holm_bonferroni(
|
| 93 |
+
tests: list[tuple[str, float]],
|
| 94 |
+
alpha: float = 0.05,
|
| 95 |
+
) -> list[HolmEntry]:
|
| 96 |
+
"""Koreksi multiple comparison Holm-Bonferroni (step-down).
|
| 97 |
+
|
| 98 |
+
Menjalankan m uji sekaligus pada alpha=0.05 menggelembungkan peluang
|
| 99 |
+
false positive (m=6 -> ~26%). Holm: urutkan p ascending, bandingkan
|
| 100 |
+
p_i dengan alpha/(m-i); berhenti di kegagalan pertama, sisanya tidak
|
| 101 |
+
signifikan. Lebih powerful dari Bonferroni murni, tetap kontrol FWER.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
tests: list (label, p_value), urutan bebas.
|
| 105 |
+
Returns:
|
| 106 |
+
list HolmEntry dengan urutan input dipertahankan.
|
| 107 |
+
"""
|
| 108 |
+
m = len(tests)
|
| 109 |
+
if m == 0:
|
| 110 |
+
return []
|
| 111 |
+
order = sorted(range(m), key=lambda i: tests[i][1])
|
| 112 |
+
|
| 113 |
+
# Adjusted p: max kumulatif dari (m - rank) * p, di-cap 1.0 (monotone)
|
| 114 |
+
adjusted = [0.0] * m
|
| 115 |
+
running_max = 0.0
|
| 116 |
+
rejecting = True
|
| 117 |
+
thresholds = [0.0] * m
|
| 118 |
+
significant = [False] * m
|
| 119 |
+
for rank, idx in enumerate(order):
|
| 120 |
+
p = tests[idx][1]
|
| 121 |
+
adj = min(1.0, (m - rank) * p)
|
| 122 |
+
running_max = max(running_max, adj)
|
| 123 |
+
adjusted[idx] = running_max
|
| 124 |
+
thresholds[idx] = alpha / (m - rank)
|
| 125 |
+
# step-down: begitu satu gagal, semua setelahnya gagal
|
| 126 |
+
if rejecting and p <= thresholds[idx]:
|
| 127 |
+
significant[idx] = True
|
| 128 |
+
else:
|
| 129 |
+
rejecting = False
|
| 130 |
+
|
| 131 |
+
return [
|
| 132 |
+
HolmEntry(
|
| 133 |
+
label=tests[i][0],
|
| 134 |
+
p_value=tests[i][1],
|
| 135 |
+
p_adjusted=adjusted[i],
|
| 136 |
+
threshold=thresholds[i],
|
| 137 |
+
significant=significant[i],
|
| 138 |
+
)
|
| 139 |
+
for i in range(m)
|
| 140 |
+
]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def wilcoxon_signed_rank(
|
| 144 |
+
model_a_scores: list[float],
|
| 145 |
+
model_b_scores: list[float],
|
| 146 |
+
alpha: float = 0.05,
|
| 147 |
+
alternative: Literal["two-sided", "less", "greater"] = "two-sided",
|
| 148 |
+
) -> StatTestResult:
|
| 149 |
+
"""Wilcoxon signed-rank test — non-parametric paired comparison.
|
| 150 |
+
|
| 151 |
+
Lebih robust untuk metric bounded [0, 1] yang sering tidak normal.
|
| 152 |
+
Direkomendasikan untuk IR metric comparison.
|
| 153 |
+
"""
|
| 154 |
+
if len(model_a_scores) != len(model_b_scores):
|
| 155 |
+
raise ValueError("Length mismatch")
|
| 156 |
+
if len(model_a_scores) < 5:
|
| 157 |
+
raise ValueError("Butuh minimal 5 query untuk Wilcoxon (statistical power)")
|
| 158 |
+
|
| 159 |
+
result = stats.wilcoxon(
|
| 160 |
+
model_a_scores, model_b_scores, alternative=alternative
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
return StatTestResult(
|
| 164 |
+
test_name="wilcoxon_signed_rank",
|
| 165 |
+
statistic=float(result.statistic),
|
| 166 |
+
p_value=float(result.pvalue),
|
| 167 |
+
n=len(model_a_scores),
|
| 168 |
+
significant=result.pvalue < alpha,
|
| 169 |
+
alpha=alpha,
|
| 170 |
+
effect_size=None, # Wilcoxon gak punya standard effect size
|
| 171 |
+
)
|
backend/app/indexing/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Indexing Module
|
| 2 |
+
|
| 3 |
+
Tiga paradigma IR + optional hybrid:
|
| 4 |
+
|
| 5 |
+
| Index | Library | Type | Strength | Weakness |
|
| 6 |
+
|-------|---------|------|----------|----------|
|
| 7 |
+
| `TFIDFIndex` | scikit-learn `TfidfVectorizer` + cosine | Lexical | Fast, interpretable | Synonym/paraphrase miss |
|
| 8 |
+
| `BM25Index` | `rank_bm25` `BM25Okapi` | Lexical (probabilistic) | Term saturation | Still lexical |
|
| 9 |
+
| `IndoBERTIndex` | `sentence-transformers` + FAISS | Neural / semantic | Semantic, paraphrase-aware | Heavy, slower |
|
| 10 |
+
| `HybridIndex` | BM25 candidates -> IndoBERT rerank | Composite | Best of both | Latency tertinggi |
|
| 11 |
+
|
| 12 |
+
## Workflow
|
| 13 |
+
|
| 14 |
+
```
|
| 15 |
+
data/raw/mamikos.jsonl (output Anggota A scraper)
|
| 16 |
+
-> preprocessing pipeline (Anggota B)
|
| 17 |
+
-> data/processed/corpus.json (input ke build.py)
|
| 18 |
+
-> python -m app.indexing.build (Anggota C + D)
|
| 19 |
+
-> data/indexes/
|
| 20 |
+
tfidf.pkl
|
| 21 |
+
bm25.pkl
|
| 22 |
+
indobert/
|
| 23 |
+
embeddings.npy
|
| 24 |
+
faiss.index
|
| 25 |
+
meta.json
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## Quick Start
|
| 29 |
+
|
| 30 |
+
```bash
|
| 31 |
+
cd backend
|
| 32 |
+
.venv\Scripts\activate # Windows
|
| 33 |
+
|
| 34 |
+
# 1. Pastikan corpus.json sudah ada
|
| 35 |
+
ls ../data/processed/corpus.json
|
| 36 |
+
|
| 37 |
+
# 2. Build semua indexes
|
| 38 |
+
python -m app.indexing.build \
|
| 39 |
+
--corpus ../data/processed/corpus.json \
|
| 40 |
+
--output-dir ../data/indexes
|
| 41 |
+
|
| 42 |
+
# 3. Smoke test load (di Python REPL atau notebook)
|
| 43 |
+
python -c "
|
| 44 |
+
from pathlib import Path
|
| 45 |
+
from app.indexing.loader import load_all_indexes
|
| 46 |
+
indexes = load_all_indexes(Path('../data/indexes'))
|
| 47 |
+
hits = indexes['bm25'].query('kos putra dekat unila', top_k=5)
|
| 48 |
+
for h in hits:
|
| 49 |
+
print(h)
|
| 50 |
+
"
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
## Corpus Format
|
| 54 |
+
|
| 55 |
+
`data/processed/corpus.json` = list of objects:
|
| 56 |
+
|
| 57 |
+
```json
|
| 58 |
+
[
|
| 59 |
+
{
|
| 60 |
+
"id": "kos-abc-123",
|
| 61 |
+
"text": "kos putra eksklusif gedong meneng air conditioner kamar mandi dalam ...",
|
| 62 |
+
"raw_text": "Kos Putra Exclusive Gedong Meneng dengan AC dan KMD ...",
|
| 63 |
+
"metadata": {
|
| 64 |
+
"judul": "Kos Putra Exclusive Gedong Meneng",
|
| 65 |
+
"harga_per_bulan": 850000,
|
| 66 |
+
"tipe": "putra",
|
| 67 |
+
"fasilitas": ["ac", "wifi", "kamar mandi dalam"],
|
| 68 |
+
"alamat": "Jl. Sumantri Brojonegoro No. 1",
|
| 69 |
+
"kecamatan": "Rajabasa"
|
| 70 |
+
}
|
| 71 |
+
},
|
| 72 |
+
...
|
| 73 |
+
]
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
- `text` = output preprocessing pipeline (lowercased, stemmed, stopword-free)
|
| 77 |
+
- `raw_text` = original deskripsi untuk display di UI
|
| 78 |
+
- `metadata` = field tambahan dari schema scraper
|
| 79 |
+
|
| 80 |
+
## What's Done (oleh mentor — scaffold)
|
| 81 |
+
|
| 82 |
+
- [x] `base.py` — `Document`, `SearchHit`, `IndexBase` abstract
|
| 83 |
+
- [x] `tfidf.py` — `TFIDFIndex` dengan default ngram=(1,2), min_df=2,
|
| 84 |
+
max_features=10000, sublinear_tf
|
| 85 |
+
- [x] `bm25.py` — `BM25Index` dengan k1=1.5, b=0.75 (rank_bm25)
|
| 86 |
+
- [x] `indobert.py` — `IndoBERTIndex` dengan MiniLM multilingual default,
|
| 87 |
+
FAISS IndexFlatIP, lazy model load, `score_docs()` untuk hybrid rerun
|
| 88 |
+
- [x] `hybrid.py` — `HybridIndex` (BM25 top-50 -> IndoBERT rerank, min-max
|
| 89 |
+
normalize + weighted combination, configurable alpha)
|
| 90 |
+
- [x] `build.py` — CLI builder untuk semua indexes
|
| 91 |
+
- [x] `loader.py` — FastAPI lifespan helper
|
| 92 |
+
- [x] `tests/test_indexing.py` — unit test dengan small fixture corpus
|
| 93 |
+
|
| 94 |
+
## What Tim Anggota C (IR Baseline) WAJIB Do
|
| 95 |
+
|
| 96 |
+
### Priority 1 — Tune TF-IDF + BM25 hyperparameters
|
| 97 |
+
|
| 98 |
+
File: `notebooks/03_model_comparison.ipynb`.
|
| 99 |
+
|
| 100 |
+
Experiment:
|
| 101 |
+
- TF-IDF: `ngram_range` in [(1,1), (1,2), (1,3)], `min_df` in [1, 2, 5],
|
| 102 |
+
`max_features` in [5000, 10000, 20000, None]
|
| 103 |
+
- BM25: `k1` in [1.0, 1.2, 1.5, 2.0], `b` in [0.5, 0.75, 1.0]
|
| 104 |
+
|
| 105 |
+
Plot heatmap MAP per kombinasi. Pilih best, dokumentasikan kenapa di laporan.
|
| 106 |
+
|
| 107 |
+
### Priority 2 — Edge case query testing
|
| 108 |
+
|
| 109 |
+
Setelah index ready, test queries:
|
| 110 |
+
- 1-word query: "kos" — apakah TF-IDF/BM25 robust?
|
| 111 |
+
- Stopword-heavy query: "yang ada di dekat kampus" — apakah preprocessing
|
| 112 |
+
handle dengan baik?
|
| 113 |
+
- Out-of-vocabulary: query dengan term yang gak muncul di corpus
|
| 114 |
+
|
| 115 |
+
## What Tim Anggota D (IR Neural) WAJIB Do
|
| 116 |
+
|
| 117 |
+
### Priority 1 — Compare embedding models
|
| 118 |
+
|
| 119 |
+
Default: `paraphrase-multilingual-MiniLM-L12-v2` (~118MB).
|
| 120 |
+
Alternative: `indobenchmark/indobert-base-p2` (~440MB, lebih bagus tapi heavy).
|
| 121 |
+
|
| 122 |
+
Test both di sample queries, compare MAP. Trade-off: model size vs accuracy
|
| 123 |
+
vs Render free tier RAM (512MB).
|
| 124 |
+
|
| 125 |
+
### Priority 2 — Tune Hybrid alpha
|
| 126 |
+
|
| 127 |
+
File: `hybrid.py` di constructor `alpha` parameter.
|
| 128 |
+
|
| 129 |
+
Experiment alpha in [0.0, 0.3, 0.5, 0.7] di ground truth queries. Pure rerank
|
| 130 |
+
(alpha=0) sering kompetitif untuk Indonesian.
|
| 131 |
+
|
| 132 |
+
### Priority 3 — Pooling strategy comparison
|
| 133 |
+
|
| 134 |
+
`sentence-transformers` default mean-pool. Bisa explore:
|
| 135 |
+
- CLS token pooling
|
| 136 |
+
- Max pooling
|
| 137 |
+
- Concat (CLS + mean)
|
| 138 |
+
|
| 139 |
+
Pakai `model.encode(texts, output_value="token_embeddings")` untuk custom pool.
|
| 140 |
+
|
| 141 |
+
## Anti-Patterns
|
| 142 |
+
|
| 143 |
+
- [BAD] `FAISS IndexIVFFlat` untuk corpus <=5000 docs — overhead lebih besar
|
| 144 |
+
dari benefit. Pakai `IndexFlatIP` (exhaustive) sampai 10K+ docs.
|
| 145 |
+
- [BAD] Re-encode docs di tiap hybrid query — slow. Pakai `score_docs()`
|
| 146 |
+
yang reuse embeddings.
|
| 147 |
+
- [BAD] Pickle FAISS index langsung — fragile across versions. Pakai
|
| 148 |
+
`faiss.write_index()` + `faiss.read_index()`.
|
| 149 |
+
- [BAD] Cache embeddings di memory tanpa save — restart loss. Selalu save
|
| 150 |
+
ke disk.
|
| 151 |
+
|
| 152 |
+
## Performance Expectations
|
| 153 |
+
|
| 154 |
+
Corpus 3000 listings @ 384-dim embeddings:
|
| 155 |
+
|
| 156 |
+
| Operation | Time |
|
| 157 |
+
|-----------|------|
|
| 158 |
+
| TF-IDF build | 1-2s |
|
| 159 |
+
| BM25 build | <1s |
|
| 160 |
+
| IndoBERT encode all 3K docs (MiniLM, CPU) | 30-60s |
|
| 161 |
+
| IndoBERT encode all 3K docs (IndoBERT base, CPU) | 2-3 min |
|
| 162 |
+
| TF-IDF query | <5ms |
|
| 163 |
+
| BM25 query | <10ms |
|
| 164 |
+
| IndoBERT query (encode + FAISS Flat) | 50-100ms (mostly encode) |
|
| 165 |
+
| Hybrid query | 80-150ms |
|
| 166 |
+
|
| 167 |
+
Acceptable untuk demo. Optimization optional kalau ada budget waktu di Week 4.
|
| 168 |
+
|
| 169 |
+
## Testing
|
| 170 |
+
|
| 171 |
+
```bash
|
| 172 |
+
cd backend
|
| 173 |
+
pytest tests/test_indexing.py -v
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
Test cover:
|
| 177 |
+
- Each index: build, query, save, load roundtrip
|
| 178 |
+
- Edge cases: empty corpus, top_k > corpus_size
|
| 179 |
+
- Hybrid: combination math, alpha=0/0.5/1
|
| 180 |
+
- Performance smoke: 50-doc fixture builds < 5s
|
| 181 |
+
|
| 182 |
+
## Save Format Reference
|
| 183 |
+
|
| 184 |
+
| Index | File | Format |
|
| 185 |
+
|-------|------|--------|
|
| 186 |
+
| TF-IDF | `tfidf.pkl` | pickle: `{vectorizer, doc_matrix (sparse), doc_ids, size}` |
|
| 187 |
+
| BM25 | `bm25.pkl` | pickle: `{k1, b, doc_ids, tokenized_corpus, size}` (BM25Okapi rebuilt on load) |
|
| 188 |
+
| IndoBERT | `indobert/` folder | `embeddings.npy` + `faiss.index` + `meta.json` |
|
| 189 |
+
| Hybrid | _none_ | Composition only — load sub-indexes, instantiate HybridIndex |
|
backend/app/indexing/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""IR index builders: TF-IDF, BM25, IndoBERT+FAISS, Hybrid.
|
| 2 |
+
|
| 3 |
+
Public API:
|
| 4 |
+
from app.indexing import (
|
| 5 |
+
Document, SearchHit, IndexBase,
|
| 6 |
+
TFIDFIndex, BM25Index, IndoBERTIndex, HybridIndex,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
Workflow:
|
| 10 |
+
1. Load corpus dari data/processed/corpus.json (after preprocessing)
|
| 11 |
+
2. Build setiap index, save ke data/indexes/
|
| 12 |
+
3. FastAPI lifespan load semua indexes saat startup
|
| 13 |
+
4. Search route /search?model=X panggil index.query(q)
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from .base import Document, IndexBase, SearchHit
|
| 17 |
+
from .bm25 import BM25Index
|
| 18 |
+
from .hybrid import HybridIndex
|
| 19 |
+
from .indobert import IndoBERTIndex
|
| 20 |
+
from .tfidf import TFIDFIndex
|
| 21 |
+
|
| 22 |
+
__all__ = [
|
| 23 |
+
"Document",
|
| 24 |
+
"SearchHit",
|
| 25 |
+
"IndexBase",
|
| 26 |
+
"TFIDFIndex",
|
| 27 |
+
"BM25Index",
|
| 28 |
+
"IndoBERTIndex",
|
| 29 |
+
"HybridIndex",
|
| 30 |
+
]
|
backend/app/indexing/base.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Abstract IndexBase + shared dataclasses.
|
| 2 |
+
|
| 3 |
+
Setiap index subclass implement: build, query, save, load.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from abc import ABC, abstractmethod
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any, Optional
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class Document:
|
| 16 |
+
"""Document representation untuk indexing.
|
| 17 |
+
|
| 18 |
+
`text` = processed text (output preprocessing pipeline), siap di-index.
|
| 19 |
+
`raw_text` = original deskripsi (untuk display di UI).
|
| 20 |
+
`metadata` = field tambahan (judul, harga, alamat, kecamatan, dll).
|
| 21 |
+
"""
|
| 22 |
+
id: str
|
| 23 |
+
text: str
|
| 24 |
+
raw_text: Optional[str] = None
|
| 25 |
+
metadata: dict[str, Any] = field(default_factory=dict)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class SearchHit:
|
| 30 |
+
"""Single result dari index query."""
|
| 31 |
+
doc_id: str
|
| 32 |
+
score: float
|
| 33 |
+
rank: int
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class IndexBase(ABC):
|
| 37 |
+
"""Abstract base class untuk semua IR index.
|
| 38 |
+
|
| 39 |
+
Convention: setiap subclass punya class attribute `name` (string identifier
|
| 40 |
+
untuk routing di /search?model=<name>).
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
name: str = "base" # override per subclass
|
| 44 |
+
|
| 45 |
+
@abstractmethod
|
| 46 |
+
def build(self, corpus: list[Document]) -> None:
|
| 47 |
+
"""Build index dari corpus. Overwrite existing state."""
|
| 48 |
+
|
| 49 |
+
@abstractmethod
|
| 50 |
+
def query(self, q: str, top_k: int = 10) -> list[SearchHit]:
|
| 51 |
+
"""Run query, return top-K hits sorted by score DESC."""
|
| 52 |
+
|
| 53 |
+
@abstractmethod
|
| 54 |
+
def save(self, path: Path) -> None:
|
| 55 |
+
"""Serialize index ke disk."""
|
| 56 |
+
|
| 57 |
+
@classmethod
|
| 58 |
+
@abstractmethod
|
| 59 |
+
def load(cls, path: Path) -> "IndexBase":
|
| 60 |
+
"""Deserialize index dari disk."""
|
| 61 |
+
|
| 62 |
+
def size(self) -> int:
|
| 63 |
+
"""Jumlah dokumen yang sudah di-index."""
|
| 64 |
+
return getattr(self, "_size", 0)
|
backend/app/indexing/bm25.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BM25 (Best Matching 25) index via rank_bm25.
|
| 2 |
+
|
| 3 |
+
Hyperparameter default sesuai standar literature:
|
| 4 |
+
- k1=1.5: term frequency saturation parameter
|
| 5 |
+
- b=0.75: length normalization parameter
|
| 6 |
+
|
| 7 |
+
Tim Anggota C: experiment k1 in [1.2, 2.0] dan b in [0.5, 1.0] untuk lihat
|
| 8 |
+
sensitivity. Biasanya BM25 beat TF-IDF tipis (~5-10% MAP) di Indonesian corpus.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import pickle
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
from rank_bm25 import BM25Okapi
|
| 19 |
+
|
| 20 |
+
from .base import Document, IndexBase, SearchHit
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class BM25Index(IndexBase):
|
| 24 |
+
name = "bm25"
|
| 25 |
+
|
| 26 |
+
def __init__(self, k1: float = 1.5, b: float = 0.75):
|
| 27 |
+
self.k1 = k1
|
| 28 |
+
self.b = b
|
| 29 |
+
self.bm25: Optional[BM25Okapi] = None
|
| 30 |
+
self.doc_ids: list[str] = []
|
| 31 |
+
self.tokenized_corpus: list[list[str]] = []
|
| 32 |
+
self._size = 0
|
| 33 |
+
|
| 34 |
+
def build(self, corpus: list[Document]) -> None:
|
| 35 |
+
self.doc_ids = [doc.id for doc in corpus]
|
| 36 |
+
# rank_bm25 minta list of token lists
|
| 37 |
+
self.tokenized_corpus = [doc.text.split() for doc in corpus]
|
| 38 |
+
self.bm25 = BM25Okapi(self.tokenized_corpus, k1=self.k1, b=self.b)
|
| 39 |
+
self._size = len(corpus)
|
| 40 |
+
|
| 41 |
+
def query(self, q: str, top_k: int = 10) -> list[SearchHit]:
|
| 42 |
+
if self.bm25 is None or self._size == 0:
|
| 43 |
+
return []
|
| 44 |
+
q_tokens = q.split()
|
| 45 |
+
scores = self.bm25.get_scores(q_tokens)
|
| 46 |
+
top_k = min(top_k, self._size)
|
| 47 |
+
top_indices = np.argpartition(-scores, top_k - 1)[:top_k]
|
| 48 |
+
top_indices = top_indices[np.argsort(-scores[top_indices])]
|
| 49 |
+
return [
|
| 50 |
+
SearchHit(
|
| 51 |
+
doc_id=self.doc_ids[idx],
|
| 52 |
+
score=float(scores[idx]),
|
| 53 |
+
rank=rank + 1,
|
| 54 |
+
)
|
| 55 |
+
for rank, idx in enumerate(top_indices)
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
def save(self, path: Path) -> None:
|
| 59 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
with open(path, "wb") as f:
|
| 61 |
+
pickle.dump(
|
| 62 |
+
{
|
| 63 |
+
"k1": self.k1,
|
| 64 |
+
"b": self.b,
|
| 65 |
+
"doc_ids": self.doc_ids,
|
| 66 |
+
"tokenized_corpus": self.tokenized_corpus,
|
| 67 |
+
"size": self._size,
|
| 68 |
+
},
|
| 69 |
+
f,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
@classmethod
|
| 73 |
+
def load(cls, path: Path) -> "BM25Index":
|
| 74 |
+
with open(path, "rb") as f:
|
| 75 |
+
data = pickle.load(f)
|
| 76 |
+
instance = cls(k1=data["k1"], b=data["b"])
|
| 77 |
+
instance.doc_ids = data["doc_ids"]
|
| 78 |
+
instance.tokenized_corpus = data["tokenized_corpus"]
|
| 79 |
+
instance._size = data["size"]
|
| 80 |
+
# Rebuild BM25Okapi (gak bisa di-pickle reliably across versions)
|
| 81 |
+
instance.bm25 = BM25Okapi(
|
| 82 |
+
instance.tokenized_corpus, k1=instance.k1, b=instance.b
|
| 83 |
+
)
|
| 84 |
+
return instance
|
backend/app/indexing/build.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLI builder untuk semua IR indexes.
|
| 2 |
+
|
| 3 |
+
Workflow:
|
| 4 |
+
# 1. Setelah scraping selesai dan preprocessing dijalankan:
|
| 5 |
+
# data/processed/corpus.json berisi list of {id, text, raw_text, metadata}
|
| 6 |
+
|
| 7 |
+
# 2. Build semua index sekaligus
|
| 8 |
+
python -m app.indexing.build \\
|
| 9 |
+
--corpus ../data/processed/corpus.json \\
|
| 10 |
+
--output-dir ../data/indexes
|
| 11 |
+
|
| 12 |
+
# 3. Output struktur:
|
| 13 |
+
# data/indexes/tfidf.pkl
|
| 14 |
+
# data/indexes/bm25.pkl
|
| 15 |
+
# data/indexes/indobert/embeddings.npy
|
| 16 |
+
# data/indexes/indobert/faiss.index
|
| 17 |
+
# data/indexes/indobert/meta.json
|
| 18 |
+
|
| 19 |
+
Tiap index loadable independen di FastAPI lifespan startup (lihat loader.py).
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import json
|
| 26 |
+
import sys
|
| 27 |
+
import time
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
from loguru import logger
|
| 31 |
+
|
| 32 |
+
from .base import Document
|
| 33 |
+
from .bm25 import BM25Index
|
| 34 |
+
from .indobert import IndoBERTIndex
|
| 35 |
+
from .tfidf import TFIDFIndex
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def load_corpus(path: Path) -> list[Document]:
|
| 39 |
+
"""Load corpus JSON: expect list of {id, text, raw_text?, metadata?}."""
|
| 40 |
+
with open(path, encoding="utf-8") as f:
|
| 41 |
+
raw = json.load(f)
|
| 42 |
+
return [
|
| 43 |
+
Document(
|
| 44 |
+
id=item["id"],
|
| 45 |
+
text=item["text"],
|
| 46 |
+
raw_text=item.get("raw_text"),
|
| 47 |
+
metadata=item.get("metadata", {}),
|
| 48 |
+
)
|
| 49 |
+
for item in raw
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def main() -> int:
|
| 54 |
+
parser = argparse.ArgumentParser(description="Build IR indexes dari corpus")
|
| 55 |
+
parser.add_argument(
|
| 56 |
+
"--corpus", type=Path, required=True,
|
| 57 |
+
help="Path ke corpus.json (output preprocessing)",
|
| 58 |
+
)
|
| 59 |
+
parser.add_argument(
|
| 60 |
+
"--output-dir", type=Path, required=True,
|
| 61 |
+
help="Folder output indexes (e.g., ../data/indexes)",
|
| 62 |
+
)
|
| 63 |
+
parser.add_argument(
|
| 64 |
+
"--skip", nargs="*", default=[],
|
| 65 |
+
choices=["tfidf", "bm25", "indobert"],
|
| 66 |
+
help="Index yang di-skip (untuk debug)",
|
| 67 |
+
)
|
| 68 |
+
parser.add_argument(
|
| 69 |
+
"--indobert-model",
|
| 70 |
+
default="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
|
| 71 |
+
help="Sentence-transformers model name (default: MiniLM lightweight)",
|
| 72 |
+
)
|
| 73 |
+
parser.add_argument(
|
| 74 |
+
"--tfidf-ngram", nargs=2, type=int, default=[1, 2],
|
| 75 |
+
help="TF-IDF ngram range (default: 1 2)",
|
| 76 |
+
)
|
| 77 |
+
parser.add_argument("--bm25-k1", type=float, default=1.5)
|
| 78 |
+
parser.add_argument("--bm25-b", type=float, default=0.75)
|
| 79 |
+
args = parser.parse_args()
|
| 80 |
+
|
| 81 |
+
logger.info(f"[load corpus] {args.corpus}")
|
| 82 |
+
corpus = load_corpus(args.corpus)
|
| 83 |
+
logger.info(f"[corpus] {len(corpus)} documents")
|
| 84 |
+
|
| 85 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 86 |
+
|
| 87 |
+
# ---- TF-IDF ----
|
| 88 |
+
if "tfidf" not in args.skip:
|
| 89 |
+
logger.info("[build TF-IDF]")
|
| 90 |
+
t0 = time.time()
|
| 91 |
+
tfidf = TFIDFIndex(ngram_range=tuple(args.tfidf_ngram))
|
| 92 |
+
tfidf.build(corpus)
|
| 93 |
+
tfidf.save(args.output_dir / "tfidf.pkl")
|
| 94 |
+
logger.info(f"[TF-IDF] saved ({time.time() - t0:.1f}s)")
|
| 95 |
+
|
| 96 |
+
# ---- BM25 ----
|
| 97 |
+
if "bm25" not in args.skip:
|
| 98 |
+
logger.info("[build BM25]")
|
| 99 |
+
t0 = time.time()
|
| 100 |
+
bm25 = BM25Index(k1=args.bm25_k1, b=args.bm25_b)
|
| 101 |
+
bm25.build(corpus)
|
| 102 |
+
bm25.save(args.output_dir / "bm25.pkl")
|
| 103 |
+
logger.info(f"[BM25] saved ({time.time() - t0:.1f}s)")
|
| 104 |
+
|
| 105 |
+
# ---- IndoBERT + FAISS ----
|
| 106 |
+
if "indobert" not in args.skip:
|
| 107 |
+
logger.info(f"[build IndoBERT] model={args.indobert_model}")
|
| 108 |
+
t0 = time.time()
|
| 109 |
+
indobert = IndoBERTIndex(model_name=args.indobert_model)
|
| 110 |
+
indobert.build(corpus)
|
| 111 |
+
indobert.save(args.output_dir / "indobert")
|
| 112 |
+
logger.info(f"[IndoBERT] saved ({time.time() - t0:.1f}s)")
|
| 113 |
+
|
| 114 |
+
logger.info(f"[done] all indexes saved ke {args.output_dir}")
|
| 115 |
+
return 0
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
sys.exit(main())
|
backend/app/indexing/hybrid.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hybrid retrieval: BM25 candidates -> IndoBERT rerank.
|
| 2 |
+
|
| 3 |
+
Strategy:
|
| 4 |
+
1. BM25 fetch top-N candidates (default N=50). BM25 cepat dan lexical-aware,
|
| 5 |
+
bagus buat narrow down dari 1500-3000 docs ke 50.
|
| 6 |
+
2. IndoBERT score 50 candidates dengan pre-computed embeddings (no re-encode).
|
| 7 |
+
3. Combine score: `final = alpha * bm25_norm + (1 - alpha) * indobert_norm`,
|
| 8 |
+
atau pure rerank by IndoBERT (alpha=0).
|
| 9 |
+
4. Return top-K (default K=10).
|
| 10 |
+
|
| 11 |
+
Tim Anggota D: experiment alpha in [0.0, 0.3, 0.5, 0.7] untuk lihat balance
|
| 12 |
+
yang optimal. Pure rerank (alpha=0) sering kompetitif untuk Indonesian
|
| 13 |
+
queries yang semantik.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Callable, Optional
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
from .base import Document, IndexBase, SearchHit
|
| 24 |
+
from .bm25 import BM25Index
|
| 25 |
+
from .indobert import IndoBERTIndex
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class HybridIndex(IndexBase):
|
| 29 |
+
name = "hybrid"
|
| 30 |
+
|
| 31 |
+
def __init__(
|
| 32 |
+
self,
|
| 33 |
+
bm25: BM25Index,
|
| 34 |
+
indobert: IndoBERTIndex,
|
| 35 |
+
bm25_top_k: int = 50,
|
| 36 |
+
# alpha 0.9 dari grid search pool-restricted n=30 (eval/
|
| 37 |
+
# hybrid_alpha_grid_pool.csv): kurva flat 0.6-0.9, puncak 0.9
|
| 38 |
+
# (MAP 0.5909 vs BM25 murni 0.5836; selisih kecil, inconclusive).
|
| 39 |
+
# Default lama 0.3 menyeret hybrid jatuh di standard eval
|
| 40 |
+
# (MAP 0.188 vs 0.267 di alpha 0.9) karena GT lexical-pooled.
|
| 41 |
+
alpha: float = 0.9,
|
| 42 |
+
query_preprocessor: Optional[Callable[[str], str]] = None,
|
| 43 |
+
):
|
| 44 |
+
"""Args:
|
| 45 |
+
bm25: BM25Index yang sudah di-build
|
| 46 |
+
indobert: IndoBERTIndex yang sudah di-build
|
| 47 |
+
bm25_top_k: jumlah candidate dari BM25 untuk re-rank (default 50)
|
| 48 |
+
alpha: weight untuk BM25 score di final combination.
|
| 49 |
+
alpha=0 -> pure IndoBERT rerank, alpha=1 -> pure BM25.
|
| 50 |
+
query_preprocessor: callable(q_raw) -> q_processed; dipakai untuk BM25
|
| 51 |
+
karena BM25 perlu teks ter-preprocess (stemmed). IndoBERT pakai
|
| 52 |
+
raw query (natural language). Kalau None, q dipakai untuk
|
| 53 |
+
keduanya (backwards-compat untuk pemanggil yang sudah preprocess).
|
| 54 |
+
"""
|
| 55 |
+
self.bm25 = bm25
|
| 56 |
+
self.indobert = indobert
|
| 57 |
+
self.bm25_top_k = bm25_top_k
|
| 58 |
+
self.alpha = alpha
|
| 59 |
+
self.query_preprocessor = query_preprocessor
|
| 60 |
+
self._size = bm25.size()
|
| 61 |
+
|
| 62 |
+
def build(self, corpus: list[Document]) -> None:
|
| 63 |
+
"""Hybrid = composition; sub-indexes di-build separately."""
|
| 64 |
+
# Tetap track size kalau dipanggil
|
| 65 |
+
self._size = len(corpus)
|
| 66 |
+
# NOTE: caller harus pastikan bm25 & indobert sudah di-build sebelum query.
|
| 67 |
+
|
| 68 |
+
def query(self, q: str, top_k: int = 10) -> list[SearchHit]:
|
| 69 |
+
# 1. BM25 candidates — pakai processed query (stemmed lexical match)
|
| 70 |
+
q_for_bm25 = self.query_preprocessor(q) if self.query_preprocessor else q
|
| 71 |
+
bm25_hits = self.bm25.query(q_for_bm25, top_k=self.bm25_top_k)
|
| 72 |
+
if not bm25_hits:
|
| 73 |
+
return []
|
| 74 |
+
candidate_ids = [h.doc_id for h in bm25_hits]
|
| 75 |
+
bm25_scores_raw = np.array([h.score for h in bm25_hits])
|
| 76 |
+
|
| 77 |
+
# 2. IndoBERT score candidates pakai RAW query (semantic embedding)
|
| 78 |
+
q_emb = self.indobert.encode_query(q)
|
| 79 |
+
indobert_scores_pairs = self.indobert.score_docs(q_emb, candidate_ids)
|
| 80 |
+
indobert_scores_map = dict(indobert_scores_pairs)
|
| 81 |
+
|
| 82 |
+
# Align IndoBERT scores dengan order BM25 candidates
|
| 83 |
+
indobert_scores_raw = np.array(
|
| 84 |
+
[indobert_scores_map.get(doc_id, 0.0) for doc_id in candidate_ids]
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# 3. Min-max normalize tiap score list (skala [0, 1])
|
| 88 |
+
bm25_norm = self._minmax_normalize(bm25_scores_raw)
|
| 89 |
+
indobert_norm = self._minmax_normalize(indobert_scores_raw)
|
| 90 |
+
|
| 91 |
+
# 4. Combine
|
| 92 |
+
combined = self.alpha * bm25_norm + (1 - self.alpha) * indobert_norm
|
| 93 |
+
|
| 94 |
+
# 5. Sort + top-K
|
| 95 |
+
sorted_idx = np.argsort(-combined)[:top_k]
|
| 96 |
+
return [
|
| 97 |
+
SearchHit(
|
| 98 |
+
doc_id=candidate_ids[idx],
|
| 99 |
+
score=float(combined[idx]),
|
| 100 |
+
rank=rank + 1,
|
| 101 |
+
)
|
| 102 |
+
for rank, idx in enumerate(sorted_idx)
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
@staticmethod
|
| 106 |
+
def _minmax_normalize(scores: np.ndarray) -> np.ndarray:
|
| 107 |
+
"""Min-max normalize ke [0, 1]. Kalau semua sama, return zeros."""
|
| 108 |
+
if len(scores) == 0:
|
| 109 |
+
return scores
|
| 110 |
+
s_min, s_max = scores.min(), scores.max()
|
| 111 |
+
if s_max - s_min < 1e-9:
|
| 112 |
+
return np.zeros_like(scores)
|
| 113 |
+
return (scores - s_min) / (s_max - s_min)
|
| 114 |
+
|
| 115 |
+
def save(self, path: Path) -> None:
|
| 116 |
+
"""Hybrid bukan index murni — sub-indexes save separately."""
|
| 117 |
+
raise NotImplementedError(
|
| 118 |
+
"HybridIndex composition; save bm25 & indobert separately. "
|
| 119 |
+
"Hybrid restore via constructor dari sub-indexes yang sudah di-load."
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
@classmethod
|
| 123 |
+
def load(cls, path: Path) -> "HybridIndex":
|
| 124 |
+
raise NotImplementedError(
|
| 125 |
+
"HybridIndex composition; load bm25 & indobert separately, "
|
| 126 |
+
"lalu instantiate HybridIndex(bm25, indobert)."
|
| 127 |
+
)
|
backend/app/indexing/indobert.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Neural / semantic index pakai sentence-transformers + FAISS.
|
| 2 |
+
|
| 3 |
+
Default model: `paraphrase-multilingual-MiniLM-L12-v2` (~118 MB, multilingual,
|
| 4 |
+
inferensi cepat di CPU). Untuk hasil lebih bagus tapi heavy: ganti ke
|
| 5 |
+
`indobenchmark/indobert-base-p2` (~440 MB).
|
| 6 |
+
|
| 7 |
+
FAISS IndexFlatIP = inner product. Karena embeddings sudah L2-normalized
|
| 8 |
+
(via `normalize_embeddings=True`), inner product == cosine similarity.
|
| 9 |
+
|
| 10 |
+
Tim Anggota D: experiment di notebook 03_model_comparison.ipynb:
|
| 11 |
+
- Compare MiniLM (small/fast) vs IndoBERT-base (better quality)
|
| 12 |
+
- Pooling strategy: mean-pool (default) vs CLS token
|
| 13 |
+
- FAISS Flat (exhaustive) vs IVF (clustered, faster tapi approximate)
|
| 14 |
+
|
| 15 |
+
Untuk corpus <= 5K docs, Flat sudah cukup (sub-ms query).
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import pickle
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Optional
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
from .base import Document, IndexBase, SearchHit
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class IndoBERTIndex(IndexBase):
|
| 31 |
+
name = "indobert"
|
| 32 |
+
|
| 33 |
+
def __init__(
|
| 34 |
+
self,
|
| 35 |
+
model_name: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
|
| 36 |
+
normalize_embeddings: bool = True,
|
| 37 |
+
batch_size: int = 32,
|
| 38 |
+
):
|
| 39 |
+
self.model_name = model_name
|
| 40 |
+
self.normalize_embeddings = normalize_embeddings
|
| 41 |
+
self.batch_size = batch_size
|
| 42 |
+
self.embeddings: Optional[np.ndarray] = None # (N, dim) float32
|
| 43 |
+
self.index = None # faiss.IndexFlatIP
|
| 44 |
+
self.doc_ids: list[str] = []
|
| 45 |
+
self.id_to_idx: dict[str, int] = {}
|
| 46 |
+
self._size = 0
|
| 47 |
+
self._model = None # Lazy load
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def model(self):
|
| 51 |
+
"""Lazy load fastembed TextEmbedding (ONNX runtime).
|
| 52 |
+
|
| 53 |
+
Trade-off vs sentence-transformers + torch:
|
| 54 |
+
- fastembed: ~150MB total RAM (Render free tier compatible)
|
| 55 |
+
- sentence-transformers + torch: ~500MB total (OOM di 512MB Render)
|
| 56 |
+
- Same underlying MiniLM model, numerical results virtually identical
|
| 57 |
+
- Faster init: ~1-2s vs 10-30s untuk first encode
|
| 58 |
+
"""
|
| 59 |
+
if self._model is None:
|
| 60 |
+
import os
|
| 61 |
+
|
| 62 |
+
from fastembed import TextEmbedding
|
| 63 |
+
|
| 64 |
+
# cache_dir eksplisit (FASTEMBED_CACHE_PATH) supaya model ONNX yang
|
| 65 |
+
# di-pre-download saat docker build ke-reuse di runtime — tanpa ini
|
| 66 |
+
# tiap cold start download ulang ~120MB.
|
| 67 |
+
cache_dir = os.getenv("FASTEMBED_CACHE_PATH")
|
| 68 |
+
self._model = TextEmbedding(self.model_name, cache_dir=cache_dir)
|
| 69 |
+
return self._model
|
| 70 |
+
|
| 71 |
+
def _encode(self, texts: list[str]) -> "np.ndarray":
|
| 72 |
+
"""Encode texts via fastembed + L2-normalize untuk FAISS IndexFlatIP."""
|
| 73 |
+
import numpy as np
|
| 74 |
+
|
| 75 |
+
embeddings = list(self.model.embed(texts))
|
| 76 |
+
arr = np.vstack(embeddings).astype("float32")
|
| 77 |
+
if self.normalize_embeddings:
|
| 78 |
+
norms = np.linalg.norm(arr, axis=1, keepdims=True)
|
| 79 |
+
norms = np.where(norms > 0, norms, 1.0)
|
| 80 |
+
arr = arr / norms
|
| 81 |
+
return arr
|
| 82 |
+
|
| 83 |
+
def build(self, corpus: list[Document]) -> None:
|
| 84 |
+
import faiss
|
| 85 |
+
|
| 86 |
+
texts = [(doc.raw_text or doc.text) for doc in corpus]
|
| 87 |
+
embeddings = self._encode(texts)
|
| 88 |
+
self.embeddings = embeddings
|
| 89 |
+
# FAISS IndexFlatIP — exhaustive cosine via inner product
|
| 90 |
+
dim = embeddings.shape[1]
|
| 91 |
+
self.index = faiss.IndexFlatIP(dim)
|
| 92 |
+
self.index.add(embeddings)
|
| 93 |
+
self.doc_ids = [doc.id for doc in corpus]
|
| 94 |
+
self.id_to_idx = {doc_id: i for i, doc_id in enumerate(self.doc_ids)}
|
| 95 |
+
self._size = len(corpus)
|
| 96 |
+
|
| 97 |
+
def encode_query(self, q: str) -> np.ndarray:
|
| 98 |
+
"""Encode query string ke embedding (via fastembed ONNX).
|
| 99 |
+
|
| 100 |
+
Returns shape (1, dim) — satu baris, bukan flat (dim,).
|
| 101 |
+
FAISS index.search dan score_docs keduanya mengharapkan (1, dim).
|
| 102 |
+
Jangan lakukan dot-product langsung tanpa .T transpose.
|
| 103 |
+
"""
|
| 104 |
+
return self._encode([q])
|
| 105 |
+
|
| 106 |
+
def score_docs(
|
| 107 |
+
self, q_emb: np.ndarray, doc_ids: list[str]
|
| 108 |
+
) -> list[tuple[str, float]]:
|
| 109 |
+
"""Score subset doc_ids vs query embedding using pre-computed embeddings.
|
| 110 |
+
|
| 111 |
+
Dipakai oleh HybridIndex untuk rerank candidate dari BM25 tanpa
|
| 112 |
+
re-encode dokumen.
|
| 113 |
+
"""
|
| 114 |
+
if self.embeddings is None:
|
| 115 |
+
return []
|
| 116 |
+
indices = [self.id_to_idx[d] for d in doc_ids if d in self.id_to_idx]
|
| 117 |
+
subset = self.embeddings[indices]
|
| 118 |
+
# Inner product (cosine kalau normalized)
|
| 119 |
+
scores = (subset @ q_emb.T).flatten()
|
| 120 |
+
return [
|
| 121 |
+
(self.doc_ids[idx], float(score))
|
| 122 |
+
for idx, score in zip(indices, scores)
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
def query(self, q: str, top_k: int = 10) -> list[SearchHit]:
|
| 126 |
+
if self.index is None or self._size == 0:
|
| 127 |
+
return []
|
| 128 |
+
q_emb = self.encode_query(q)
|
| 129 |
+
top_k = min(top_k, self._size)
|
| 130 |
+
scores, indices = self.index.search(q_emb, top_k)
|
| 131 |
+
return [
|
| 132 |
+
SearchHit(
|
| 133 |
+
doc_id=self.doc_ids[idx],
|
| 134 |
+
score=float(scores[0][rank]),
|
| 135 |
+
rank=rank + 1,
|
| 136 |
+
)
|
| 137 |
+
for rank, idx in enumerate(indices[0])
|
| 138 |
+
if idx >= 0 # FAISS pakai -1 untuk padding kalau result < top_k
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
def save(self, path: Path) -> None:
|
| 142 |
+
"""Save: embeddings.npy + faiss.index + meta.json di folder `path`."""
|
| 143 |
+
import faiss
|
| 144 |
+
|
| 145 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 146 |
+
np.save(path / "embeddings.npy", self.embeddings)
|
| 147 |
+
faiss.write_index(self.index, str(path / "faiss.index"))
|
| 148 |
+
with open(path / "meta.json", "w", encoding="utf-8") as f:
|
| 149 |
+
json.dump(
|
| 150 |
+
{
|
| 151 |
+
"model_name": self.model_name,
|
| 152 |
+
"normalize_embeddings": self.normalize_embeddings,
|
| 153 |
+
"batch_size": self.batch_size,
|
| 154 |
+
"doc_ids": self.doc_ids,
|
| 155 |
+
"size": self._size,
|
| 156 |
+
},
|
| 157 |
+
f,
|
| 158 |
+
ensure_ascii=False,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
@classmethod
|
| 162 |
+
def load(cls, path: Path) -> "IndoBERTIndex":
|
| 163 |
+
"""Load dari folder yang berisi embeddings.npy + faiss.index + meta.json."""
|
| 164 |
+
import faiss
|
| 165 |
+
|
| 166 |
+
with open(path / "meta.json", encoding="utf-8") as f:
|
| 167 |
+
meta = json.load(f)
|
| 168 |
+
instance = cls(
|
| 169 |
+
model_name=meta["model_name"],
|
| 170 |
+
normalize_embeddings=meta["normalize_embeddings"],
|
| 171 |
+
batch_size=meta["batch_size"],
|
| 172 |
+
)
|
| 173 |
+
instance.embeddings = np.load(path / "embeddings.npy")
|
| 174 |
+
instance.index = faiss.read_index(str(path / "faiss.index"))
|
| 175 |
+
instance.doc_ids = meta["doc_ids"]
|
| 176 |
+
instance.id_to_idx = {d: i for i, d in enumerate(instance.doc_ids)}
|
| 177 |
+
instance._size = meta["size"]
|
| 178 |
+
return instance
|
backend/app/indexing/loader.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Loader helper untuk FastAPI lifespan startup.
|
| 2 |
+
|
| 3 |
+
Load semua indexes dari disk sekali saat backend startup, store di
|
| 4 |
+
`app.state` untuk reuse di route handlers.
|
| 5 |
+
|
| 6 |
+
Usage di app/main.py:
|
| 7 |
+
|
| 8 |
+
from app.indexing.loader import load_all_indexes
|
| 9 |
+
|
| 10 |
+
@asynccontextmanager
|
| 11 |
+
async def lifespan(app: FastAPI):
|
| 12 |
+
indexes = load_all_indexes(Path(settings.indexes_dir))
|
| 13 |
+
app.state.tfidf = indexes["tfidf"]
|
| 14 |
+
app.state.bm25 = indexes["bm25"]
|
| 15 |
+
app.state.indobert = indexes["indobert"]
|
| 16 |
+
app.state.hybrid = HybridIndex(indexes["bm25"], indexes["indobert"])
|
| 17 |
+
yield
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Optional
|
| 24 |
+
|
| 25 |
+
from loguru import logger
|
| 26 |
+
|
| 27 |
+
from .bm25 import BM25Index
|
| 28 |
+
from .tfidf import TFIDFIndex
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def load_all_indexes(
|
| 32 |
+
indexes_dir: Path, include_neural: bool = True
|
| 33 |
+
) -> dict[str, object]:
|
| 34 |
+
"""Load semua indexes dari folder. Return dict {name: index_instance}.
|
| 35 |
+
|
| 36 |
+
Missing index logged sebagai warning, tidak raise (supaya partial deploy OK).
|
| 37 |
+
`include_neural=False` melewati IndoBERT supaya runtime production tidak
|
| 38 |
+
perlu import faiss/fastembed (hemat RAM).
|
| 39 |
+
"""
|
| 40 |
+
result: dict[str, object] = {}
|
| 41 |
+
|
| 42 |
+
tfidf_path = indexes_dir / "tfidf.pkl"
|
| 43 |
+
if tfidf_path.exists():
|
| 44 |
+
logger.info(f"[load] TF-IDF dari {tfidf_path}")
|
| 45 |
+
result["tfidf"] = TFIDFIndex.load(tfidf_path)
|
| 46 |
+
else:
|
| 47 |
+
logger.warning(f"[skip] TF-IDF tidak ditemukan di {tfidf_path}")
|
| 48 |
+
|
| 49 |
+
bm25_path = indexes_dir / "bm25.pkl"
|
| 50 |
+
if bm25_path.exists():
|
| 51 |
+
logger.info(f"[load] BM25 dari {bm25_path}")
|
| 52 |
+
result["bm25"] = BM25Index.load(bm25_path)
|
| 53 |
+
else:
|
| 54 |
+
logger.warning(f"[skip] BM25 tidak ditemukan di {bm25_path}")
|
| 55 |
+
|
| 56 |
+
indobert_path = indexes_dir / "indobert"
|
| 57 |
+
if not include_neural:
|
| 58 |
+
logger.info("[skip] IndoBERT dilewati (enable_neural=False)")
|
| 59 |
+
elif indobert_path.exists() and indobert_path.is_dir():
|
| 60 |
+
from .indobert import IndoBERTIndex # lazy: hindari import faiss saat off
|
| 61 |
+
|
| 62 |
+
logger.info(f"[load] IndoBERT dari {indobert_path}")
|
| 63 |
+
result["indobert"] = IndoBERTIndex.load(indobert_path)
|
| 64 |
+
else:
|
| 65 |
+
logger.warning(f"[skip] IndoBERT tidak ditemukan di {indobert_path}")
|
| 66 |
+
|
| 67 |
+
logger.info(f"[load] {len(result)} indexes loaded: {list(result.keys())}")
|
| 68 |
+
return result
|
backend/app/indexing/tfidf.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TF-IDF + cosine similarity index (scikit-learn).
|
| 2 |
+
|
| 3 |
+
Hyperparameter default:
|
| 4 |
+
- ngram_range=(1, 2): unigram + bigram (capture compound terms seperti
|
| 5 |
+
"kamar mandi", "air panas")
|
| 6 |
+
- min_df=2: drop term yang muncul cuma sekali (likely typo/noise)
|
| 7 |
+
- max_features=10000: limit vocab size untuk memory + speed
|
| 8 |
+
|
| 9 |
+
Tim Anggota C: experiment dengan hyperparameter di
|
| 10 |
+
notebooks/03_model_comparison.ipynb. Pada corpus real 227 listing, TF-IDF
|
| 11 |
+
MAP ~0.24 (standard) / ~0.59 (pool-restricted) — kompetitif dengan BM25.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import pickle
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 22 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 23 |
+
|
| 24 |
+
from .base import Document, IndexBase, SearchHit
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TFIDFIndex(IndexBase):
|
| 28 |
+
name = "tfidf"
|
| 29 |
+
|
| 30 |
+
def __init__(
|
| 31 |
+
self,
|
| 32 |
+
ngram_range: tuple[int, int] = (1, 2),
|
| 33 |
+
min_df: int = 2,
|
| 34 |
+
max_features: int = 10000,
|
| 35 |
+
):
|
| 36 |
+
self.vectorizer = TfidfVectorizer(
|
| 37 |
+
ngram_range=ngram_range,
|
| 38 |
+
min_df=min_df,
|
| 39 |
+
max_features=max_features,
|
| 40 |
+
sublinear_tf=True, # log(1+tf) — handle high-tf terms
|
| 41 |
+
)
|
| 42 |
+
self.doc_matrix = None # sparse matrix (N, vocab_size)
|
| 43 |
+
self.doc_ids: list[str] = []
|
| 44 |
+
self._size = 0
|
| 45 |
+
|
| 46 |
+
def build(self, corpus: list[Document]) -> None:
|
| 47 |
+
texts = [doc.text for doc in corpus]
|
| 48 |
+
self.doc_ids = [doc.id for doc in corpus]
|
| 49 |
+
self.doc_matrix = self.vectorizer.fit_transform(texts)
|
| 50 |
+
self._size = len(corpus)
|
| 51 |
+
|
| 52 |
+
def query(self, q: str, top_k: int = 10) -> list[SearchHit]:
|
| 53 |
+
if self.doc_matrix is None or self._size == 0:
|
| 54 |
+
return []
|
| 55 |
+
q_vec = self.vectorizer.transform([q])
|
| 56 |
+
# Cosine similarity (since TF-IDF vectors already L2-normalized by default)
|
| 57 |
+
scores = cosine_similarity(q_vec, self.doc_matrix).flatten()
|
| 58 |
+
# Top-K indices (descending)
|
| 59 |
+
top_k = min(top_k, self._size)
|
| 60 |
+
top_indices = np.argpartition(-scores, top_k - 1)[:top_k]
|
| 61 |
+
# Sort within top-K
|
| 62 |
+
top_indices = top_indices[np.argsort(-scores[top_indices])]
|
| 63 |
+
return [
|
| 64 |
+
SearchHit(
|
| 65 |
+
doc_id=self.doc_ids[idx],
|
| 66 |
+
score=float(scores[idx]),
|
| 67 |
+
rank=rank + 1,
|
| 68 |
+
)
|
| 69 |
+
for rank, idx in enumerate(top_indices)
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
def save(self, path: Path) -> None:
|
| 73 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 74 |
+
with open(path, "wb") as f:
|
| 75 |
+
pickle.dump(
|
| 76 |
+
{
|
| 77 |
+
"vectorizer": self.vectorizer,
|
| 78 |
+
"doc_matrix": self.doc_matrix,
|
| 79 |
+
"doc_ids": self.doc_ids,
|
| 80 |
+
"size": self._size,
|
| 81 |
+
},
|
| 82 |
+
f,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
@classmethod
|
| 86 |
+
def load(cls, path: Path) -> "TFIDFIndex":
|
| 87 |
+
with open(path, "rb") as f:
|
| 88 |
+
data = pickle.load(f)
|
| 89 |
+
instance = cls.__new__(cls)
|
| 90 |
+
instance.vectorizer = data["vectorizer"]
|
| 91 |
+
instance.doc_matrix = data["doc_matrix"]
|
| 92 |
+
instance.doc_ids = data["doc_ids"]
|
| 93 |
+
instance._size = data["size"]
|
| 94 |
+
return instance
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI entry point untuk KozyNear search engine.
|
| 2 |
+
|
| 3 |
+
Architecture: single-container Docker. FastAPI serve:
|
| 4 |
+
- React SPA dari `/app/static/` (built dari frontend/)
|
| 5 |
+
- API endpoints di `/api/*`
|
| 6 |
+
- Health check di `/health` (Render uses this)
|
| 7 |
+
- Swagger UI di `/api/docs`
|
| 8 |
+
|
| 9 |
+
Routing order (FastAPI matches in registration order):
|
| 10 |
+
1. Specific API routes: /health, /api/info, /api/search, /api/listings/{id}, /api/eval/*
|
| 11 |
+
2. /api/docs, /api/openapi.json (FastAPI auto-generated)
|
| 12 |
+
3. /assets/* (Vite build output)
|
| 13 |
+
4. Catch-all /{path} -> serve index.html for SPA routing (React Router fallback)
|
| 14 |
+
|
| 15 |
+
Lifespan:
|
| 16 |
+
- Startup: load preprocessing pipeline + IR indexes dari disk
|
| 17 |
+
- Shutdown: cleanup
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from contextlib import asynccontextmanager
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
from fastapi import FastAPI, HTTPException
|
| 24 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 25 |
+
from fastapi.responses import FileResponse
|
| 26 |
+
from fastapi.staticfiles import StaticFiles
|
| 27 |
+
from loguru import logger
|
| 28 |
+
|
| 29 |
+
from app.api.eval import router as eval_router
|
| 30 |
+
from app.api.health import router as health_router
|
| 31 |
+
from app.api.insights import router as insights_router
|
| 32 |
+
from app.api.listings import router as listings_router
|
| 33 |
+
from app.api.search import router as search_router
|
| 34 |
+
from app.core.config import settings
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@asynccontextmanager
|
| 38 |
+
async def lifespan(app: FastAPI):
|
| 39 |
+
"""Startup: load heavy resources. Shutdown: cleanup."""
|
| 40 |
+
logger.info(f"[startup] KozyNear backend (env={settings.environment})")
|
| 41 |
+
|
| 42 |
+
# Initialize app.state slots
|
| 43 |
+
app.state.tfidf = None
|
| 44 |
+
app.state.bm25 = None
|
| 45 |
+
app.state.indobert = None
|
| 46 |
+
app.state.hybrid = None
|
| 47 |
+
app.state.preprocessing_pipeline = None
|
| 48 |
+
app.state.gazetteer = None
|
| 49 |
+
|
| 50 |
+
# 1. Preprocessing pipeline (Sastrawi factory init ~1s)
|
| 51 |
+
try:
|
| 52 |
+
from app.preprocessing import PreprocessingPipeline
|
| 53 |
+
|
| 54 |
+
app.state.preprocessing_pipeline = PreprocessingPipeline()
|
| 55 |
+
logger.info("[startup] preprocessing pipeline loaded")
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.error(f"[startup] preprocessing pipeline FAIL: {e}")
|
| 58 |
+
|
| 59 |
+
# 1b. Gazetteer (kampus + landmark) untuk smart search
|
| 60 |
+
try:
|
| 61 |
+
from app.search.gazetteer import Gazetteer
|
| 62 |
+
|
| 63 |
+
app.state.gazetteer = Gazetteer.load()
|
| 64 |
+
logger.info("[startup] gazetteer loaded")
|
| 65 |
+
except Exception as e:
|
| 66 |
+
app.state.gazetteer = None
|
| 67 |
+
logger.error(f"[startup] gazetteer FAIL: {e}")
|
| 68 |
+
|
| 69 |
+
# 2. IR indexes (graceful skip kalau missing)
|
| 70 |
+
try:
|
| 71 |
+
from app.indexing.loader import load_all_indexes
|
| 72 |
+
|
| 73 |
+
indexes_path = Path(settings.indexes_dir)
|
| 74 |
+
if indexes_path.exists():
|
| 75 |
+
indexes = load_all_indexes(
|
| 76 |
+
indexes_path, include_neural=settings.enable_neural
|
| 77 |
+
)
|
| 78 |
+
app.state.tfidf = indexes.get("tfidf")
|
| 79 |
+
app.state.bm25 = indexes.get("bm25")
|
| 80 |
+
app.state.indobert = indexes.get("indobert")
|
| 81 |
+
|
| 82 |
+
if app.state.bm25 is not None and app.state.indobert is not None:
|
| 83 |
+
from app.indexing.hybrid import HybridIndex
|
| 84 |
+
|
| 85 |
+
_pipeline = getattr(app.state, "preprocessing_pipeline", None)
|
| 86 |
+
app.state.hybrid = HybridIndex(
|
| 87 |
+
app.state.bm25,
|
| 88 |
+
app.state.indobert,
|
| 89 |
+
query_preprocessor=(
|
| 90 |
+
(lambda q: _pipeline.process(q).processed)
|
| 91 |
+
if _pipeline is not None else None
|
| 92 |
+
),
|
| 93 |
+
)
|
| 94 |
+
logger.info("[startup] hybrid index assembled")
|
| 95 |
+
|
| 96 |
+
# BACKGROUND prewarm IndoBERT model:
|
| 97 |
+
# - Index sudah loaded (embeddings.npy + faiss.index dari disk)
|
| 98 |
+
# - SentenceTransformer model load butuh ~10-30s di Render free tier CPU
|
| 99 |
+
# - SYNCHRONOUS prewarm di lifespan = block port binding (deploy fail)
|
| 100 |
+
# - LAZY load di first request = Render proxy timeout 30s -> 502
|
| 101 |
+
# - SOLUTION: spawn async background task setelah port binds
|
| 102 |
+
# - Sambil model load di background, /search?model=indobert return
|
| 103 |
+
# 503 dengan flag indobert_ready=False sampai task complete
|
| 104 |
+
app.state.indobert_ready = False
|
| 105 |
+
app.state.indobert_failed = False
|
| 106 |
+
if app.state.indobert is not None:
|
| 107 |
+
async def _bg_prewarm():
|
| 108 |
+
import asyncio
|
| 109 |
+
import time
|
| 110 |
+
try:
|
| 111 |
+
logger.info("[bg] starting IndoBERT model prewarm...")
|
| 112 |
+
t0 = time.perf_counter()
|
| 113 |
+
# Run blocking encode_query di thread pool supaya
|
| 114 |
+
# tidak block event loop
|
| 115 |
+
await asyncio.to_thread(
|
| 116 |
+
app.state.indobert.encode_query, "warmup",
|
| 117 |
+
)
|
| 118 |
+
elapsed = time.perf_counter() - t0
|
| 119 |
+
app.state.indobert_ready = True
|
| 120 |
+
logger.info(f"[bg] IndoBERT model warm ({elapsed:.1f}s)")
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.error(f"[bg] IndoBERT prewarm FAIL: {e}")
|
| 123 |
+
# Set ke None supaya graceful 503 di /search
|
| 124 |
+
app.state.indobert = None
|
| 125 |
+
app.state.hybrid = None
|
| 126 |
+
app.state.indobert_failed = True
|
| 127 |
+
|
| 128 |
+
import asyncio
|
| 129 |
+
asyncio.create_task(_bg_prewarm())
|
| 130 |
+
logger.info(
|
| 131 |
+
"[startup] IndoBERT prewarm scheduled di background; "
|
| 132 |
+
"/search?model=indobert akan return 503 sampai ready"
|
| 133 |
+
)
|
| 134 |
+
else:
|
| 135 |
+
logger.warning(
|
| 136 |
+
f"[startup] indexes_dir tidak ada: {indexes_path}. "
|
| 137 |
+
"Build dulu via `python -m app.indexing.build`."
|
| 138 |
+
)
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.error(f"[startup] index loading FAIL: {e}")
|
| 141 |
+
|
| 142 |
+
yield
|
| 143 |
+
logger.info("[shutdown] KozyNear backend stopping")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
app = FastAPI(
|
| 147 |
+
title="KozyNear API",
|
| 148 |
+
description=(
|
| 149 |
+
"Indonesian Kos-Kosan Search Engine -- UNILA STKI Final Project. "
|
| 150 |
+
"Compare 3 IR paradigms: TF-IDF, BM25, IndoBERT+FAISS, dan Hybrid."
|
| 151 |
+
),
|
| 152 |
+
version="0.1.0",
|
| 153 |
+
docs_url="/api/docs",
|
| 154 |
+
redoc_url="/api/redoc",
|
| 155 |
+
openapi_url="/api/openapi.json",
|
| 156 |
+
lifespan=lifespan,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# CORS: tetap aktifkan untuk dev (frontend di :5173, backend di :8000).
|
| 160 |
+
# Di production single-container, frontend & API same origin -> CORS no-op.
|
| 161 |
+
app.add_middleware(
|
| 162 |
+
CORSMiddleware,
|
| 163 |
+
allow_origins=settings.cors_origins,
|
| 164 |
+
allow_credentials=True,
|
| 165 |
+
allow_methods=["GET", "POST"],
|
| 166 |
+
allow_headers=["*"],
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
# ----------------------------------------------------------------------------
|
| 170 |
+
# Routes order: API dulu, lalu static catch-all
|
| 171 |
+
# ----------------------------------------------------------------------------
|
| 172 |
+
|
| 173 |
+
# Health check at /health (Render uses this)
|
| 174 |
+
app.include_router(health_router, tags=["health"])
|
| 175 |
+
|
| 176 |
+
# API endpoints under /api prefix
|
| 177 |
+
app.include_router(search_router, prefix="/api", tags=["search"])
|
| 178 |
+
app.include_router(listings_router, prefix="/api", tags=["listings"])
|
| 179 |
+
app.include_router(eval_router, prefix="/api") # eval_router internal prefix /eval
|
| 180 |
+
app.include_router(insights_router, prefix="/api", tags=["insights"])
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ----------------------------------------------------------------------------
|
| 184 |
+
# Static frontend (Docker build) -- serve dari /app/static
|
| 185 |
+
# ----------------------------------------------------------------------------
|
| 186 |
+
# Path discovery: di Docker container path absolute /app/static.
|
| 187 |
+
# Untuk local dev tanpa build, skip mount (frontend run terpisah via vite dev).
|
| 188 |
+
_DOCKER_STATIC = Path("/app/static")
|
| 189 |
+
_LOCAL_STATIC = Path(__file__).resolve().parent.parent.parent / "static"
|
| 190 |
+
|
| 191 |
+
STATIC_DIR: Path | None = None
|
| 192 |
+
if _DOCKER_STATIC.exists():
|
| 193 |
+
STATIC_DIR = _DOCKER_STATIC
|
| 194 |
+
elif _LOCAL_STATIC.exists():
|
| 195 |
+
STATIC_DIR = _LOCAL_STATIC
|
| 196 |
+
|
| 197 |
+
if STATIC_DIR is not None:
|
| 198 |
+
# Mount /assets/ untuk JS/CSS chunks (Vite outputs ke /assets/)
|
| 199 |
+
assets_dir = STATIC_DIR / "assets"
|
| 200 |
+
if assets_dir.exists():
|
| 201 |
+
app.mount(
|
| 202 |
+
"/assets",
|
| 203 |
+
StaticFiles(directory=str(assets_dir)),
|
| 204 |
+
name="assets",
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# Catch-all: serve real file kalau ada, else index.html (SPA fallback)
|
| 208 |
+
@app.get("/{full_path:path}", include_in_schema=False)
|
| 209 |
+
async def serve_spa(full_path: str):
|
| 210 |
+
# Security: prevent path traversal
|
| 211 |
+
candidate = (STATIC_DIR / full_path).resolve()
|
| 212 |
+
if not str(candidate).startswith(str(STATIC_DIR.resolve())):
|
| 213 |
+
raise HTTPException(403, "Forbidden")
|
| 214 |
+
|
| 215 |
+
if candidate.is_file():
|
| 216 |
+
return FileResponse(candidate)
|
| 217 |
+
|
| 218 |
+
# SPA fallback: serve index.html untuk unknown routes
|
| 219 |
+
index = STATIC_DIR / "index.html"
|
| 220 |
+
if index.exists():
|
| 221 |
+
return FileResponse(index)
|
| 222 |
+
raise HTTPException(404, "Not Found")
|
| 223 |
+
|
| 224 |
+
logger.info(f"[static] frontend mounted dari {STATIC_DIR}")
|
| 225 |
+
else:
|
| 226 |
+
logger.info("[static] tidak ada static dir -- run frontend terpisah via vite dev")
|
backend/app/models/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas (API request/response) + SQLAlchemy ORM models.
|
| 2 |
+
|
| 3 |
+
Analogi Laravel:
|
| 4 |
+
- Pydantic schemas = Form Request validation + API Resource
|
| 5 |
+
- SQLAlchemy ORM = Eloquent Models
|
| 6 |
+
|
| 7 |
+
Files (akan dibuat di Week 2):
|
| 8 |
+
- listing.py — Listing ORM + Pydantic schemas (ListingRead, ListingDetail)
|
| 9 |
+
- query.py — Query + Annotation + EvalResult ORM
|
| 10 |
+
- search.py — SearchRequest, SearchResponse Pydantic schemas
|
| 11 |
+
"""
|
backend/app/models/eval.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ORM + Pydantic schemas untuk eval data (queries, ground truth, results).
|
| 2 |
+
|
| 3 |
+
Tables di-create via Alembic migration 001_initial_schema.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel, ConfigDict
|
| 12 |
+
from sqlalchemy import (
|
| 13 |
+
CheckConstraint,
|
| 14 |
+
DateTime,
|
| 15 |
+
ForeignKey,
|
| 16 |
+
Numeric,
|
| 17 |
+
SmallInteger,
|
| 18 |
+
String,
|
| 19 |
+
Text,
|
| 20 |
+
func,
|
| 21 |
+
)
|
| 22 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 23 |
+
|
| 24 |
+
from app.core.db import Base
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# =============================================================================
|
| 28 |
+
# ORM models
|
| 29 |
+
# =============================================================================
|
| 30 |
+
class Query(Base):
|
| 31 |
+
"""Query set untuk evaluation."""
|
| 32 |
+
__tablename__ = "queries"
|
| 33 |
+
|
| 34 |
+
id: Mapped[str] = mapped_column(String, primary_key=True)
|
| 35 |
+
query_text: Mapped[str] = mapped_column(Text, nullable=False)
|
| 36 |
+
context: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 37 |
+
expected_tipe: Mapped[Optional[str]] = mapped_column(
|
| 38 |
+
String(20), nullable=True
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class GroundTruth(Base):
|
| 43 |
+
"""Raw annotation per (query, listing, annotator)."""
|
| 44 |
+
__tablename__ = "ground_truth"
|
| 45 |
+
|
| 46 |
+
query_id: Mapped[str] = mapped_column(
|
| 47 |
+
String, ForeignKey("queries.id"), primary_key=True
|
| 48 |
+
)
|
| 49 |
+
listing_id: Mapped[str] = mapped_column(
|
| 50 |
+
String, ForeignKey("listings.id"), primary_key=True
|
| 51 |
+
)
|
| 52 |
+
annotator: Mapped[str] = mapped_column(String(50), primary_key=True)
|
| 53 |
+
relevance: Mapped[int] = mapped_column(SmallInteger, nullable=False)
|
| 54 |
+
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 55 |
+
|
| 56 |
+
__table_args__ = (
|
| 57 |
+
CheckConstraint(
|
| 58 |
+
"relevance IN (0, 1, 2)", name="ck_relevance_0_1_2"
|
| 59 |
+
),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class GroundTruthConsensus(Base):
|
| 64 |
+
"""Consensus labels (post-discussion). Input untuk evaluation runner."""
|
| 65 |
+
__tablename__ = "ground_truth_consensus"
|
| 66 |
+
|
| 67 |
+
query_id: Mapped[str] = mapped_column(
|
| 68 |
+
String, ForeignKey("queries.id"), primary_key=True
|
| 69 |
+
)
|
| 70 |
+
listing_id: Mapped[str] = mapped_column(
|
| 71 |
+
String, ForeignKey("listings.id"), primary_key=True
|
| 72 |
+
)
|
| 73 |
+
relevance: Mapped[int] = mapped_column(SmallInteger, nullable=False)
|
| 74 |
+
|
| 75 |
+
__table_args__ = (
|
| 76 |
+
CheckConstraint(
|
| 77 |
+
"relevance IN (0, 1, 2)", name="ck_consensus_relevance"
|
| 78 |
+
),
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class EvalResult(Base):
|
| 83 |
+
"""Per-model per-query evaluation metrics."""
|
| 84 |
+
__tablename__ = "eval_results"
|
| 85 |
+
|
| 86 |
+
model: Mapped[str] = mapped_column(String(20), primary_key=True)
|
| 87 |
+
query_id: Mapped[str] = mapped_column(
|
| 88 |
+
String, ForeignKey("queries.id"), primary_key=True
|
| 89 |
+
)
|
| 90 |
+
precision_at_5: Mapped[Optional[float]] = mapped_column(
|
| 91 |
+
Numeric(5, 4), nullable=True
|
| 92 |
+
)
|
| 93 |
+
precision_at_10: Mapped[Optional[float]] = mapped_column(
|
| 94 |
+
Numeric(5, 4), nullable=True
|
| 95 |
+
)
|
| 96 |
+
average_precision: Mapped[Optional[float]] = mapped_column(
|
| 97 |
+
Numeric(5, 4), nullable=True
|
| 98 |
+
)
|
| 99 |
+
ndcg_at_10: Mapped[Optional[float]] = mapped_column(
|
| 100 |
+
Numeric(5, 4), nullable=True
|
| 101 |
+
)
|
| 102 |
+
reciprocal_rank: Mapped[Optional[float]] = mapped_column(
|
| 103 |
+
Numeric(5, 4), nullable=True
|
| 104 |
+
)
|
| 105 |
+
computed_at: Mapped[Optional[datetime]] = mapped_column(
|
| 106 |
+
DateTime, server_default=func.now(), nullable=True
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# =============================================================================
|
| 111 |
+
# Pydantic schemas (API)
|
| 112 |
+
# =============================================================================
|
| 113 |
+
class ModelMetrics(BaseModel):
|
| 114 |
+
"""Aggregate metrics per IR model."""
|
| 115 |
+
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
| 116 |
+
|
| 117 |
+
model: str
|
| 118 |
+
p_at_5: float
|
| 119 |
+
p_at_10: float
|
| 120 |
+
map: float
|
| 121 |
+
ndcg_at_10: float
|
| 122 |
+
mrr: float
|
| 123 |
+
n_queries: int
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class EvalSummary(BaseModel):
|
| 127 |
+
"""Response untuk /eval/summary."""
|
| 128 |
+
models: list[ModelMetrics]
|
| 129 |
+
total_queries: int
|
| 130 |
+
total_listings_indexed: int
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class QueryResult(BaseModel):
|
| 134 |
+
"""Per-query metric breakdown untuk /eval/query/{id}."""
|
| 135 |
+
model_config = ConfigDict(protected_namespaces=())
|
| 136 |
+
|
| 137 |
+
query_id: str
|
| 138 |
+
query_text: str
|
| 139 |
+
model: str
|
| 140 |
+
p_at_5: float
|
| 141 |
+
p_at_10: float
|
| 142 |
+
average_precision: float
|
| 143 |
+
ndcg_at_10: float
|
| 144 |
+
reciprocal_rank: float
|
backend/app/models/listing.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Listing ORM model + Pydantic schemas.
|
| 2 |
+
|
| 3 |
+
Analogi Laravel:
|
| 4 |
+
- Listing ORM = Eloquent Model (database mapping)
|
| 5 |
+
- ListingRead/Detail Pydantic = Form Request / API Resource (serialization)
|
| 6 |
+
|
| 7 |
+
ORM untuk persistence, Pydantic untuk API I/O. Konversi lewat
|
| 8 |
+
`ListingRead.model_validate(listing_orm)` atau `from_attributes=True`.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from datetime import date
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 17 |
+
from sqlalchemy import ARRAY, Date, DateTime, Integer, Numeric, String, Text, func
|
| 18 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 19 |
+
|
| 20 |
+
from app.core.db import Base
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# =============================================================================
|
| 24 |
+
# ORM model
|
| 25 |
+
# =============================================================================
|
| 26 |
+
class Listing(Base):
|
| 27 |
+
"""Table `listings` — raw scraped + processed kos data."""
|
| 28 |
+
|
| 29 |
+
__tablename__ = "listings"
|
| 30 |
+
|
| 31 |
+
id: Mapped[str] = mapped_column(String, primary_key=True)
|
| 32 |
+
judul: Mapped[str] = mapped_column(Text, nullable=False)
|
| 33 |
+
deskripsi: Mapped[str] = mapped_column(Text, nullable=False)
|
| 34 |
+
deskripsi_processed: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 35 |
+
|
| 36 |
+
harga_per_bulan: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
| 37 |
+
tipe: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
| 38 |
+
fasilitas: Mapped[Optional[list[str]]] = mapped_column(
|
| 39 |
+
ARRAY(String), nullable=True
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
alamat: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 43 |
+
kecamatan: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
| 44 |
+
koordinat_lat: Mapped[Optional[float]] = mapped_column(
|
| 45 |
+
Numeric(8, 5), nullable=True
|
| 46 |
+
)
|
| 47 |
+
koordinat_lng: Mapped[Optional[float]] = mapped_column(
|
| 48 |
+
Numeric(8, 5), nullable=True
|
| 49 |
+
)
|
| 50 |
+
jarak_kampus_km: Mapped[Optional[float]] = mapped_column(
|
| 51 |
+
Numeric(5, 2), nullable=True
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
url_source: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 55 |
+
scrape_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
| 56 |
+
source: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
| 57 |
+
|
| 58 |
+
inserted_at: Mapped[Optional[date]] = mapped_column(
|
| 59 |
+
DateTime, server_default=func.now(), nullable=True
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# =============================================================================
|
| 64 |
+
# Pydantic schemas (API I/O)
|
| 65 |
+
# =============================================================================
|
| 66 |
+
class ListingBase(BaseModel):
|
| 67 |
+
"""Shared fields antara list & detail."""
|
| 68 |
+
model_config = ConfigDict(from_attributes=True)
|
| 69 |
+
|
| 70 |
+
id: str
|
| 71 |
+
judul: str
|
| 72 |
+
harga_per_bulan: Optional[int] = None
|
| 73 |
+
tipe: Optional[str] = None
|
| 74 |
+
fasilitas: Optional[list[str]] = None
|
| 75 |
+
alamat: Optional[str] = None
|
| 76 |
+
kecamatan: Optional[str] = None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class ListingRead(ListingBase):
|
| 80 |
+
"""Schema untuk hasil search (deskripsi present, score dari IR model)."""
|
| 81 |
+
deskripsi: str = Field(..., description="Original deskripsi; UI truncate ~200 chars")
|
| 82 |
+
score: float = Field(..., description="Relevance score dari IR model")
|
| 83 |
+
koordinat: Optional[list[float]] = Field(
|
| 84 |
+
None, description="[lat, lng] untuk pin Google Maps di frontend"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class ListingDetail(ListingBase):
|
| 89 |
+
"""Schema untuk endpoint /listings/{id} (full detail)."""
|
| 90 |
+
deskripsi: str
|
| 91 |
+
deskripsi_processed: Optional[str] = None
|
| 92 |
+
koordinat: Optional[list[float]] = Field(
|
| 93 |
+
None, description="[lat, lng] format"
|
| 94 |
+
)
|
| 95 |
+
jarak_kampus_km: Optional[float] = None
|
| 96 |
+
url_source: Optional[str] = None
|
| 97 |
+
scrape_date: Optional[date] = None
|
| 98 |
+
source: Optional[str] = None
|
backend/app/models/search.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas untuk /search endpoint request/response."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Literal
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
|
| 9 |
+
from app.models.listing import ListingRead
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# Allowed IR model names (sinkron dengan IndexBase.name di app.indexing.*)
|
| 13 |
+
Model = Literal["tfidf", "bm25", "indobert", "hybrid", "smart"]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class SearchResponse(BaseModel):
|
| 17 |
+
"""Response untuk GET /search."""
|
| 18 |
+
query: str
|
| 19 |
+
model: Model
|
| 20 |
+
top_k: int
|
| 21 |
+
took_ms: int = Field(..., description="Latency total search dalam millisecond")
|
| 22 |
+
results: list[ListingRead]
|
| 23 |
+
understood: dict = Field(
|
| 24 |
+
default_factory=dict, description="Atribut terdeteksi dari query (mode smart)"
|
| 25 |
+
)
|
| 26 |
+
relaxed: list[str] = Field(
|
| 27 |
+
default_factory=list, description="Filter yang dilonggarkan (mode smart)"
|
| 28 |
+
)
|
backend/app/preprocessing/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Preprocessing Module
|
| 2 |
+
|
| 3 |
+
Indonesian text preprocessing pipeline untuk kos listings, dengan custom jargon
|
| 4 |
+
dictionary (105+ entries).
|
| 5 |
+
|
| 6 |
+
## Pipeline Order (PENTING)
|
| 7 |
+
|
| 8 |
+
```
|
| 9 |
+
raw text
|
| 10 |
+
-> 1. HTML strip (BeautifulSoup)
|
| 11 |
+
-> 2. Whitespace normalize
|
| 12 |
+
-> 3. Price extraction (SEBELUM lowercase — preserve `Rp` capitalized)
|
| 13 |
+
-> 4. Lowercase
|
| 14 |
+
-> 5. Jargon dictionary substitution (word-boundary, longest-first)
|
| 15 |
+
-> 6. Spelling correction (typo dictionary)
|
| 16 |
+
-> 7. Tokenize (whitespace + punctuation)
|
| 17 |
+
-> 8. Stopword removal (Sastrawi default + custom domain)
|
| 18 |
+
-> 9. Stem (Sastrawi StemmerFactory, LRU-cached)
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
**Anti-pattern**: lowercase SEBELUM extract harga → regex `[Rr][Pp]` masih
|
| 22 |
+
match, tapi edge case `Rp850k` (tanpa space) jadi rapuh kalau pattern berubah.
|
| 23 |
+
|
| 24 |
+
## Usage
|
| 25 |
+
|
| 26 |
+
```python
|
| 27 |
+
from app.preprocessing import PreprocessingPipeline, PipelineConfig
|
| 28 |
+
|
| 29 |
+
# Default: semua stage aktif
|
| 30 |
+
pipeline = PreprocessingPipeline()
|
| 31 |
+
result = pipeline.process(
|
| 32 |
+
"Kos Putra AC WiFi <b>Rp 850.000</b>/bulan dekat unyila"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
print(result.processed) # joined tokens setelah stemming
|
| 36 |
+
print(result.tokens) # ['putra', 'air', 'condition', ...]
|
| 37 |
+
print(result.extracted_prices) # [850000]
|
| 38 |
+
print(result.stages_applied) # ['strip_html', 'normalize_whitespace', ...]
|
| 39 |
+
|
| 40 |
+
# Shorthand utility
|
| 41 |
+
from app.preprocessing import preprocess
|
| 42 |
+
text = preprocess("kos murah 500k dekat unyila")
|
| 43 |
+
|
| 44 |
+
# Experiment mode: disable stem
|
| 45 |
+
config = PipelineConfig(stem=False)
|
| 46 |
+
pipeline = PreprocessingPipeline(config)
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
## What's Done (oleh mentor — scaffold)
|
| 50 |
+
|
| 51 |
+
- [x] `pipeline.py` — orchestrator dengan stage toggles
|
| 52 |
+
- [x] `normalizer.py` — HTML strip, whitespace, lowercase, inline price extract
|
| 53 |
+
- [x] `jargon.py` — **105+ entries** (abbreviation, location, type, rules,
|
| 54 |
+
payment, kos form) — `python -m app.preprocessing.jargon` cek count
|
| 55 |
+
- [x] `spelling.py` — typo dictionary baseline (15+ fixes)
|
| 56 |
+
- [x] `tokenizer.py` — whitespace + punctuation tokenizer
|
| 57 |
+
- [x] `stopwords.py` — Sastrawi + custom kos stopwords
|
| 58 |
+
- [x] `stemmer.py` — Sastrawi wrapper dengan LRU cache 10k
|
| 59 |
+
- [x] Tests di `backend/tests/test_preprocessing.py`
|
| 60 |
+
|
| 61 |
+
## What Tim Anggota B (Preprocessing Engineer) WAJIB Do
|
| 62 |
+
|
| 63 |
+
### Priority 1 — Tambah jargon dict ke ≥120 entries
|
| 64 |
+
|
| 65 |
+
File: `jargon.py`. Saat ini 105 entries (sudah lewat threshold 100, tapi
|
| 66 |
+
**lebih bagus 120+ untuk dokumentasi rubric**).
|
| 67 |
+
|
| 68 |
+
Cara discover entries baru:
|
| 69 |
+
1. Tunggu Anggota A scrape selesai (`data/raw/mamikos.jsonl`)
|
| 70 |
+
2. Random sample 50 listings, baca manual:
|
| 71 |
+
```python
|
| 72 |
+
import json
|
| 73 |
+
with open("data/raw/mamikos.jsonl") as f:
|
| 74 |
+
sample = [json.loads(line) for line in list(f)[:50]]
|
| 75 |
+
for listing in sample:
|
| 76 |
+
print(listing["deskripsi"][:300])
|
| 77 |
+
print("---")
|
| 78 |
+
```
|
| 79 |
+
3. Catat pattern abbreviation / slang yang belum di `jargon.py`
|
| 80 |
+
4. Tambah ke kategori sesuai (`ABBREVIATIONS`, `LOCATIONS`, etc.)
|
| 81 |
+
5. Run check:
|
| 82 |
+
```bash
|
| 83 |
+
cd backend
|
| 84 |
+
python -m app.preprocessing.jargon
|
| 85 |
+
# Output: KOS_JARGON_DICT size: 120+ -- OK: meets rubric requirement
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### Priority 2 — Custom stopwords dari EDA
|
| 89 |
+
|
| 90 |
+
File: `stopwords.py` — `DEFAULT_CUSTOM_STOPWORDS`.
|
| 91 |
+
|
| 92 |
+
Di notebook `01_eda.ipynb`:
|
| 93 |
+
```python
|
| 94 |
+
from collections import Counter
|
| 95 |
+
import json
|
| 96 |
+
|
| 97 |
+
corpus = [json.loads(line) for line in open("../data/raw/mamikos.jsonl")]
|
| 98 |
+
all_tokens = []
|
| 99 |
+
for listing in corpus:
|
| 100 |
+
all_tokens.extend(listing["deskripsi"].lower().split())
|
| 101 |
+
|
| 102 |
+
# Top-50 paling sering
|
| 103 |
+
for token, count in Counter(all_tokens).most_common(50):
|
| 104 |
+
print(f"{token}: {count}")
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
Top-50 paling sering biasanya stopword domain — tambah ke
|
| 108 |
+
`DEFAULT_CUSTOM_STOPWORDS` kalau memang noise (bukan informative term).
|
| 109 |
+
|
| 110 |
+
### Priority 3 — Benchmark preprocessing impact
|
| 111 |
+
|
| 112 |
+
File: `notebooks/02_preprocessing_experiment.ipynb` (Anggota B buat sendiri).
|
| 113 |
+
|
| 114 |
+
Untuk **rubric Preprocessing 15%** — wajib show preprocessing impact secara
|
| 115 |
+
empirik:
|
| 116 |
+
|
| 117 |
+
| Variant | MAP | P@10 | Notes |
|
| 118 |
+
|---------|-----|------|-------|
|
| 119 |
+
| BASELINE (raw text, no preprocess) | 0.XX | 0.XX | reference |
|
| 120 |
+
| + HTML strip + lowercase | 0.XX | 0.XX | |
|
| 121 |
+
| + jargon dict | 0.XX | 0.XX | expect lift |
|
| 122 |
+
| + spelling correction | 0.XX | 0.XX | |
|
| 123 |
+
| + stopword removal | 0.XX | 0.XX | |
|
| 124 |
+
| + stemming | 0.XX | 0.XX | |
|
| 125 |
+
| FULL pipeline | 0.XX | 0.XX | |
|
| 126 |
+
|
| 127 |
+
Insight ditulis di laporan akhir + slide.
|
| 128 |
+
|
| 129 |
+
### Priority 4 — Spelling correction yang lebih robust (optional, Week 3)
|
| 130 |
+
|
| 131 |
+
Kalau ada budget waktu di Week 2-3, upgrade `spelling.py`:
|
| 132 |
+
- Edit distance (Levenshtein) untuk catch unknown typos
|
| 133 |
+
- Bigram language model untuk context-aware correction
|
| 134 |
+
|
| 135 |
+
Libraries: `pyspellchecker`, custom n-gram model.
|
| 136 |
+
|
| 137 |
+
## Testing
|
| 138 |
+
|
| 139 |
+
```bash
|
| 140 |
+
cd backend
|
| 141 |
+
pip install -r requirements.txt # include Sastrawi
|
| 142 |
+
pytest tests/test_preprocessing.py -v
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
Test coverage:
|
| 146 |
+
- Atomic functions (normalizer, tokenizer, jargon, spelling) — no Sastrawi needed
|
| 147 |
+
- Pipeline integration (stopword, stemmer) — needs Sastrawi installed
|
| 148 |
+
- Anti-pattern test: price extraction sebelum lowercase
|
| 149 |
+
- Jargon substitution dengan word boundary + longest-first ordering
|
| 150 |
+
- Config toggles (disable individual stages)
|
| 151 |
+
|
| 152 |
+
## Performance Notes
|
| 153 |
+
|
| 154 |
+
Untuk corpus 3000 listings × ~150 tokens avg:
|
| 155 |
+
- HTML strip: ~5ms/doc
|
| 156 |
+
- Jargon substitution: ~2ms/doc (pre-compiled regex)
|
| 157 |
+
- Sastrawi stem (first time): ~500ms-1s setup, ~10-50ms/doc
|
| 158 |
+
- Sastrawi stem (cached): ~1-5ms/doc
|
| 159 |
+
|
| 160 |
+
Total batch preprocessing 3000 docs: ~30-60 detik (acceptable, run once,
|
| 161 |
+
save corpus.json).
|
backend/app/preprocessing/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Indonesian text preprocessing pipeline untuk kos listings.
|
| 2 |
+
|
| 3 |
+
Public API:
|
| 4 |
+
from app.preprocessing import (
|
| 5 |
+
PreprocessingPipeline, PipelineConfig, PipelineResult, preprocess,
|
| 6 |
+
KOS_JARGON_DICT, jargon_count,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
Pipeline ORDER (PENTING):
|
| 10 |
+
1. HTML strip (BeautifulSoup)
|
| 11 |
+
2. Whitespace normalize
|
| 12 |
+
3. Price extraction (SEBELUM lowercase — preserve `Rp`)
|
| 13 |
+
4. Lowercase
|
| 14 |
+
5. Jargon dict substitution
|
| 15 |
+
6. Spelling correction
|
| 16 |
+
7. Tokenize
|
| 17 |
+
8. Stopword removal (Sastrawi + custom)
|
| 18 |
+
9. Stem (Sastrawi StemmerFactory, cached)
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from .jargon import KOS_JARGON_DICT, jargon_count
|
| 22 |
+
from .pipeline import (
|
| 23 |
+
PipelineConfig,
|
| 24 |
+
PipelineResult,
|
| 25 |
+
PreprocessingPipeline,
|
| 26 |
+
preprocess,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
__all__ = [
|
| 30 |
+
"PreprocessingPipeline",
|
| 31 |
+
"PipelineConfig",
|
| 32 |
+
"PipelineResult",
|
| 33 |
+
"preprocess",
|
| 34 |
+
"KOS_JARGON_DICT",
|
| 35 |
+
"jargon_count",
|
| 36 |
+
]
|
backend/app/preprocessing/jargon.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Kos-specific jargon dictionary untuk Indonesian preprocessing.
|
| 2 |
+
|
| 3 |
+
COURSE REQUIREMENT: ≥100 entries domain-specific (drop rubric Preprocessing 15%
|
| 4 |
+
kalau cuma pakai pipeline Sastrawi default tanpa custom dict).
|
| 5 |
+
|
| 6 |
+
Mentor menyediakan ~105 baseline entries di file ini. Tim Anggota B (Preprocessing
|
| 7 |
+
Engineer) WAJIB tambah minimal 20-30 lagi dari hasil EDA, terutama dari:
|
| 8 |
+
- Slang kos spesifik Bandar Lampung yang ditemukan saat scrape (cek
|
| 9 |
+
`data/raw/mamikos.jsonl` untuk pattern abbreviation baru)
|
| 10 |
+
- Variant kecamatan / area UNILA yang belum tercakup
|
| 11 |
+
- Brand merchandise / produk yang sering muncul (merek kasur, mereka brand AC)
|
| 12 |
+
- Singkatan unik dari listing pengelola tertentu
|
| 13 |
+
|
| 14 |
+
Format: variant (apa adanya di listing) -> canonical (bentuk standar).
|
| 15 |
+
Matching: word-boundary regex, case-insensitive, longest-first ordering.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# =============================================================================
|
| 22 |
+
# Abbreviation umum kos & fasilitas
|
| 23 |
+
# =============================================================================
|
| 24 |
+
ABBREVIATIONS: dict[str, str] = {
|
| 25 |
+
# Fasilitas kamar mandi
|
| 26 |
+
"kmd": "kamar mandi dalam",
|
| 27 |
+
"km dlm": "kamar mandi dalam",
|
| 28 |
+
"wc dlm": "kamar mandi dalam",
|
| 29 |
+
"km dalam": "kamar mandi dalam",
|
| 30 |
+
"wc dalam": "kamar mandi dalam",
|
| 31 |
+
"km luar": "kamar mandi luar",
|
| 32 |
+
"wc luar": "kamar mandi luar",
|
| 33 |
+
# Fasilitas elektronik
|
| 34 |
+
"ac": "air conditioner",
|
| 35 |
+
"tv": "televisi",
|
| 36 |
+
"wf": "wifi",
|
| 37 |
+
# Harga & periode
|
| 38 |
+
"jt": "juta",
|
| 39 |
+
"rb": "ribu",
|
| 40 |
+
"thn": "tahun",
|
| 41 |
+
"bln": "bulan",
|
| 42 |
+
"blnan": "bulanan",
|
| 43 |
+
"thnan": "tahunan",
|
| 44 |
+
"min": "minimum",
|
| 45 |
+
"maks": "maksimum",
|
| 46 |
+
# General Indonesian abbreviation
|
| 47 |
+
"dlm": "dalam",
|
| 48 |
+
"sblm": "sebelum",
|
| 49 |
+
"ssdh": "sesudah",
|
| 50 |
+
"yg": "yang",
|
| 51 |
+
"krn": "karena",
|
| 52 |
+
"tdk": "tidak",
|
| 53 |
+
"gak": "tidak",
|
| 54 |
+
"ngga": "tidak",
|
| 55 |
+
"engga": "tidak",
|
| 56 |
+
"dr": "dari",
|
| 57 |
+
"dgn": "dengan",
|
| 58 |
+
"utk": "untuk",
|
| 59 |
+
"skr": "sekarang",
|
| 60 |
+
"blm": "belum",
|
| 61 |
+
"udh": "sudah",
|
| 62 |
+
"udah": "sudah",
|
| 63 |
+
"byk": "banyak",
|
| 64 |
+
"sdh": "sudah",
|
| 65 |
+
"jln": "jalan",
|
| 66 |
+
"jl": "jalan",
|
| 67 |
+
"rmh": "rumah",
|
| 68 |
+
"ortu": "orang tua",
|
| 69 |
+
"bsk": "besok",
|
| 70 |
+
"tgl": "tanggal",
|
| 71 |
+
"tgl2": "tanggal",
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
# =============================================================================
|
| 75 |
+
# Location variants (Bandar Lampung area sekitar UNILA)
|
| 76 |
+
# =============================================================================
|
| 77 |
+
LOCATIONS: dict[str, str] = {
|
| 78 |
+
# Kecamatan & area kampus
|
| 79 |
+
"gdg meneng": "gedong meneng",
|
| 80 |
+
"gd meneng": "gedong meneng",
|
| 81 |
+
"rjbs": "rajabasa",
|
| 82 |
+
"rj basa": "rajabasa",
|
| 83 |
+
"sumbro": "sumantri brojonegoro",
|
| 84 |
+
"kdtn": "kedaton",
|
| 85 |
+
"wh": "way halim",
|
| 86 |
+
"lab ratu": "labuhan ratu",
|
| 87 |
+
"tj senang": "tanjung senang",
|
| 88 |
+
"tlb": "teluk betung",
|
| 89 |
+
"tjk": "tanjungkarang",
|
| 90 |
+
"bdl": "bandar lampung",
|
| 91 |
+
"blampung": "bandar lampung",
|
| 92 |
+
# Kampus & landmark
|
| 93 |
+
"unila": "universitas lampung",
|
| 94 |
+
"unyila": "universitas lampung",
|
| 95 |
+
"unl": "universitas lampung",
|
| 96 |
+
"fmipa": "fakultas mipa universitas lampung",
|
| 97 |
+
"ft unila": "fakultas teknik universitas lampung",
|
| 98 |
+
"fkip": "fakultas keguruan universitas lampung",
|
| 99 |
+
"feb": "fakultas ekonomi bisnis universitas lampung",
|
| 100 |
+
"uin": "uin raden intan lampung",
|
| 101 |
+
"polnep": "politeknik negeri lampung",
|
| 102 |
+
"itera": "institut teknologi sumatera",
|
| 103 |
+
"kampus a": "kampus utama",
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
# =============================================================================
|
| 107 |
+
# Tipe kos & slang gender
|
| 108 |
+
# =============================================================================
|
| 109 |
+
TYPE_SLANG: dict[str, str] = {
|
| 110 |
+
"cowo": "putra",
|
| 111 |
+
"cowok": "putra",
|
| 112 |
+
"pria": "putra",
|
| 113 |
+
"laki": "putra",
|
| 114 |
+
"lk": "putra",
|
| 115 |
+
"cewe": "putri",
|
| 116 |
+
"cewek": "putri",
|
| 117 |
+
"wanita": "putri",
|
| 118 |
+
"perempuan": "putri",
|
| 119 |
+
"pr": "putri",
|
| 120 |
+
"mix": "campur",
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
# =============================================================================
|
| 124 |
+
# Aturan kos (rules)
|
| 125 |
+
# =============================================================================
|
| 126 |
+
RULES: dict[str, str] = {
|
| 127 |
+
"jamal": "jam malam",
|
| 128 |
+
"jam mlm": "jam malam",
|
| 129 |
+
"no smoking": "dilarang merokok",
|
| 130 |
+
"no smoke": "dilarang merokok",
|
| 131 |
+
"free": "bebas",
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
# =============================================================================
|
| 135 |
+
# Payment & harga terms
|
| 136 |
+
# =============================================================================
|
| 137 |
+
PAYMENT: dict[str, str] = {
|
| 138 |
+
"dp": "uang muka",
|
| 139 |
+
"uang dp": "uang muka",
|
| 140 |
+
"down payment": "uang muka",
|
| 141 |
+
"deposit": "uang jaminan",
|
| 142 |
+
"all in": "termasuk semua",
|
| 143 |
+
"all-in": "termasuk semua",
|
| 144 |
+
"include": "termasuk",
|
| 145 |
+
"exclude": "tidak termasuk",
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
# =============================================================================
|
| 149 |
+
# Bentuk kos (kostan, kontrakan, dll)
|
| 150 |
+
# =============================================================================
|
| 151 |
+
KOS_FORM: dict[str, str] = {
|
| 152 |
+
"kostan": "kos kosan",
|
| 153 |
+
"kost-kostan": "kos kosan",
|
| 154 |
+
"kost2an": "kos kosan",
|
| 155 |
+
"kost kostan": "kos kosan",
|
| 156 |
+
"rumah kost": "kos kosan",
|
| 157 |
+
"kontrakan": "kos kosan",
|
| 158 |
+
"kos eksekutif": "kos eksklusif",
|
| 159 |
+
"ekslusive": "eksklusif",
|
| 160 |
+
"exclusive": "eksklusif",
|
| 161 |
+
"exclusif": "eksklusif",
|
| 162 |
+
"exklusif": "eksklusif",
|
| 163 |
+
"exklusive": "eksklusif",
|
| 164 |
+
"ready": "tersedia",
|
| 165 |
+
"available": "tersedia",
|
| 166 |
+
"ready stock": "tersedia",
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
# =============================================================================
|
| 170 |
+
# Final merged dict
|
| 171 |
+
# =============================================================================
|
| 172 |
+
KOS_JARGON_DICT: dict[str, str] = {
|
| 173 |
+
**ABBREVIATIONS,
|
| 174 |
+
**LOCATIONS,
|
| 175 |
+
**TYPE_SLANG,
|
| 176 |
+
**RULES,
|
| 177 |
+
**PAYMENT,
|
| 178 |
+
**KOS_FORM,
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def jargon_count() -> int:
|
| 183 |
+
"""Total unique variants di KOS_JARGON_DICT."""
|
| 184 |
+
return len(KOS_JARGON_DICT)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# Rubric requirement
|
| 188 |
+
MIN_REQUIRED = 100
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
if __name__ == "__main__":
|
| 192 |
+
count = jargon_count()
|
| 193 |
+
print(f"KOS_JARGON_DICT size: {count}")
|
| 194 |
+
print(f"Minimum required for rubric: {MIN_REQUIRED}")
|
| 195 |
+
if count < MIN_REQUIRED:
|
| 196 |
+
print(f"WARNING: tambah {MIN_REQUIRED - count} more entries (tim Anggota B)")
|
| 197 |
+
else:
|
| 198 |
+
print("OK: meets rubric requirement")
|
| 199 |
+
print("\nBreakdown:")
|
| 200 |
+
print(f" ABBREVIATIONS: {len(ABBREVIATIONS)}")
|
| 201 |
+
print(f" LOCATIONS: {len(LOCATIONS)}")
|
| 202 |
+
print(f" TYPE_SLANG: {len(TYPE_SLANG)}")
|
| 203 |
+
print(f" RULES: {len(RULES)}")
|
| 204 |
+
print(f" PAYMENT: {len(PAYMENT)}")
|
| 205 |
+
print(f" KOS_FORM: {len(KOS_FORM)}")
|
backend/app/preprocessing/normalizer.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Atomic normalization: HTML strip, whitespace, lowercase, price extraction.
|
| 2 |
+
|
| 3 |
+
ORDER-SENSITIVE: `extract_prices_inline()` MUST jalan SEBELUM `lowercase()`
|
| 4 |
+
karena regex match `[Rr][Pp]` (case-insensitive), tapi `Rp` capitalized lebih
|
| 5 |
+
robust untuk catch edge case dimana harga tidak dipisah space.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
|
| 12 |
+
from bs4 import BeautifulSoup
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def strip_html(text: str) -> str:
|
| 16 |
+
"""Strip HTML tags + decode entities, leave plain text dengan space separator."""
|
| 17 |
+
if not text:
|
| 18 |
+
return ""
|
| 19 |
+
soup = BeautifulSoup(text, "lxml")
|
| 20 |
+
return soup.get_text(separator=" ", strip=True)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def normalize_whitespace(text: str) -> str:
|
| 24 |
+
"""Collapse multiple whitespace (space/tab/newline) → single space, strip."""
|
| 25 |
+
if not text:
|
| 26 |
+
return ""
|
| 27 |
+
return re.sub(r"\s+", " ", text).strip()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def lowercase(text: str) -> str:
|
| 31 |
+
"""Lowercase. Wajib jalan SETELAH `extract_prices_inline()`."""
|
| 32 |
+
return (text or "").lower()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# =============================================================================
|
| 36 |
+
# Price extraction (inline, return all matches)
|
| 37 |
+
# =============================================================================
|
| 38 |
+
_PRICE_PATTERNS = [
|
| 39 |
+
# Rp 1.250.000 / Rp1.250.000 / Rp 850,000
|
| 40 |
+
(re.compile(r"[Rr][Pp]\s*([\d.,]+)", re.IGNORECASE), "rupiah"),
|
| 41 |
+
# 1.5jt, 1jt, 2,3 jt
|
| 42 |
+
(re.compile(r"(\d+(?:[.,]\d+)?)\s*jt\b", re.IGNORECASE), "juta"),
|
| 43 |
+
# 500k, 500rb, 500 ribu
|
| 44 |
+
(re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:k|rb|ribu)\b", re.IGNORECASE), "ribu"),
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def extract_prices_inline(text: str) -> list[int]:
|
| 49 |
+
"""Extract semua harga muncul di text. Return list IDR integer.
|
| 50 |
+
|
| 51 |
+
Untuk kos listing biasanya 1-2 harga (sewa bulanan + uang muka), tapi
|
| 52 |
+
return list untuk fleksibilitas.
|
| 53 |
+
"""
|
| 54 |
+
if not text:
|
| 55 |
+
return []
|
| 56 |
+
|
| 57 |
+
prices: list[int] = []
|
| 58 |
+
for pattern, unit in _PRICE_PATTERNS:
|
| 59 |
+
for match in pattern.finditer(text):
|
| 60 |
+
raw = match.group(1)
|
| 61 |
+
try:
|
| 62 |
+
if unit == "rupiah":
|
| 63 |
+
value = int(raw.replace(".", "").replace(",", ""))
|
| 64 |
+
elif unit == "juta":
|
| 65 |
+
value = int(float(raw.replace(",", ".")) * 1_000_000)
|
| 66 |
+
elif unit == "ribu":
|
| 67 |
+
value = int(float(raw.replace(",", ".")) * 1_000)
|
| 68 |
+
else:
|
| 69 |
+
continue
|
| 70 |
+
if value not in prices:
|
| 71 |
+
prices.append(value)
|
| 72 |
+
except ValueError:
|
| 73 |
+
continue
|
| 74 |
+
return prices
|
backend/app/preprocessing/pipeline.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Indonesian preprocessing pipeline orchestrator.
|
| 2 |
+
|
| 3 |
+
Stage-based dengan toggle via PipelineConfig — tim Anggota B pakai untuk
|
| 4 |
+
benchmark `BEFORE vs AFTER tiap stage` di notebook 02_preprocessing_experiment.
|
| 5 |
+
|
| 6 |
+
Analogi Laravel: ini seperti chain of Middleware. Request (raw text) lewat
|
| 7 |
+
chain, tiap middleware transform.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import re
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
from .jargon import KOS_JARGON_DICT
|
| 17 |
+
from .normalizer import (
|
| 18 |
+
extract_prices_inline,
|
| 19 |
+
lowercase,
|
| 20 |
+
normalize_whitespace,
|
| 21 |
+
strip_html,
|
| 22 |
+
)
|
| 23 |
+
from .spelling import correct_spelling
|
| 24 |
+
from .stemmer import SastrawiStemmer
|
| 25 |
+
from .stopwords import StopwordRemover
|
| 26 |
+
from .tokenizer import simple_tokenize
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class PipelineConfig:
|
| 31 |
+
"""Toggle setiap stage untuk experiment.
|
| 32 |
+
|
| 33 |
+
Tim Anggota B: pakai config ini di notebooks/02_preprocessing_experiment.ipynb
|
| 34 |
+
untuk benchmark IR metric BEFORE vs AFTER tiap stage.
|
| 35 |
+
"""
|
| 36 |
+
strip_html: bool = True
|
| 37 |
+
normalize_whitespace: bool = True
|
| 38 |
+
extract_prices: bool = True # always run — non-destructive
|
| 39 |
+
lowercase: bool = True
|
| 40 |
+
apply_jargon_dict: bool = True
|
| 41 |
+
correct_spelling: bool = True
|
| 42 |
+
tokenize: bool = True
|
| 43 |
+
remove_stopwords: bool = True
|
| 44 |
+
stem: bool = True
|
| 45 |
+
custom_stopwords: list[str] = field(default_factory=list)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class PipelineResult:
|
| 50 |
+
"""Hasil preprocessing dengan tracking per-stage."""
|
| 51 |
+
raw: str
|
| 52 |
+
processed: str
|
| 53 |
+
tokens: list[str]
|
| 54 |
+
extracted_prices: list[int] = field(default_factory=list)
|
| 55 |
+
stages_applied: list[str] = field(default_factory=list)
|
| 56 |
+
# Snapshot output tiap stage (untuk visualisasi /api/preprocess).
|
| 57 |
+
# Diisi hanya kalau process(trace=True) — list of {stage, output}.
|
| 58 |
+
trace: list[dict] = field(default_factory=list)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class PreprocessingPipeline:
|
| 62 |
+
"""Indonesian preprocessing pipeline untuk kos listings.
|
| 63 |
+
|
| 64 |
+
Sastrawi factory + stopword set di-init sekali (heavy) — instance shared
|
| 65 |
+
antar dokumen. Untuk batch processing, instantiate sekali, panggil
|
| 66 |
+
`process()` per dokumen.
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
def __init__(
|
| 70 |
+
self,
|
| 71 |
+
config: Optional[PipelineConfig] = None,
|
| 72 |
+
jargon_dict: Optional[dict[str, str]] = None,
|
| 73 |
+
):
|
| 74 |
+
self.config = config or PipelineConfig()
|
| 75 |
+
self.jargon_dict = jargon_dict if jargon_dict is not None else KOS_JARGON_DICT
|
| 76 |
+
# Lazy init heavy components
|
| 77 |
+
self._stemmer: Optional[SastrawiStemmer] = (
|
| 78 |
+
SastrawiStemmer() if self.config.stem else None
|
| 79 |
+
)
|
| 80 |
+
self._stopword_remover: Optional[StopwordRemover] = (
|
| 81 |
+
StopwordRemover(custom=self.config.custom_stopwords)
|
| 82 |
+
if self.config.remove_stopwords
|
| 83 |
+
else None
|
| 84 |
+
)
|
| 85 |
+
# Pre-compile jargon patterns (sorted longest-first untuk hindari
|
| 86 |
+
# "dlm" match dulu sebelum "km dlm")
|
| 87 |
+
self._jargon_patterns: list[tuple[re.Pattern[str], str]] = []
|
| 88 |
+
if self.jargon_dict:
|
| 89 |
+
sorted_variants = sorted(self.jargon_dict.keys(), key=len, reverse=True)
|
| 90 |
+
for variant in sorted_variants:
|
| 91 |
+
pattern = re.compile(
|
| 92 |
+
r"\b" + re.escape(variant) + r"\b", re.IGNORECASE
|
| 93 |
+
)
|
| 94 |
+
self._jargon_patterns.append((pattern, self.jargon_dict[variant]))
|
| 95 |
+
|
| 96 |
+
def process(self, text: str, trace: bool = False) -> PipelineResult:
|
| 97 |
+
"""Run full pipeline. Return PipelineResult dengan tracking stages.
|
| 98 |
+
|
| 99 |
+
trace=True merekam snapshot output tiap stage (untuk visualisasi
|
| 100 |
+
step-by-step di /api/preprocess); default False supaya jalur serving
|
| 101 |
+
tidak bayar overhead.
|
| 102 |
+
"""
|
| 103 |
+
result = PipelineResult(raw=text, processed=text or "", tokens=[])
|
| 104 |
+
if not text:
|
| 105 |
+
return result
|
| 106 |
+
|
| 107 |
+
current = text
|
| 108 |
+
|
| 109 |
+
def snap(stage: str, output) -> None:
|
| 110 |
+
if trace:
|
| 111 |
+
result.trace.append({"stage": stage, "output": output})
|
| 112 |
+
|
| 113 |
+
# Stage 1: HTML strip
|
| 114 |
+
if self.config.strip_html:
|
| 115 |
+
current = strip_html(current)
|
| 116 |
+
result.stages_applied.append("strip_html")
|
| 117 |
+
snap("strip_html", current)
|
| 118 |
+
|
| 119 |
+
# Stage 2: Whitespace normalize
|
| 120 |
+
if self.config.normalize_whitespace:
|
| 121 |
+
current = normalize_whitespace(current)
|
| 122 |
+
result.stages_applied.append("normalize_whitespace")
|
| 123 |
+
snap("normalize_whitespace", current)
|
| 124 |
+
|
| 125 |
+
# Stage 3: Price extraction (PENTING — sebelum lowercase)
|
| 126 |
+
if self.config.extract_prices:
|
| 127 |
+
result.extracted_prices = extract_prices_inline(current)
|
| 128 |
+
result.stages_applied.append("extract_prices")
|
| 129 |
+
snap("extract_prices", result.extracted_prices)
|
| 130 |
+
|
| 131 |
+
# Stage 4: Lowercase
|
| 132 |
+
if self.config.lowercase:
|
| 133 |
+
current = lowercase(current)
|
| 134 |
+
result.stages_applied.append("lowercase")
|
| 135 |
+
snap("lowercase", current)
|
| 136 |
+
|
| 137 |
+
# Stage 5: Jargon dictionary substitution
|
| 138 |
+
if self.config.apply_jargon_dict and self._jargon_patterns:
|
| 139 |
+
current = self._apply_jargon(current)
|
| 140 |
+
result.stages_applied.append("apply_jargon_dict")
|
| 141 |
+
snap("apply_jargon_dict", current)
|
| 142 |
+
|
| 143 |
+
# Stage 6: Spelling correction
|
| 144 |
+
if self.config.correct_spelling:
|
| 145 |
+
current = correct_spelling(current)
|
| 146 |
+
result.stages_applied.append("correct_spelling")
|
| 147 |
+
snap("correct_spelling", current)
|
| 148 |
+
|
| 149 |
+
# Stage 7: Tokenize
|
| 150 |
+
if self.config.tokenize:
|
| 151 |
+
tokens = simple_tokenize(current)
|
| 152 |
+
result.stages_applied.append("tokenize")
|
| 153 |
+
snap("tokenize", list(tokens))
|
| 154 |
+
else:
|
| 155 |
+
tokens = current.split()
|
| 156 |
+
|
| 157 |
+
# Stage 8: Stopword removal
|
| 158 |
+
if self.config.remove_stopwords and self._stopword_remover:
|
| 159 |
+
tokens = self._stopword_remover.remove(tokens)
|
| 160 |
+
result.stages_applied.append("remove_stopwords")
|
| 161 |
+
snap("remove_stopwords", list(tokens))
|
| 162 |
+
|
| 163 |
+
# Stage 9: Stem
|
| 164 |
+
if self.config.stem and self._stemmer:
|
| 165 |
+
tokens = [self._stemmer.stem(tok) for tok in tokens]
|
| 166 |
+
result.stages_applied.append("stem")
|
| 167 |
+
snap("stem", list(tokens))
|
| 168 |
+
|
| 169 |
+
result.tokens = tokens
|
| 170 |
+
result.processed = " ".join(tokens)
|
| 171 |
+
return result
|
| 172 |
+
|
| 173 |
+
def _apply_jargon(self, text: str) -> str:
|
| 174 |
+
"""Replace jargon variants → canonical, longest-first dengan word boundary."""
|
| 175 |
+
for pattern, canonical in self._jargon_patterns:
|
| 176 |
+
text = pattern.sub(canonical, text)
|
| 177 |
+
return text
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def preprocess(text: str, config: Optional[PipelineConfig] = None) -> str:
|
| 181 |
+
"""Shorthand: run pipeline dengan config default, return processed string.
|
| 182 |
+
|
| 183 |
+
Untuk batch lebih efisien, instantiate PreprocessingPipeline sekali,
|
| 184 |
+
panggil `.process()` per dokumen.
|
| 185 |
+
"""
|
| 186 |
+
pipeline = PreprocessingPipeline(config)
|
| 187 |
+
return pipeline.process(text).processed
|
backend/app/preprocessing/spelling.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain-specific spelling correction untuk kos listings.
|
| 2 |
+
|
| 3 |
+
Approach: simple typo dictionary lookup (no statistical model untuk simplicity).
|
| 4 |
+
Tim Anggota B: tambah typo umum yang ditemukan saat scrape data.
|
| 5 |
+
|
| 6 |
+
Alternative future enhancement (Week 3 kalau ada waktu):
|
| 7 |
+
- Edit distance (Levenshtein) untuk catch typos baru
|
| 8 |
+
- Bigram language model untuk context-aware correction
|
| 9 |
+
- Library: pyspellchecker, atau custom n-gram model
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# Common typos di kos listings (curated baseline)
|
| 18 |
+
TYPO_FIXES: dict[str, str] = {
|
| 19 |
+
# Fasilitas typos
|
| 20 |
+
"fasilitsa": "fasilitas",
|
| 21 |
+
"fasiltas": "fasilitas",
|
| 22 |
+
"fasilitas2": "fasilitas",
|
| 23 |
+
"fasilitas-fasilitas": "fasilitas",
|
| 24 |
+
# Lokasi typos
|
| 25 |
+
"strategis": "strategis", # canonical (kept untuk dokumentasi)
|
| 26 |
+
"stratejis": "strategis",
|
| 27 |
+
"lokasinyy": "lokasinya",
|
| 28 |
+
"lokasinya2": "lokasinya",
|
| 29 |
+
# Eksklusif variants (sering muncul di judul listing + query)
|
| 30 |
+
"ekslusif": "eksklusif",
|
| 31 |
+
"ekslusive": "eksklusif",
|
| 32 |
+
"exclusive": "eksklusif",
|
| 33 |
+
"exlusive": "eksklusif",
|
| 34 |
+
# Kondisi
|
| 35 |
+
"bagys": "bagus",
|
| 36 |
+
"barangny": "barangnya",
|
| 37 |
+
"rapih": "rapi",
|
| 38 |
+
"luas2": "luas",
|
| 39 |
+
"bersih2": "bersih",
|
| 40 |
+
# Bahasa gaul yang sering muncul
|
| 41 |
+
"pny": "punya",
|
| 42 |
+
"pnya": "punya",
|
| 43 |
+
"ada2": "ada",
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# Filter out self-mappings (no-op) saat build pattern
|
| 48 |
+
_REAL_FIXES = {variant: fix for variant, fix in TYPO_FIXES.items() if variant != fix}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# Pre-compile patterns sekali (mahal kalau per-call)
|
| 52 |
+
_TYPO_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
| 53 |
+
(re.compile(r"\b" + re.escape(typo) + r"\b", re.IGNORECASE), fix)
|
| 54 |
+
for typo, fix in _REAL_FIXES.items()
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def correct_spelling(text: str) -> str:
|
| 59 |
+
"""Replace common typos dengan bentuk standar (word-boundary regex)."""
|
| 60 |
+
if not text:
|
| 61 |
+
return text
|
| 62 |
+
for pattern, fix in _TYPO_PATTERNS:
|
| 63 |
+
text = pattern.sub(fix, text)
|
| 64 |
+
return text
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
if __name__ == "__main__":
|
| 68 |
+
print(f"TYPO_FIXES: {len(TYPO_FIXES)} total, {len(_REAL_FIXES)} real fixes")
|
backend/app/preprocessing/stemmer.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sastrawi stemmer wrapper dengan LRU cache.
|
| 2 |
+
|
| 3 |
+
Sastrawi stemming bisa lambat untuk corpus besar (3000+ dokumen × N tokens) —
|
| 4 |
+
LRU cache di-method `stem()` mempercepat repeated tokens.
|
| 5 |
+
|
| 6 |
+
Factory di-init sekali (heavy, ~100ms), instance shared antar dokumen.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
|
| 13 |
+
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class SastrawiStemmer:
|
| 17 |
+
"""Wrapper Sastrawi `StemmerFactory().create_stemmer()` dengan LRU cache.
|
| 18 |
+
|
| 19 |
+
Cache size 10k tokens cukup untuk corpus 3000 listings (typical
|
| 20 |
+
vocabulary unique tokens ~5-8k setelah preprocessing).
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
def __init__(self, cache_size: int = 10_000):
|
| 24 |
+
factory = StemmerFactory()
|
| 25 |
+
self._stemmer = factory.create_stemmer()
|
| 26 |
+
# Wrap stem method dengan LRU cache
|
| 27 |
+
self.stem = lru_cache(maxsize=cache_size)(self._stem_uncached)
|
| 28 |
+
|
| 29 |
+
def _stem_uncached(self, word: str) -> str:
|
| 30 |
+
"""Stem single word tanpa cache (called via cached wrapper)."""
|
| 31 |
+
if not word:
|
| 32 |
+
return word
|
| 33 |
+
return self._stemmer.stem(word)
|
| 34 |
+
|
| 35 |
+
def stem_tokens(self, tokens: list[str]) -> list[str]:
|
| 36 |
+
"""Stem list of tokens (gunakan cache per-token)."""
|
| 37 |
+
return [self.stem(tok) for tok in tokens]
|
| 38 |
+
|
| 39 |
+
def cache_info(self):
|
| 40 |
+
"""Debug: LRU cache hit/miss stats."""
|
| 41 |
+
return self.stem.cache_info() # type: ignore[attr-defined]
|
backend/app/preprocessing/stopwords.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Indonesian stopword removal: Sastrawi default + custom domain stopwords.
|
| 2 |
+
|
| 3 |
+
Custom stopwords penting untuk domain kos — kata yang terlalu sering muncul
|
| 4 |
+
turun jadi noise (low IR signal). Tim Anggota B: setelah Anggota A scrape,
|
| 5 |
+
jalanin frequency analysis di `notebooks/01_eda.ipynb`:
|
| 6 |
+
|
| 7 |
+
from collections import Counter
|
| 8 |
+
all_tokens = [tok for listing in corpus for tok in listing['deskripsi'].split()]
|
| 9 |
+
Counter(all_tokens).most_common(50)
|
| 10 |
+
|
| 11 |
+
Top-50 paling sering biasanya stopword domain — tambah ke
|
| 12 |
+
`DEFAULT_CUSTOM_STOPWORDS` kalau memang noise (bukan informative term).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
from Sastrawi.StopWordRemover.StopWordRemoverFactory import StopWordRemoverFactory
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# Domain stopwords baseline (kos-specific). Tim Anggota B: extend dari EDA.
|
| 23 |
+
DEFAULT_CUSTOM_STOPWORDS: list[str] = [
|
| 24 |
+
# Form kos
|
| 25 |
+
"kos", "kost", "kosan", "kostan", "kos-kosan",
|
| 26 |
+
# Kata generic listing
|
| 27 |
+
"kamar",
|
| 28 |
+
"sewa", "harga",
|
| 29 |
+
"tersedia", "ready",
|
| 30 |
+
"info", "informasi",
|
| 31 |
+
"hubungi", "kontak",
|
| 32 |
+
# Kata penghubung tambahan (Sastrawi sudah handle, tapi extra safety)
|
| 33 |
+
# "untuk", "yang", "ke", "di" # already di Sastrawi default
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class StopwordRemover:
|
| 38 |
+
"""Wrapper Sastrawi StopWordRemover + custom set."""
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
custom: Optional[list[str]] = None,
|
| 43 |
+
use_sastrawi_default: bool = True,
|
| 44 |
+
):
|
| 45 |
+
if use_sastrawi_default:
|
| 46 |
+
factory = StopWordRemoverFactory()
|
| 47 |
+
self.sastrawi_stopwords: set[str] = set(factory.get_stop_words())
|
| 48 |
+
else:
|
| 49 |
+
self.sastrawi_stopwords = set()
|
| 50 |
+
|
| 51 |
+
self.custom_stopwords: set[str] = set(custom or DEFAULT_CUSTOM_STOPWORDS)
|
| 52 |
+
self.all_stopwords: set[str] = (
|
| 53 |
+
self.sastrawi_stopwords | self.custom_stopwords
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
def remove(self, tokens: list[str]) -> list[str]:
|
| 57 |
+
"""Filter tokens — drop yang termasuk stopword."""
|
| 58 |
+
return [tok for tok in tokens if tok.lower() not in self.all_stopwords]
|
| 59 |
+
|
| 60 |
+
def is_stopword(self, word: str) -> bool:
|
| 61 |
+
return word.lower() in self.all_stopwords
|
| 62 |
+
|
| 63 |
+
def count(self) -> dict[str, int]:
|
| 64 |
+
return {
|
| 65 |
+
"sastrawi": len(self.sastrawi_stopwords),
|
| 66 |
+
"custom": len(self.custom_stopwords),
|
| 67 |
+
"total": len(self.all_stopwords),
|
| 68 |
+
}
|