File size: 9,016 Bytes
9616326
321f513
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9616326
 
 
321f513
 
9616326
 
 
 
 
 
 
 
 
321f513
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9616326
 
321f513
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9616326
321f513
9616326
 
 
321f513
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9616326
 
 
 
321f513
9616326
 
 
321f513
 
 
 
 
 
 
9616326
 
 
321f513
9616326
 
 
321f513
9616326
 
 
 
321f513
 
 
 
9616326
321f513
9616326
321f513
 
 
365845d
 
 
 
 
 
 
321f513
365845d
321f513
 
 
 
 
 
 
 
 
 
 
 
 
9616326
321f513
9616326
6fb1483
 
 
 
 
 
7778db5
 
 
 
 
6fb1483
 
 
 
321f513
6fb1483
 
 
9616326
321f513
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9616326
321f513
 
 
 
 
9616326
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
#!/usr/bin/env python3
"""
generate_embeddings.py
======================
Generates or regenerates semantic embeddings for all chunks in fiqh.db.

Usage:
    # Normal run (resumes from where it left off):
    python scripts/generate_embeddings.py

    # Switch to a NEW model (wipes old embeddings first, then rebuilds all):
    python scripts/generate_embeddings.py --model CAMeL-Lab/bert-base-arabic-camelbert-ca --reset

    # Check which model was used for current embeddings:
    python scripts/generate_embeddings.py --info
"""
from __future__ import annotations

import sys
import time
import argparse
import sqlite3
from pathlib import Path

CURRENT_FILE = Path(__file__).resolve()
API_DIR = CURRENT_FILE.parents[1]
sys.path.insert(0, str(API_DIR))

from app.config import DB_PATH  # noqa: E402

# ── Default model ──────────────────────────────────────────────────────────────
# CAMeL-BERT is trained on Classical + Modern Arabic, much better for fiqh texts.
# asafaya/bert-base-arabic = general Arabic (old default)
# CAMeL-Lab/bert-base-arabic-camelbert-ca = Classical Arabic (recommended for fiqh)
DEFAULT_MODEL = "CAMeL-Lab/bert-base-arabic-camelbert-ca"


def get_current_model(conn: sqlite3.Connection) -> str | None:
    """Read which model was used to generate the stored embeddings."""
    try:
        row = conn.execute(
            "SELECT value FROM embedding_meta WHERE key = 'model_name'"
        ).fetchone()
        return row[0] if row else None
    except sqlite3.OperationalError:
        return None


def set_current_model(conn: sqlite3.Connection, model_name: str) -> None:
    """Persist the model name into embedding_meta table."""
    conn.execute("""
        CREATE TABLE IF NOT EXISTS embedding_meta (
            key TEXT PRIMARY KEY,
            value TEXT NOT NULL
        )
    """)
    conn.execute(
        "INSERT OR REPLACE INTO embedding_meta (key, value) VALUES ('model_name', ?)",
        (model_name,)
    )


def main() -> None:
    parser = argparse.ArgumentParser(description="Generate semantic embeddings for fiqh.db chunks.")
    parser.add_argument(
        "--model",
        default=DEFAULT_MODEL,
        help=f"HuggingFace model name (default: {DEFAULT_MODEL})"
    )
    parser.add_argument(
        "--reset",
        action="store_true",
        help="Wipe all existing embeddings and regenerate from scratch (required when switching models)"
    )
    parser.add_argument(
        "--info",
        action="store_true",
        help="Print info about current embeddings and exit"
    )
    parser.add_argument(
        "--threads",
        type=int,
        default=4,
        help="PyTorch CPU thread count (default: 4)"
    )
    parser.add_argument(
        "--batch-size",
        type=int,
        default=96,
        help="Encoding batch size (default: 96)"
    )
    args = parser.parse_args()

    if not DB_PATH.exists():
        print(f"❌ Error: Database not found at {DB_PATH}. Run ingest first.")
        sys.exit(1)

    conn = sqlite3.connect(DB_PATH)
    conn.execute("PRAGMA journal_mode = WAL")
    conn.execute("PRAGMA synchronous = OFF")
    conn.execute("PRAGMA temp_store = MEMORY")
    conn.execute("PRAGMA cache_size = -128000")

    # ── Info mode ──────────────────────────────────────────────────────────────
    if args.info:
        current_model = get_current_model(conn)
        try:
            count = conn.execute("SELECT COUNT(*) FROM chunk_embeddings").fetchone()[0]
            total = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
            print(f"Embedding model : {current_model or 'unknown (old index)'}")
            print(f"Indexed         : {count:,} / {total:,} chunks ({count/total*100:.1f}%)")
        except Exception as e:
            print(f"No embeddings found: {e}")
        conn.close()
        return

    # ── Detect model mismatch ──────────────────────────────────────────────────
    current_model = get_current_model(conn)
    if current_model and current_model != args.model and not args.reset:
        print(f"⚠️  WARNING: Existing embeddings were generated with: {current_model}")
        print(f"   You are trying to add embeddings with          : {args.model}")
        print(f"   These are INCOMPATIBLE vector spaces!")
        print(f"   Run with --reset to wipe and rebuild from scratch:")
        print(f"   python scripts/generate_embeddings.py --model {args.model} --reset")
        conn.close()
        sys.exit(1)

    # ── Initialize / Reset embedding table ────────────────────────────────────
    conn.execute("""
        CREATE TABLE IF NOT EXISTS chunk_embeddings (
            chunk_id INTEGER PRIMARY KEY REFERENCES chunks(id),
            embedding BLOB NOT NULL
        )
    """)
    conn.commit()

    if args.reset:
        print(f"πŸ—‘οΈ  --reset: Dropping all existing embeddings...")
        conn.execute("DELETE FROM chunk_embeddings")
        conn.commit()
        print(f"   Wiped. Starting fresh with model: {args.model}")

    # ── Fetch chunks without embeddings ───────────────────────────────────────
    rows = conn.execute("""
        SELECT id, text_normalized FROM chunks
        WHERE id NOT IN (SELECT chunk_id FROM chunk_embeddings)
        ORDER BY id
    """).fetchall()

    if not rows:
        print(f"βœ… All chunks already have embeddings (model: {current_model or args.model})")
        conn.close()
        return

    total_chunks = len(rows)
    print(f"Found {total_chunks:,} chunk(s) needing semantic embeddings.")
    print(f"Model : {args.model}")
    print(f"Device: Optimized CPU ({args.threads} threads)")
    print(f"Batch : {args.batch_size}")

    # ── Load model ─────────────────────────────────────────────────────────────
    import torch
    torch.set_num_threads(args.threads)
    from sentence_transformers import SentenceTransformer

    device = "cpu"
    if torch.cuda.is_available():
        device = "cuda"
    elif torch.backends.mps.is_available():
        device = "mps"

    print(f"\nLoading model '{args.model}' on device: {device}...")
    t_load = time.time()
    model = SentenceTransformer(args.model, device=device)
    print(f"Model loaded in {time.time() - t_load:.1f}s\n")

    # Persist model name before we start writing vectors
    set_current_model(conn, args.model)
    conn.commit()

    # ── Encode in batches ──────────────────────────────────────────────────────
    t_start = time.time()
    committed = 0
    COMMIT_EVERY = 40  # batches

    for batch_idx, i in enumerate(range(0, total_chunks, args.batch_size)):
        batch = rows[i : i + args.batch_size]
        texts = [r[1] for r in batch]
        ids   = [r[0] for r in batch]

        encode_kwargs = {
            "show_progress_bar": False,
            "batch_size": args.batch_size,
            "convert_to_numpy": True,
        }

        if device == "cuda":
            with torch.cuda.amp.autocast():
                vectors = model.encode(texts, **encode_kwargs)
        else:
            vectors = model.encode(texts, **encode_kwargs)

        conn.executemany(
            "INSERT OR REPLACE INTO chunk_embeddings (chunk_id, embedding) VALUES (?, ?)",
            [
                (chunk_id, vector.astype("float32").tobytes())
                for chunk_id, vector in zip(ids, vectors)
            ]
        )

        if batch_idx % COMMIT_EVERY == 0:
            conn.commit()
            committed = i + len(batch)

        # Progress + ETA
        done = i + len(batch)
        elapsed = time.time() - t_start
        rate = done / elapsed if elapsed > 0 else 1
        remaining = (total_chunks - done) / rate if rate > 0 else 0
        eta_min = int(remaining // 60)
        eta_sec = int(remaining % 60)
        print(
            f"Progress: {done:>7,}/{total_chunks:,} ({done/total_chunks*100:5.1f}%)  "
            f"| {rate:,.0f} chunks/s  "
            f"| ETA {eta_min}m {eta_sec:02d}s"
        )

    conn.commit()
    conn.close()

    total_time = time.time() - t_start
    print(f"\nβ˜… Done! Indexed {total_chunks:,} chunks with '{args.model}'")
    print(f"  Total time: {int(total_time//60)}m {int(total_time%60):02d}s")
    print(f"\nNext step: re-deploy or restart the API server.")


if __name__ == "__main__":
    main()