File size: 11,460 Bytes
257fe44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bd719d5
257fe44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bd719d5
 
 
 
 
257fe44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bd719d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257fe44
bd719d5
 
 
 
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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
"""
One-time migration script: Re-embed all Qdrant vectors using the new multilingual model.

Old model:  all-MiniLM-L6-v2             (English only)
New model:  paraphrase-multilingual-MiniLM-L12-v2  (50+ languages)

Both models produce 384-dim vectors, but they live in DIFFERENT vector spaces,
so every existing vector must be re-computed with the new model.

Usage (from project root):
    python -m backend.migrate_embeddings

What this does:
    1. Connects to MongoDB + Qdrant using .env credentials
    2. Deletes & recreates both Qdrant collections (prompt_memory, saved_prompt_vectors)
    3. Loads the new multilingual embedding model
    4. Re-embeds all prompt_logs docs   β†’ prompt_memory collection
    5. Re-embeds all saved_prompts docs β†’ saved_prompt_vectors collection
"""

import sys
import uuid
import time

from pymongo import MongoClient
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
from sentence_transformers import SentenceTransformer

from .core.config import settings
from .services.memory_service import point_id_for


# ─── CONFIG ────────────────────────────────────────────────────────────────────
PROMPT_MEMORY_COLLECTION = settings.COLLECTION_NAME        # "prompt_memory"
SAVED_PROMPTS_COLLECTION = "saved_prompt_vectors"
VECTOR_SIZE = 384
NEW_MODEL_NAME = settings.EMBEDDING_MODEL_NAME             # should already be the multilingual model


def _create_collection(qdrant: QdrantClient, name: str):
    """Delete if exists, then create fresh with 384-dim cosine + user_id index."""
    # Delete old collection
    try:
        qdrant.delete_collection(name)
        print(f"  πŸ—‘οΈ  Deleted old collection: '{name}'")
    except Exception:
        pass  # didn't exist

    # Create new
    qdrant.create_collection(
        collection_name=name,
        vectors_config=VectorParams(size=VECTOR_SIZE, distance=Distance.COSINE),
    )
    print(f"  βœ… Created collection: '{name}'")

    # Add user_id payload index
    try:
        qdrant.create_payload_index(
            collection_name=name,
            field_name="user_id",
            field_schema="keyword",
        )
    except Exception:
        pass


def main():
    print("=" * 60)
    print("πŸ”„ Embedding Migration Script")
    print(f"   New model: {NEW_MODEL_NAME}")
    print("=" * 60)

    # ── 1. Connect to MongoDB ──────────────────────────────────────────────
    mongo_uri = settings.MONGO_URI
    if not mongo_uri:
        print("❌ MONGO_URI not set in .env β€” cannot migrate.")
        sys.exit(1)

    print("\nπŸ“¦ Connecting to MongoDB...")
    mongo_client = MongoClient(mongo_uri, serverSelectionTimeoutMS=5000)
    try:
        mongo_client.admin.command("ping")
    except Exception as e:
        print(f"❌ MongoDB connection failed: {e}")
        sys.exit(1)

    db = mongo_client["prompt_engine_db"]
    prompt_logs_col = db["prompt_logs"]
    saved_prompts_col = db["saved_prompts"]

    prompt_logs_count = prompt_logs_col.count_documents({})
    saved_prompts_count = saved_prompts_col.count_documents({})
    print(f"   βœ… MongoDB connected β€” {prompt_logs_count} prompt logs, {saved_prompts_count} saved prompts")

    # ── 2. Connect to Qdrant ──────────────────────────────────────────────
    print("\nπŸ“¦ Connecting to Qdrant...")
    qdrant_url = settings.QDRANT_URL
    qdrant_api_key = settings.QDRANT_API_KEY

    if not qdrant_url or qdrant_url == ":memory:":
        print("❌ QDRANT_URL not set or is :memory: β€” cannot migrate a persistent instance.")
        sys.exit(1)

    qdrant = QdrantClient(url=qdrant_url, api_key=qdrant_api_key)
    print(f"   βœ… Qdrant connected ({qdrant_url})")

    # ── 3. Recreate collections ───────────────────────────────────────────
    print("\nπŸ”¨ Recreating Qdrant collections...")
    _create_collection(qdrant, PROMPT_MEMORY_COLLECTION)
    _create_collection(qdrant, SAVED_PROMPTS_COLLECTION)

    # ── 4. Load the new embedding model ───────────────────────────────────
    print(f"\n⏳ Loading embedding model: {NEW_MODEL_NAME}")
    start_load = time.time()
    try:
        model = SentenceTransformer(NEW_MODEL_NAME, backend="onnx")
        print(f"   βœ… Model loaded (ONNX backend) in {time.time() - start_load:.1f}s")
    except Exception:
        model = SentenceTransformer(NEW_MODEL_NAME)
        print(f"   βœ… Model loaded (default backend) in {time.time() - start_load:.1f}s")

    def embed(text: str):
        return model.encode(text, convert_to_numpy=True).tolist()

    # ── 5. Re-embed prompt_logs β†’ prompt_memory ───────────────────────────
    print(f"\nπŸ“ Re-embedding {prompt_logs_count} prompt logs β†’ '{PROMPT_MEMORY_COLLECTION}'...")
    success_logs = 0
    skipped_logs = 0
    batch_points = []
    BATCH_SIZE = 50

    for i, doc in enumerate(prompt_logs_col.find({})):
        original = doc.get("original", "")
        enhanced = doc.get("enhanced", "")
        user_id = doc.get("user_id", "")

        if not original or not user_id:
            skipped_logs += 1
            continue

        try:
            vec = embed(original)
            point_id = uuid.uuid4().int % (2**63)
            batch_points.append(PointStruct(
                id=point_id,
                vector=vec,
                payload={
                    "user_id": user_id,
                    "original_prompt": original,
                    "refined_prompt": enhanced or "",
                },
            ))
            success_logs += 1

            # Flush batch
            if len(batch_points) >= BATCH_SIZE:
                qdrant.upsert(collection_name=PROMPT_MEMORY_COLLECTION, points=batch_points)
                batch_points = []
                print(f"   ... processed {i + 1}/{prompt_logs_count}")

        except Exception as e:
            print(f"   ⚠️  Failed to embed prompt log (id={doc.get('_id')}): {e}")
            skipped_logs += 1

    # Flush remaining
    if batch_points:
        qdrant.upsert(collection_name=PROMPT_MEMORY_COLLECTION, points=batch_points)
        batch_points = []

    print(f"   βœ… Done β€” {success_logs} embedded, {skipped_logs} skipped")

    # ── 6. Re-embed saved_prompts β†’ saved_prompt_vectors ──────────────────
    print(f"\nπŸ“ Re-embedding {saved_prompts_count} saved prompts β†’ '{SAVED_PROMPTS_COLLECTION}'...")
    success_saved = 0
    skipped_saved = 0

    for i, doc in enumerate(saved_prompts_col.find({})):
        content = doc.get("content", "")
        user_id = doc.get("user_id", "")
        mongo_id = str(doc["_id"])

        if not content or not user_id:
            skipped_saved += 1
            continue

        try:
            vec = embed(content)
            # Must match memory_service.point_id_for(). This was
            # `abs(hash(mongo_id)) % (2**63)`, which is randomised per process,
            # so migrated points landed at ids the running server could never
            # recompute β€” the same defect the app itself had.
            point_id = point_id_for(mongo_id)
            batch_points.append(PointStruct(
                id=point_id,
                vector=vec,
                payload={
                    "user_id": user_id,
                    "mongo_id": mongo_id,
                    "content": content,
                    "title": doc.get("title", "") or "",
                    "tags": doc.get("tags", []) or [],
                },
            ))
            success_saved += 1

            if len(batch_points) >= BATCH_SIZE:
                qdrant.upsert(collection_name=SAVED_PROMPTS_COLLECTION, points=batch_points)
                batch_points = []
                print(f"   ... processed {i + 1}/{saved_prompts_count}")

        except Exception as e:
            print(f"   ⚠️  Failed to embed saved prompt (id={mongo_id}): {e}")
            skipped_saved += 1

    if batch_points:
        qdrant.upsert(collection_name=SAVED_PROMPTS_COLLECTION, points=batch_points)

    print(f"   βœ… Done β€” {success_saved} embedded, {skipped_saved} skipped")

    # ── 7. Summary ────────────────────────────────────────────────────────
    print("\n" + "=" * 60)
    print("βœ… MIGRATION COMPLETE")
    print(f"   Model:           {NEW_MODEL_NAME}")
    print(f"   prompt_memory:   {success_logs} vectors ({skipped_logs} skipped)")
    print(f"   saved_prompts:   {success_saved} vectors ({skipped_saved} skipped)")
    print("=" * 60)
    print("\nYou can now restart the server:")
    print("   python -m uvicorn backend.main:app --reload")


def purge_orphans():
    """
    Delete saved-prompt vectors whose Mongo document no longer exists.

    Needed once, for data written before point ids became deterministic. Those
    points were created with a randomised hash, so DELETE /saved-prompts/{id}
    never reached them: the document went away and the vector stayed, still
    matching similarity searches and still being spliced into that user's
    enhancements as "### RELATED SAVED PROMPTS".

    Deleting a saved prompt now cleans up its own vector by payload filter, so
    this only has to cover prompts that were already deleted in the past β€” no
    future user action will ever target those points again.

        python -m backend.migrate_embeddings --purge-orphans
    """
    from qdrant_client.models import PointIdsList

    mongo = MongoClient(settings.MONGO_URI, serverSelectionTimeoutMS=5000)
    mongo.admin.command("ping")
    db = mongo["prompt_engine_db"]
    qdrant = QdrantClient(url=settings.QDRANT_URL, api_key=settings.QDRANT_API_KEY)

    live_ids = {str(doc["_id"]) for doc in db["saved_prompts"].find({}, {"_id": 1})}
    print(f"πŸ“„ {len(live_ids)} saved prompts in Mongo")

    orphan_ids, scanned, offset = [], 0, None
    while True:
        points, offset = qdrant.scroll(
            collection_name=SAVED_PROMPTS_COLLECTION,
            limit=256, offset=offset, with_payload=True, with_vectors=False,
        )
        if not points:
            break
        for p in points:
            scanned += 1
            if (p.payload or {}).get("mongo_id") not in live_ids:
                orphan_ids.append(p.id)
        if offset is None:
            break

    print(f"πŸ” scanned {scanned} vectors β€” {len(orphan_ids)} orphaned")
    if not orphan_ids:
        print("βœ… nothing to clean up")
        return

    for i in range(0, len(orphan_ids), 256):
        qdrant.delete(
            collection_name=SAVED_PROMPTS_COLLECTION,
            points_selector=PointIdsList(points=orphan_ids[i:i + 256]),
        )
    print(f"πŸ—‘οΈ  deleted {len(orphan_ids)} orphaned vectors")


if __name__ == "__main__":
    if "--purge-orphans" in sys.argv:
        purge_orphans()
    else:
        main()