Migration guide
Move existing data from Pinecone, Weaviate, or Qdrant to GraphANN™. The pattern is the same in every case: dump source → intermediate JSON → replay against the GraphANN™ ingest API.
Why migrate
GraphANN™'s storage architecture stores compressed text and a graph index instead of raw embedding vectors. Embeddings are recomputed on demand during search, only for the small set of nodes the graph traversal visits. The design target is up to 95% less storage than a traditional vector store for a typical knowledge base, with re-embedding cost amortised against query traffic. Single-binary deployment and multi-tenant isolation come along for the ride.
Migration shape
The flow is three stages, regardless of source:
- Dump: read documents and metadata from the source. Vectors themselves are not transferred. GraphANN™ re-embeds with the embedding server you've configured.
- Stage: write JSON Lines to disk. One document per line:
id,text,metadata. - Replay: POST batches to
/v1/tenants/{t}/indexes/{i}/documents. Use your source's primary id as the GraphANN documentidfor round-trip stability.
# Stage format — one JSON object per line (.jsonl)
{"id":"src-001","text":"Vector search recomputes...","metadata":{"src":"docs"}}
{"id":"src-002","text":"Multi-tenant isolation...","metadata":{"src":"docs"}}
{"id":"src-003","text":"Backup uses snapshots...","metadata":{"src":"runbook"}}
From Pinecone
Pinecone stores text in metadata by convention; pull it from there. If your index does not store the source text, you'll need to join against your system of record before staging.
# pip install pinecone-client
import json
from pinecone import Pinecone
pc = Pinecone(api_key="...")
index = pc.Index("my-index")
with open("staged.jsonl", "w") as out:
# Iterate ids in batches; fetch with metadata
for ids_batch in index.list(limit=100):
fetched = index.fetch(ids=ids_batch).vectors
for vid, v in fetched.items():
md = v.metadata or {}
text = md.pop("text", None) or md.pop("content", None)
if not text:
continue # skip vectors with no source text
out.write(json.dumps({
"id": vid,
"text": text,
"metadata": md,
}) + "\n")
From Weaviate
Weaviate exposes objects via the GraphQL or REST API. Pull the property you treat as the searchable text plus any structured metadata.
# pip install weaviate-client
import json, weaviate
client = weaviate.connect_to_local()
collection = client.collections.get("Document")
with open("staged.jsonl", "w") as out:
for obj in collection.iterator():
props = obj.properties
out.write(json.dumps({
"id": str(obj.uuid),
"text": props.get("text") or props.get("content", ""),
"metadata": {k: v for k, v in props.items()
if k not in ("text", "content")},
}) + "\n")
client.close()
From Qdrant
Qdrant's scroll API streams points. Use payload fields for text + metadata.
# pip install qdrant-client
import json
from qdrant_client import QdrantClient
q = QdrantClient(url="http://localhost:6333")
next_offset = None
with open("staged.jsonl", "w") as out:
while True:
points, next_offset = q.scroll(
collection_name="my-collection",
limit=500, offset=next_offset,
with_payload=True, with_vectors=False,
)
for p in points:
payload = p.payload or {}
text = payload.pop("text", None) or payload.pop("content", "")
if not text:
continue
out.write(json.dumps({
"id": str(p.id),
"text": text,
"metadata": payload,
}) + "\n")
if next_offset is None:
break
Replay into GraphANN™
One small Bash loop covers all three sources once they've staged to JSON Lines. Tune BATCH to the largest your network and embedding server tolerate (200–1000 typical).
#!/usr/bin/env bash
set -euo pipefail
GRAPHANN=${GRAPHANN:-http://localhost:38888}
TENANT=${TENANT:-t_acme}
INDEX=${INDEX:-i_migrated}
BATCH=${BATCH:-500}
INPUT=${1:-staged.jsonl}
# Idempotent tenant + index
curl -s -X POST "$GRAPHANN/v1/tenants" \
-H 'content-type: application/json' \
-d "{\"id\":\"$TENANT\",\"name\":\"migration\"}" >/dev/null
curl -s -X POST "$GRAPHANN/v1/tenants/$TENANT/indexes" \
-H 'content-type: application/json' \
-d "{\"id\":\"$INDEX\",\"name\":\"migrated\"}" >/dev/null
split -l "$BATCH" "$INPUT" batch_
for f in batch_*; do
body=$(jq -s '{documents: .}' "$f")
curl -s -X POST \
"$GRAPHANN/v1/tenants/$TENANT/indexes/$INDEX/documents" \
-H 'content-type: application/json' \
-d "$body" >/dev/null
echo "ingested: $f"
done
# Optional: compact live → base once everything is in
curl -s -X POST \
"$GRAPHANN/v1/tenants/$TENANT/indexes/$INDEX/compact"
Dual-run strategy
Run Pinecone (or your existing store) and GraphANN™ side-by-side for long enough to compare. Common sequence:
- Backfill GraphANN™ with the migration script.
- Wire your application to write to both stores on ingest.
- Wire reads to query both, treating the existing store as primary. Compare top-k overlap and per-result rank correlation.
- Once recall and latency match expectations on production traffic for a fixed window, flip reads to GraphANN™. Keep dual writes for a buffer period.
- Stop dual writes. Decommission the old store.
# Application pseudocode — read-shadow phase
def search(query, k=10):
primary = pinecone.query(vector=embed(query), top_k=k)
try:
shadow = graphann.post(
f"/v1/tenants/{T}/indexes/{I}/search",
json={"query": query, "k": k},
).json()["results"]
log_recall_overlap(primary, shadow)
except Exception as e:
metrics.incr("graphann.shadow.error")
return primary # primary still wins
Switching embedding models on existing data
Re-embedding moves an existing index from one embedding model to another. Two cases drive this in practice:
- Model upgrade. A newer or larger model is available and you want existing data to benefit from it.
- Model downgrade. A smaller model is good enough for your recall target and you want lower memory or latency.
Reembed CLI
The graphann reembed command rebuilds one index against a new embedding model. It reads every chunk's text, re-embeds with the new model, and rebuilds the searchable graph against the new vectors. The data directory must not be in use by a running server.
- Stop the server.
- Run
reembedonce per(tenant, index)pair. - Update the server's
--embedding-modelflag, restart, and run a few sample queries to confirm.
# 1. Stop the server
sudo systemctl stop graphann
# 2. Reembed one index. The pre-migration snapshot is deleted on success.
# Add --keep-backup if this is a high-risk migration and you want a
# rollback path.
sudo -u graphann graphann reembed \
--tenant=t_acme \
--index=i_product-docs \
--embedding-server=local_onnx \
--embedding-model=bge-micro-v2 \
--data-dir=/var/lib/graphann/data
# 3. Update --embedding-model in the service config and restart
sudo sed -i 's/--embedding-model bge-small-en-v1.5/--embedding-model bge-micro-v2/' \
/etc/systemd/system/graphann.service.d/override.conf
sudo systemctl daemon-reload
sudo systemctl start graphann
Looping every index in a deployment
Most production deployments have many indexes, so the practical shape is a loop driven by an enumeration query taken just before the server stops. By default graphann reembed deletes its per-index snapshot on success; the loop below relies on that. For high-risk migrations on a handful of large indexes, add --keep-backup to those invocations so you have an instant rollback path; pair it with the orphan-sweep configuration below to clean up afterwards.
#!/usr/bin/env bash
# Reembed every non-empty index from the current model to a new one.
# Run while the server is stopped. Outputs progress to /tmp/reembed.log.
set -u
DATA_DIR=/var/lib/graphann/data
NEW_MODEL=bge-micro-v2
SERVICE_USER=graphann
LOG=/tmp/reembed.log
# 1. Enumerate (tenant, index) pairs while the server is still running.
# Save the list, THEN stop the server.
graphann_url=http://127.0.0.1:38888
TENANTS=$(curl -s "$graphann_url/v1/tenants" | jq -r '.tenants[].id')
: > /tmp/reembed-pairs.txt
for t in $TENANTS; do
curl -s "$graphann_url/v1/tenants/$t/indexes" \
| jq -r --arg t "$t" '.indexes[] | select(.num_chunks > 0)
| "\($t) \(.id) \(.num_chunks)"' \
>> /tmp/reembed-pairs.txt
done
echo "$(date -Iseconds) enumerated $(wc -l </tmp/reembed-pairs.txt) indexes" | tee $LOG
sudo systemctl stop graphann
# 2. Loop. Run the CLI as the service user so file ownership stays correct.
total=$(wc -l </tmp/reembed-pairs.txt); i=0; fail=0
while IFS=' ' read -r tenant index chunks; do
i=$((i+1))
echo "$(date -Iseconds) [$i/$total] tenant=$tenant index=$index chunks=$chunks" \
| tee -a $LOG
if sudo -u $SERVICE_USER graphann reembed \
--tenant=$tenant --index=$index \
--embedding-server=local_onnx \
--embedding-model=$NEW_MODEL \
--data-dir=$DATA_DIR >>$LOG 2>&1; then
echo " OK" | tee -a $LOG
else
echo " FAILED rc=$?" | tee -a $LOG
fail=$((fail+1))
fi
done </tmp/reembed-pairs.txt
# 3. Restart on the new model.
sudo sed -i "s/--embedding-model [^ ]\+/--embedding-model $NEW_MODEL/" \
/etc/systemd/system/graphann.service.d/override.conf
sudo systemctl daemon-reload
sudo systemctl start graphann
echo "$(date -Iseconds) done. $((total-fail))/$total succeeded" | tee -a $LOG
exit $fail
Verifying the cutover
After the server restarts on the new model, sanity-check that searches return results and that the embedding pool initialises with the expected size. The two signals to watch:
- Server log line at startup reporting the active model and pool configuration. The
modelfield must match the value you set in--embedding-model. - A handful of
POST /searchrequests against the largest re-migrated indexes. Top-1 score and result text should be coherent with the query. Large drops in score versus the pre-migration baseline are the first symptom of a mismatch.
# Confirm the new model and pool came up clean
sudo journalctl -u graphann --since "30 seconds ago" --no-pager \
| grep -E "session pool|Starting"
# Health and readiness
curl -fs http://localhost:38888/health
curl -fs http://localhost:38888/ready
# Spot-check a search against a large re-migrated index
curl -s -X POST \
http://localhost:38888/v1/tenants/t_acme/indexes/i_product-docs/search \
-H 'content-type: application/json' \
-d '{"query":"how does search work","k":3}' \
| jq '.results[] | {score, text: (.text[:60])}'
Rolling back
Default reembed runs delete the snapshot on success. Once the loop completes, there is no per-index rollback path. For migrations where that's unacceptable, run the offending indexes individually with --keep-backup first, verify, then drop the flag for the bulk loop. With a snapshot present, rollback is: stop the server, remove the new directory, rename the <index>.pre-reembed.<timestamp> sibling back into place, restart against the old --embedding-model.
FAQ
What about my vector ids?
Pass your existing id as the document id. GraphANN preserves it as your stable external identifier across re-embedding, compaction, and migration. You can fetch and delete by external id via GET /chunks/{chunkID} and DELETE /documents/by-external-id.
What about my metadata?
Pass it as the metadata object on the document. It is stored verbatim per chunk and returned with every search hit. Schema-free; arbitrary JSON.
Do I need to bring my embedding model?
No. GraphANN™ re-embeds with whatever backend you've configured at boot (local_onnx, ollama, lmstudio, or openai). If you want to match the source store's recall profile, point GraphANN™ at the same model the source used.
How long does migration take?
It's a function of knowledge base size and embedding throughput. For a rough estimate, divide your knowledge base chunk count by your embedding server's tokens-per-second and add the network round-trip overhead per batch. The GraphANN™ side itself is not the bottleneck. Embedding is. Run a pilot on 10k documents first to calibrate.
Can I migrate without downtime?
Yes. That is what the dual-run strategy above is for. The cutover is a configuration flip in your application; GraphANN™ does not need to coordinate anything with the source store.