- Posted on
- • Artificial Intelligence
Artificial Intelligence Database Migration Guide
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
Artificial Intelligence Database Migration Guide (for Linux + Bash)
Your data is ready for AI—your database probably isn’t. Whether you want semantic search, retrieval-augmented generation (RAG), or smarter recommendations, you need fast vector search and metadata filtering. This guide shows you how to migrate existing data into an AI-ready database on Linux using Bash-first tooling and open-source components.
You’ll get a reproducible, zero-downtime-friendly migration pipeline: export from a relational DB, clean and chunk text, generate embeddings, load into a vector database, and validate the results—all from your terminal.
Why this matters
AI workloads need vectors, not just rows. Semantic search and RAG require embeddings (high-dimensional vectors) and specialized indexes.
Ad hoc migrations are risky. Poor chunking, bad encodings, or mismatched vector dimensions lead to slow queries and poor relevance.
Linux + Bash keeps it simple and auditable. Scripted steps are easy to automate, version, and roll back.
What you’ll build:
A scripted pipeline to move documents from a relational source into a vector database (Qdrant), with clean chunking and embedding.
A safe cutover strategy that supports blue/green collections and rollback.
Who this is for:
Linux users comfortable with Bash, psql, curl, and Python.
Teams migrating knowledge bases, tickets, docs, logs, or product catalogs to AI-ready search.
Prerequisites and installation
We’ll use:
psql to export data from PostgreSQL (adaptable to other sources)
Qdrant as the vector database (runs locally via Podman or Docker)
Python + sentence-transformers to compute embeddings
Install core tools (choose your distro):
- Debian/Ubuntu (apt):
sudo apt-get update
sudo apt-get install -y curl jq git python3 python3-venv python3-pip podman postgresql-client
- Fedora/RHEL/CentOS (dnf):
sudo dnf install -y curl jq git python3 python3-virtualenv python3-pip podman postgresql
- openSUSE/SLE (zypper):
sudo zypper refresh
sudo zypper install -y curl jq git python3 python3-venv python3-pip podman postgresql
Start Qdrant (vector DB) locally:
mkdir -p ./qdrant_storage
podman run -d --name qdrant -p 6333:6333 \
-v "$PWD/qdrant_storage:/qdrant/storage" \
qdrant/qdrant:v1.7.4
Verify Qdrant is up:
curl -s http://localhost:6333/ | jq
Set up Python environment:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install "sentence-transformers>=2.2" "qdrant-client>=1.7" "ujson>=5.8" "tqdm>=4.66"
Tip: First-time installs of sentence-transformers will download a CPU PyTorch wheel; allow a minute.
Step 1 — Assess and export your source data
Goal: Extract the text you want to search semantically (e.g., knowledge base articles).
Example: exporting from PostgreSQL into NDJSON (one JSON object per line).
Set a connection string:
export PGURI="postgresql://USER:PASSWORD@HOST:5432/DBNAME"
Quick profile:
psql "$PGURI" -Atc "SELECT count(*) FROM articles WHERE body IS NOT NULL;"
psql "$PGURI" -c "\d+ articles"
Export target fields to NDJSON:
psql "$PGURI" -c "\copy (
SELECT row_to_json(t)
FROM (
SELECT id, title, body, updated_at
FROM articles
WHERE body IS NOT NULL
) t
) TO 'export.ndjson'"
Sanity check:
head -n 3 export.ndjson | jq
wc -l export.ndjson
Notes:
Prefer NDJSON for streaming pipelines.
If you don’t use PostgreSQL, export from your DB to NDJSON/CSV and adapt the next steps.
Step 2 — Clean and chunk text
Chunking is critical. Too-small chunks hurt recall; too-large chunks hurt precision and embedding quality. Start simple: 700–1,000 tokens per chunk with overlap.
Create a small prep script:
cat > prepare_chunks.py << 'PY'
import json, re, sys, hashlib
from datetime import datetime
def sanitize_text(s: str) -> str:
if s is None:
return ""
s = re.sub(r"[\x00-\x1f\x7f]", " ", s) # remove control chars
s = re.sub(r"\s+", " ", s).strip() # normalize whitespace
# light PII masking example (extend as needed)
s = re.sub(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "[redacted_email]", s)
return s
def chunk_text(text: str, max_words=250, overlap_words=40):
# quick, token-agnostic chunking by words
words = text.split()
if not words:
return []
chunks = []
start = 0
while start < len(words):
end = min(start + max_words, len(words))
chunk = " ".join(words[start:end])
chunks.append(chunk)
if end == len(words):
break
start = max(0, end - overlap_words)
return chunks
in_path = sys.argv[1] if len(sys.argv) > 1 else "export.ndjson"
out_path = sys.argv[2] if len(sys.argv) > 2 else "chunks.ndjson"
model_name = "sentence-transformers/all-MiniLM-L6-v2"
chunk_method = "words250_overlap40"
outf = open(out_path, "w", encoding="utf-8")
with open(in_path, "r", encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
doc = json.loads(line)
title = sanitize_text(doc.get("title"))
body = sanitize_text(doc.get("body"))
text = (title + "\n\n" + body).strip()
chunks = chunk_text(text, max_words=250, overlap_words=40)
base_id = str(doc.get("id"))
for i, ch in enumerate(chunks):
cid = f"{base_id}::chunk::{i}"
payload = {
"source_id": base_id,
"chunk_index": i,
"title": title[:512],
"updated_at": doc.get("updated_at"),
"model": model_name,
"chunk_method": chunk_method,
"length": len(ch),
}
# store the raw text to embed later
rec = {"id": cid, "text": ch, "payload": payload}
outf.write(json.dumps(rec, ensure_ascii=False) + "\n")
outf.close()
print(f"Wrote chunks to {out_path}")
PY
Run it:
python prepare_chunks.py export.ndjson chunks.ndjson
head -n 2 chunks.ndjson | jq
Step 3 — Embed and load into a vector database (Qdrant)
We’ll embed each chunk using a small, fast model and upsert into Qdrant. The chosen model:
- all-MiniLM-L6-v2 (384 dimensions, cosine distance)
Create the loader:
cat > embed_and_load.py << 'PY'
import json, sys
from pathlib import Path
from tqdm import tqdm
from sentence_transformers import SentenceTransformer
from qdrant_client import QdrantClient
from qdrant_client.http import models as qm
chunks_path = sys.argv[1] if len(sys.argv) > 1 else "chunks.ndjson"
collection = sys.argv[2] if len(sys.argv) > 2 else "docs_v1"
# 1) Connect to Qdrant
client = QdrantClient(url="http://localhost:6333")
# 2) Load embedding model
model_name = "sentence-transformers/all-MiniLM-L6-v2"
model = SentenceTransformer(model_name)
dim = model.get_sentence_embedding_dimension()
# 3) Create collection if not exists
existing = [c.name for c in client.get_collections().collections]
if collection not in existing:
client.create_collection(
collection_name=collection,
vectors_config=qm.VectorParams(size=dim, distance=qm.Distance.COSINE),
)
print(f"Created collection {collection} with dim={dim}")
# 4) Stream chunks, batch-embed, and upsert
BATCH = 256
buf_ids, buf_vecs, buf_payloads = [], [], []
def flush():
if not buf_ids:
return
client.upsert(
collection_name=collection,
points=qm.Batch(
ids=buf_ids,
vectors=buf_vecs,
payloads=buf_payloads
)
)
buf_ids.clear(); buf_vecs.clear(); buf_payloads.clear()
lines = 0
with open(chunks_path, "r", encoding="utf-8") as f:
texts, records = [], []
for line in f:
if not line.strip():
continue
rec = json.loads(line)
texts.append(rec["text"])
records.append(rec)
lines += 1
if len(texts) >= BATCH:
vecs = model.encode(texts, normalize_embeddings=True).tolist()
for v, r in zip(vecs, records):
buf_ids.append(r["id"])
buf_vecs.append(v)
buf_payloads.append(r["payload"] | {"text_preview": r["text"][:200]})
flush()
texts, records = [], []
# final partial
if texts:
vecs = model.encode(texts, normalize_embeddings=True).tolist()
for v, r in zip(vecs, records):
buf_ids.append(r["id"])
buf_vecs.append(v)
buf_payloads.append(r["payload"] | {"text_preview": r["text"][:200]})
flush()
print(f"Upserted {lines} chunks into {collection}")
PY
Run it:
python embed_and_load.py chunks.ndjson docs_v1
Check collection stats:
curl -s localhost:6333/collections | jq
curl -s localhost:6333/collections/docs_v1 | jq
Step 4 — Validate semantic search end-to-end
Generate a query embedding and search Qdrant.
Compute a query vector:
VEC=$(python - <<'PY'
from sentence_transformers import SentenceTransformer
m = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
v = m.encode(["How do I reset my account password?"], normalize_embeddings=True)[0].tolist()
import json; print(json.dumps(v))
PY
)
Search Qdrant:
curl -s -X POST localhost:6333/collections/docs_v1/points/search \
-H 'Content-Type: application/json' \
-d "{
\"vector\": $VEC,
\"limit\": 5,
\"with_payload\": true
}" | jq '.result[] | {id, score, title:.payload.title, preview:.payload.text_preview}'
Add a metadata filter example (e.g., updated after a date):
curl -s -X POST localhost:6333/collections/docs_v1/points/search \
-H 'Content-Type: application/json' \
-d "{
\"vector\": $VEC,
\"limit\": 5,
\"with_payload\": true,
\"filter\": {\"must\": [{\"key\":\"updated_at\",\"range\":{\"gte\": \"2024-01-01\"}}]}
}" | jq '.result[] | {id, score, title:.payload.title}'
If results look irrelevant:
Revisit chunk size/overlap in prepare_chunks.py
Try a stronger model (e.g., BAAI/bge-small-en-v1.5) and rebuild a new collection (docs_v2)
Verify text cleaning preserves key entities and context
Step 5 — Plan cutover with confidence
Avoid risky “big switch” moments. Use a blue/green index strategy:
Build in parallel:
- Keep current search live.
- Create a new collection (e.g., docs_v2) with refined chunking/model.
Shadow test:
- Mirror a subset of queries to both collections.
- Compare top-k overlap and qualitative relevance offline.
Dual write:
- As new/updated documents arrive, update both collections.
Cutover:
- Toggle an environment variable or config flag to query docs_v2.
- Keep docs_v1 for rollback until confidence is high.
Clean up:
- Decommission old collection after a stability window.
Example commands:
# new collection name
python embed_and_load.py chunks.ndjson docs_v2
# monitor sizes
curl -s localhost:6333/collections | jq '.collections[] | {name:.name}'
# backup vector DB storage (since we used a host volume)
tar czf qdrant_storage_backup.tgz qdrant_storage/
Real-world example: Migrating a knowledge base
Source: articles(id, title, body, updated_at) in PostgreSQL
Target: Qdrant docs_v1 with all-MiniLM-L6-v2 (384-dim cosine)
Steps: 1) Export to NDJSON with row_to_json to preserve structural fidelity. 2) Clean text, mask emails, chunk by ~250 words with 40-word overlap. 3) Embed and upsert in batches of 256; keep a short text preview in payload for debugging. 4) Validate top-5 results for common support queries; filter by updated_at during incidents. 5) Iterate chunking/model, build docs_v2, shadow test, and cut over.
Outcome: Faster, more relevant search for support teams, and a repeatable pipeline for future updates.
Troubleshooting tips
Embedding dimension mismatch: Ensure the collection’s vector size matches the model’s embedding size. We derive it programmatically in the loader.
Slow performance: Increase batch size, ensure normalize_embeddings=True for cosine, and pin to a small model first.
Encoding issues: Use UTF-8 and sanitize control characters. Validate with
jqandiconvif needed.Storage growth: Keep only a short text_preview in payload; store full text elsewhere if compliance requires.
Conclusion and next steps
You now have a Linux-native, scriptable path to migrate data into an AI-ready database:
Export from your source DB
Clean and chunk text
Embed with a solid baseline model
Load into a vector database
Validate, iterate, and cut over safely
Next steps:
Parameterize your pipeline (env vars for collection/model names).
Add CI/CD to rebuild indexes on schema/model changes.
Introduce hybrid search (BM25 + vectors) if your use case needs keyword precision.
Layer on RAG or recommendations using your new vector index.
If this was helpful, try running the pipeline on a small subset of your production data today—and measure how semantic search improves your users’ outcomes. Then iterate and scale with confidence.