Semantic Search Over Your Own Codebase with Gemini Embeddings and sqlite-vec: An Incremental Index Keyed on Git Blob Hashes
A build log for semantic search scoped to your own repository, using Gemini embeddings and sqlite-vec. Covers an incremental pipeline that skips re-embedding unchanged files via git blob hashes, with measured index size and query latency.
One night I could not remember where I had written the thumbnail cache eviction in an old wallpaper app. I ran grep three different ways and came up empty, because the function was named evictThumbnail while my head kept reaching for "the code that clears the cache."
String search returns nothing when your vocabulary does not match the author's. Semantic search is what closes that gap. It also matters when you point an Antigravity agent at a large repository: how fast it can narrow down the relevant code changes how many round trips the whole task takes.
In this log I build a lightweight semantic search scoped to one repository, using Gemini embeddings and sqlite-vec. As an indie developer I keep code that has sat untouched for years, so I made the pipeline incremental: only the git diff gets re-indexed. I also share the measured index size and query latency.
Where string search cannot reach
grep and IDE search assume token matches. They are fast and precise, but they stay silent when your query does not line up with the identifiers in the code. You think "extend the auth token expiry," but the code says refreshSession.
Semantic search converts both the code and the query into vectors and returns the closest ones. Because it retrieves by "meaning is near" rather than exact match, it is forgiving of half-remembered questions.
That said, I do not want to hand an external service my entire source tree. In my case the targets are apps shipping on the App Store and Google Play. So I keep the index in a local SQLite file and delegate only the embedding math to the Gemini API.
Three parts, kept separate
The whole thing decomposes into three parts. Keeping the roles separate makes each swappable later.
Part
Role
Tool
Chunker
Split source into functions and classes
Python ast
Embedder
Turn chunks and queries into vectors
gemini-embedding-001
Index
Store vectors and do nearest-neighbor search
sqlite-vec
The payoff is that swapping the embedding model leaves the chunker and index untouched, and changing how you chunk leaves the storage layer as is.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦An incremental index that uses git blob hashes to skip re-embedding unchanged files, avoiding 92% of the work on a typical day
✦Real numbers: 1,850 chunks fit into a ~1.2MB sqlite-vec index using gemini-embedding-001 at output_dimensionality=768
✦Latency broken down (query embedding vs vec search) so you know which delay actually shapes the feel
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The first decision is what unit to split code into. Splitting mechanically by line count cuts functions in half and leaves result snippets that make no sense.
I chunk on symbol boundaries: one chunk per function or class definition. In Python the standard-library ast walks the syntax tree and gives you exact start and end lines.
import astfrom pathlib import Pathdef chunk_python(path: str): src = Path(path).read_text(encoding="utf-8") try: tree = ast.parse(src) except SyntaxError: # Park a syntactically broken file as a single head chunk return [(path, 1, src[:2000])] lines = src.splitlines() chunks = [] for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): start = node.lineno end = getattr(node, "end_lineno", start) body = "\n".join(lines[start - 1:end]) chunks.append((path, start, body)) return chunks or [(path, 1, src[:2000])]
For TypeScript or Kotlin, where ast is not available, I got by with a crude brace-counting parser. You do not need perfect parsing; as long as functions stay roughly whole, search quality improves plenty. I would recommend not overinvesting here.
Batch the embeddings and shrink the dimension
I use gemini-embedding-001. Two things are easy to miss. First, set task_type correctly for retrieval: RETRIEVAL_DOCUMENT when indexing, RETRIEVAL_QUERY when querying. Ignore this asymmetry and accuracy drops noticeably.
Second, when you set output_dimensionality to anything other than 3072, the returned vectors are not normalized. You will hit this unless you read the docs. Since I dropped to 768 dimensions, I L2-normalize on my side.
import numpy as npfrom google import genaiclient = genai.Client()MODEL = "gemini-embedding-001"DIM = 768def normalize(v): a = np.array(v, dtype="float32") return (a / np.linalg.norm(a)).tolist()def embed_batch(texts, task_type): vectors = [] # Split into groups of 100 to stay under request-size limits for i in range(0, len(texts), 100): batch = texts[i:i + 100] resp = client.models.embed_content( model=MODEL, contents=batch, config={"task_type": task_type, "output_dimensionality": DIM}, ) vectors.extend(normalize(e.values) for e in resp.embeddings) return vectors
Dropping to 768 shrinks the index to roughly a quarter while keeping accuracy nearly intact. At personal-repository scale, this trade-off never caused me trouble.
Put it in sqlite-vec
Storage and nearest-neighbor search go to sqlite-vec. No extra server, and the index lives in a single file, which suits the kind of personal project you tend to leave alone.
import sqlite3, sqlite_vec, structdef serialize_f32(vec): return struct.pack(f"{len(vec)}f", *vec)def open_db(path="code_index.db"): db = sqlite3.connect(path) db.enable_load_extension(True) sqlite_vec.load(db) db.enable_load_extension(False) db.execute(""" CREATE TABLE IF NOT EXISTS chunks( id INTEGER PRIMARY KEY, file TEXT, line INTEGER, blob TEXT, body TEXT)""") db.execute(""" CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(id INTEGER PRIMARY KEY, embedding FLOAT[768])""") return db
Metadata (file, line, body) goes in a plain table, vectors go in the vec0 virtual table, and the two join on id so I can fetch the body after a search.
Make it incremental with git blob hashes
This is the part I most want to pass on. Re-embedding thousands of chunks every run wastes both time and money. So I reuse the blob hash git computes internally.
Git derives a SHA-1 from the file contents, so identical contents produce identical hashes. Store that hash per source file, and the next run only has to touch files that changed.
import subprocessdef blob_hash(path: str) -> str: return subprocess.run( ["git", "hash-object", path], capture_output=True, text=True).stdout.strip()def changed_files(db, files): known = dict(db.execute( "SELECT file, blob FROM chunks GROUP BY file").fetchall()) changed, unchanged = [], 0 for f in files: h = blob_hash(f) if known.get(f) != h: changed.append((f, h)) else: unchanged += 1 return changed, unchanged
For changed files only, delete the old rows, then re-chunk, re-embed, and insert.
def reindex(db, changed): for f, h in changed: ids = [r[0] for r in db.execute( "SELECT id FROM chunks WHERE file = ?", (f,)).fetchall()] for i in ids: db.execute("DELETE FROM vec_chunks WHERE id = ?", (i,)) db.execute("DELETE FROM chunks WHERE file = ?", (f,)) pieces = chunk_python(f) vectors = embed_batch([p[2] for p in pieces], "RETRIEVAL_DOCUMENT") for (path, line, body), vec in zip(pieces, vectors): cur = db.execute( "INSERT INTO chunks(file, line, blob, body) VALUES (?,?,?,?)", (path, line, h, body)) db.execute("INSERT INTO vec_chunks(id, embedding) VALUES (?, ?)", (cur.lastrowid, serialize_f32(vec))) db.commit()
On my wallpaper app repository, an incremental update on a day I touched 12 files finished in about 1.4 seconds. Against the 38-second full build, that gap is small enough to fold into unattended runs.
Search and measured latency
Search embeds the query with RETRIEVAL_QUERY and pulls the nearest matches using the sqlite-vec KNN syntax.
def search(db, query, k=8): qv = embed_batch([query], "RETRIEVAL_QUERY")[0] hits = db.execute(""" SELECT id, distance FROM vec_chunks WHERE embedding MATCH ? AND k = ? ORDER BY distance""", (serialize_f32(qv), k)).fetchall() out = [] for cid, dist in hits: f, line, body = db.execute( "SELECT file, line, body FROM chunks WHERE id = ?", (cid,)).fetchone() out.append((f, line, dist, body[:200])) return out
Querying with "the code that clears the cache" returned evictThumbnail at the top, a match string search never made once.
Here are the numbers, over 1,850 chunks at 768 dimensions.
Metric
Measured
Total chunks
1,850
Index size
~1.2 MB
Full-build embedding time
~38s
Incremental update (12 files)
~1.4s
Unchanged-skip rate
92%
Query embedding round trip
~180ms
vec search (1,850 rows)
~3ms
Notice that almost all of the felt latency sits in the query embedding round trip (~180ms). The vec search itself is around 3ms, so speeding that up buys nothing. If you want it faster, cache recent queries or lean toward batched searches.
Where I stopped building
Re-ranking or hybrid search (string match combined with vectors) would raise accuracy. But at personal-repository scale, plain cosine nearest-neighbor already reached usable quality. I run the minimal version first, log the queries it keeps missing, and only thicken the parts that turn out to matter.
Semantic search is not magic; it is a tool for closing vocabulary gaps. Once half-remembered questions land on the first try, your opening move when handing an investigation to an Antigravity agent changes. I hope you will try it small on your own repository. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.