File size: 5,323 Bytes
744603c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
r"""
One-off backfill: compute embeddings for rows in the DuckDB that are missing them.

Run once (with the app AND DBeaver closed, so the file isn't locked):
  d:\2026\July\Agentic-RAG-with-LangGraph-and-Ollama\.venv\Scripts\python.exe backfill_embeddings.py
"""

import os
import time
import duckdb
import pandas as pd
from dotenv import load_dotenv

load_dotenv()


def env(key):
    val = os.getenv(key)
    if val is None or val == "":
        raise RuntimeError(f"Missing required config '{key}' in .env")
    return val


# All config comes from .env (no hardcoded values here).
DUCKDB_PATH = env("DUCKDB_PATH")
TABLE = env("TABLE")
HNSW_INDEX = f"{TABLE}_embedding_hnsw"

# Subset + throttling for the Gemini free tier (100 embed requests/minute).
MAX_ROWS = int(env("EMBED_MAX_ROWS"))   # cap rows per run
CHUNK = int(env("EMBED_CHUNK"))         # texts per API request
SLEEP = float(env("EMBED_SLEEP"))       # seconds between chunks

# Must match duckdb_app.py's embedding backend (set in .env).
EMBED_PROVIDER = env("EMBED_PROVIDER")
EMBED_MODEL = env("EMBED_MODEL")
EMBED_DIM = int(env("EMBED_DIM"))


def embed_documents(texts):
    if EMBED_PROVIDER == "google":
        from langchain_google_genai import GoogleGenerativeAIEmbeddings
        emb = GoogleGenerativeAIEmbeddings(model=EMBED_MODEL,
                                           output_dimensionality=EMBED_DIM)
        return emb.embed_documents(list(texts))
    from sentence_transformers import SentenceTransformer
    model = SentenceTransformer(EMBED_MODEL)
    return [v.tolist() for v in model.encode(list(texts), normalize_embeddings=True)]


def build_embed_text(r):
    return (
        f"{r['make']} {r['model']} {r['modelYear']} | "
        f"manufacturer: {r['manufacturer']} | component: {r['component']} | "
        f"vin: {r['vin']} | crash: {r['crash']} | fire: {r['fire']} | "
        f"injuries: {r['numberOfInjuries']} | deaths: {r['numberOfDeaths']} | "
        f"filed: {r['dateComplaintFiled']} | summary: {r['summary']}"
    )


def main():
    con = duckdb.connect(DUCKDB_PATH)
    con.execute("INSTALL vss; LOAD vss;")
    con.execute("SET hnsw_enable_experimental_persistence = true;")

    # Ensure columns exist (in case this DB predates them).
    con.execute(f"ALTER TABLE {TABLE} ADD COLUMN IF NOT EXISTS embed_text VARCHAR")
    con.execute(f"ALTER TABLE {TABLE} ADD COLUMN IF NOT EXISTS embedding FLOAT[{EMBED_DIM}]")

    # If the embedding dimension changed, recreate the column to match.
    row = con.execute(
        "SELECT data_type FROM information_schema.columns "
        "WHERE table_name = ? AND column_name = 'embedding'", [TABLE]).fetchone()
    want = f"FLOAT[{EMBED_DIM}]"
    if row and row[0] != want:
        print(f"Embedding dim changed {row[0]} -> {want}; recreating column…")
        con.execute(f"DROP INDEX IF EXISTS {HNSW_INDEX}")
        con.execute(f"ALTER TABLE {TABLE} DROP COLUMN embedding")
        con.execute(f"ALTER TABLE {TABLE} ADD COLUMN embedding {want}")

    # Ensure the HNSW vector index exists (UPDATEs below maintain it).
    con.execute(f"""
        CREATE INDEX IF NOT EXISTS {HNSW_INDEX}
        ON {TABLE} USING HNSW (embedding) WITH (metric = 'cosine')
    """)

    # Subset: only embed up to MAX_ROWS per run (free-tier friendly).
    todo = con.execute(f"""
        SELECT odiNumber, make, model, modelYear, manufacturer, component, summary,
               vin, crash, fire, numberOfInjuries, numberOfDeaths, dateComplaintFiled
        FROM {TABLE}
        WHERE embedding IS NULL
        LIMIT {MAX_ROWS}
    """).fetchdf()

    if todo.empty:
        print("Nothing to backfill — all selected rows already have embeddings.")
        return

    todo["embed_text"] = todo.apply(build_embed_text, axis=1)
    total = len(todo)
    print(f"Backfilling {total} rows (subset, MAX_ROWS={MAX_ROWS}) with "
          f"{EMBED_PROVIDER} ({EMBED_MODEL}), chunk={CHUNK}…")

    done = 0
    # Process in small chunks, committing each so progress survives a quota error.
    for start in range(0, total, CHUNK):
        batch = todo.iloc[start:start + CHUNK]
        try:
            vecs = embed_documents(batch["embed_text"].tolist())
        except Exception as e:
            print(f"  Stopped at {done}/{total} — {type(e).__name__}: {e}")
            print("  Re-run later to continue where it left off (resumable).")
            break

        updates = pd.DataFrame({
            "odiNumber": batch["odiNumber"].values,
            "embed_text": batch["embed_text"].values,
            "embedding": list(vecs),
        })
        con.register("upd", updates)
        con.execute(f"""
            UPDATE {TABLE} AS t
            SET embed_text = u.embed_text, embedding = u.embedding
            FROM upd AS u WHERE t.odiNumber = u.odiNumber
        """)
        con.unregister("upd")
        done += len(batch)
        print(f"  embedded {done}/{total}")
        time.sleep(SLEEP)   # throttle to respect the rate limit

    embedded = con.execute(
        f"SELECT COUNT(*) FROM {TABLE} WHERE embedding IS NOT NULL").fetchone()[0]
    remaining = con.execute(
        f"SELECT COUNT(*) FROM {TABLE} WHERE embedding IS NULL").fetchone()[0]
    print(f"Done. Embedded so far: {embedded} · still NULL: {remaining}")


if __name__ == "__main__":
    main()