KB Vectorization (Phase 1) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax.
Goal: Embed every OCR’d manual section into a Qdrant vector DB with per-chunk metadata (manual, page URL, images) and provide semantic search.
Architecture: A ~/kb-vectors/ Python project (CPU-torch + sentence-transformers bge-large-en-v1.5 + qdrant-client) reads the knowledgebase’s per-section markdown, sub-chunks it, embeds it, and upserts into a Qdrant docker container. Re-runnable/idempotent per manual.
Tech Stack: Python venv (CPU torch, sentence-transformers, qdrant-client), Qdrant (docker), the knowledgebase docs at ~/knowledgebase/manuals-src/docs/.
Design: 2026-07-24-kb-vectorize-design
Global Constraints
- All code under
/home/levander/kb-vectors/on telep-mainframe. Runs aslevander(Qdrant via docker; levander is in the docker group). - No comments/docstrings/annotations in any code (user’s global rule). Self-explanatory naming only.
- Do not run
git commit. Each task ends in verification. - CPU torch (install from the cpu index) — sidesteps the CUDA-13-vs-driver-550 trap we hit with marker AND avoids GPU contention with the OCR queue + Frigate. Embedding a few thousand chunks on the 12900K is fine.
- Embeddings:
BAAI/bge-large-en-v1.5, 1024-dim, cosine. Passages: no prefix. Queries: prefix"Represent this sentence for searching relevant passages: ". Normalize embeddings. - Qdrant: container bound to
127.0.0.1:6333(REST) +127.0.0.1:6334(gRPC), storage volume~/kb-vectors/qdrant-storage. Collectionmanuals. - Deterministic point IDs (uuid5 of folder/manual/page/chunk_index) so re-index replaces, never duplicates.
- Read-only over
~/knowledgebase/manuals-src/docs/— must not modify the live KB. - Run commands on the box:
/usr/bin/ssh levander@telep-mainframe. On this Mac baresshis broken — always/usr/bin/ssh.
Test command: cd /home/levander/kb-vectors && venv/bin/python -m unittest <module> -v
Task 1: Project setup — venv, deps, Qdrant container
Files:
-
Create:
/home/levander/kb-vectors/docker-compose.yml -
Step 1: Create project + venv + CPU torch + deps
/usr/bin/ssh levander@telep-mainframe 'mkdir -p /home/levander/kb-vectors/qdrant-storage && cd /home/levander/kb-vectors && python3 -m venv venv && venv/bin/pip install -q --upgrade pip && venv/bin/pip install -q torch --index-url https://download.pytorch.org/whl/cpu && venv/bin/pip install -q sentence-transformers qdrant-client && venv/bin/python -c "import torch, sentence_transformers, qdrant_client; print(\"torch\", torch.__version__, \"| cuda\", torch.cuda.is_available(), \"| st ok | qc ok\")"'Expected: prints torch version, cuda False (CPU build — intended), and no import errors. (Large download; torch CPU ~200MB, sentence-transformers deps.)
- Step 2: Start Qdrant as a docker container
/usr/bin/ssh levander@telep-mainframe 'cat > /home/levander/kb-vectors/docker-compose.yml <<'"'"'YML'"'"'
services:
qdrant:
image: qdrant/qdrant:latest
container_name: kb-qdrant
restart: unless-stopped
ports:
- "127.0.0.1:6333:6333"
- "127.0.0.1:6334:6334"
volumes:
- /home/levander/kb-vectors/qdrant-storage:/qdrant/storage
YML
cd /home/levander/kb-vectors && docker compose up -d && sleep 5 && curl -s http://127.0.0.1:6333/ | head -c 200; echo; docker ps --filter name=kb-qdrant --format "{{.Names}} {{.Status}}"'Expected: a Qdrant JSON banner (version info) and kb-qdrant Up.
- Step 3: Verify the Python client reaches Qdrant
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/kb-vectors && venv/bin/python -c "
from qdrant_client import QdrantClient
c = QdrantClient(host=\"127.0.0.1\", port=6333)
print(\"collections:\", c.get_collections())
"'Expected: prints an (empty) collections list, no connection error.
Task 2: chunker.py — read a manual dir into token-bounded chunks
Files:
- Create:
/home/levander/kb-vectors/chunker.py - Test:
/home/levander/kb-vectors/test_chunker.py
Interfaces:
-
Produces:
heading_of(text) -> str— first#/##heading text, else""images_of(text) -> list[str]— therefs in orderchunk_text(text, max_words=350, overlap=50) -> list[str]— word windows with overlap;[]if empty; single chunk if shortchunk_manual(manual_dir, folder, manual) -> list[dict]— per section file, one dict per chunk with keys:folder, manual, manual_id, page, page_url, heading, images, text, chunk_index
-
Step 1: Write the failing test
Create /home/levander/kb-vectors/test_chunker.py:
import os
import tempfile
import unittest
from chunker import chunk_manual, chunk_text, heading_of, images_of
SECTION = """## Brake Bleeding
Loosen the bleeder screw.

Tighten to spec.

"""
class TestHelpers(unittest.TestCase):
def test_heading(self):
self.assertEqual(heading_of(SECTION), "Brake Bleeding")
def test_heading_none(self):
self.assertEqual(heading_of("no heading here"), "")
def test_images(self):
self.assertEqual(images_of(SECTION), ["_page_12_Figure_1.jpeg", "_page_12_Picture_3.jpeg"])
def test_chunk_short_single(self):
self.assertEqual(chunk_text("a b c"), ["a b c"])
def test_chunk_empty(self):
self.assertEqual(chunk_text(" "), [])
def test_chunk_long_overlap(self):
text = " ".join(str(i) for i in range(800))
chunks = chunk_text(text, max_words=350, overlap=50)
self.assertGreaterEqual(len(chunks), 2)
first = chunks[0].split()
second = chunks[1].split()
self.assertEqual(first[-50:], second[:50])
class TestChunkManual(unittest.TestCase):
def _manual(self):
base = tempfile.mkdtemp()
d = os.path.join(base, "suzuki-vitara", "5door-supplement")
os.makedirs(d)
with open(os.path.join(d, "001-intro.md"), "w", encoding="utf-8") as h:
h.write(SECTION)
with open(os.path.join(d, "readme.txt"), "w") as h:
h.write("ignore me")
return d
def test_chunk_manual(self):
d = self._manual()
chunks = chunk_manual(d, "suzuki-vitara", "5door-supplement")
self.assertEqual(len(chunks), 1)
c = chunks[0]
self.assertEqual(c["manual_id"], "suzuki-vitara/5door-supplement")
self.assertEqual(c["page"], "001-intro")
self.assertEqual(c["page_url"], "suzuki-vitara/5door-supplement/001-intro/")
self.assertEqual(c["heading"], "Brake Bleeding")
self.assertIn("_page_12_Figure_1.jpeg", c["images"])
self.assertEqual(c["chunk_index"], 0)
def test_ignores_non_md(self):
d = self._manual()
chunks = chunk_manual(d, "suzuki-vitara", "5door-supplement")
self.assertTrue(all(c["page"] != "readme" for c in chunks))
if __name__ == "__main__":
unittest.main()- Step 2: Run the test to verify it fails
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/kb-vectors && venv/bin/python -m unittest test_chunker -v'Expected: FAIL with ModuleNotFoundError: No module named 'chunker'
- Step 3: Write the implementation
Create /home/levander/kb-vectors/chunker.py:
import os
import re
_IMG = re.compile(r"!\[\]\(([^)]+)\)")
def heading_of(text):
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("# ") or stripped.startswith("## "):
return stripped.lstrip("#").strip()
return ""
def images_of(text):
return _IMG.findall(text)
def chunk_text(text, max_words=350, overlap=50):
words = text.split()
if not words:
return []
if len(words) <= max_words:
return [text.strip()]
step = max_words - overlap
chunks = []
index = 0
while index < len(words):
chunks.append(" ".join(words[index:index + max_words]))
if index + max_words >= len(words):
break
index += step
return chunks
def chunk_manual(manual_dir, folder, manual):
results = []
for name in sorted(os.listdir(manual_dir)):
if not name.endswith(".md"):
continue
with open(os.path.join(manual_dir, name), encoding="utf-8") as handle:
text = handle.read()
page = name[:-3]
heading = heading_of(text)
images = images_of(text)
page_url = folder + "/" + manual + "/" + page + "/"
for index, chunk in enumerate(chunk_text(text)):
if not chunk.strip():
continue
results.append(
{
"folder": folder,
"manual": manual,
"manual_id": folder + "/" + manual,
"page": page,
"page_url": page_url,
"heading": heading,
"images": images,
"text": chunk,
"chunk_index": index,
}
)
return results- Step 4: Run the test to verify it passes
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/kb-vectors && venv/bin/python -m unittest test_chunker -v'Expected: OK — 9 tests pass.
- Step 5: Verify against a real manual
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/kb-vectors && venv/bin/python -c "
from chunker import chunk_manual
c = chunk_manual(\"/home/levander/knowledgebase/manuals-src/docs/suzuki-vitara/5door-supplement\", \"suzuki-vitara\", \"5door-supplement\")
print(len(c), \"chunks\")
print(\"sample:\", {k: c[0][k] for k in [\"page_url\",\"heading\",\"chunk_index\"]}, \"images:\", len(c[0][\"images\"]))
"'Expected: a chunk count (more than the 59 section files, since long sections split) and a sensible sample.
Task 3: embed.py — local bge embeddings
Files:
- Create:
/home/levander/kb-vectors/embed.py
Interfaces:
-
Produces:
embed_passages(texts) -> list[list[float]]— normalized, no prefixembed_query(text) -> list[float]— normalized, with the bge query instruction prefixDIM = 1024
-
Step 1: Write the implementation
Create /home/levander/kb-vectors/embed.py:
from sentence_transformers import SentenceTransformer
MODEL_NAME = "BAAI/bge-large-en-v1.5"
QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
DIM = 1024
_model = None
def _get_model():
global _model
if _model is None:
_model = SentenceTransformer(MODEL_NAME, device="cpu")
return _model
def embed_passages(texts):
vectors = _get_model().encode(texts, normalize_embeddings=True, batch_size=16)
return [vector.tolist() for vector in vectors]
def embed_query(text):
vector = _get_model().encode([QUERY_PREFIX + text], normalize_embeddings=True)[0]
return vector.tolist()- Step 2: Verify the model loads and embeds (first run downloads ~1.3GB)
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/kb-vectors && venv/bin/python -c "
from embed import embed_passages, embed_query, DIM
p = embed_passages([\"loosen the bleeder screw\", \"torque the axle nut\"])
q = embed_query(\"how to bleed brakes\")
print(\"passage dim:\", len(p[0]), \"count:\", len(p))
print(\"query dim:\", len(q))
import math
print(\"normalized:\", round(math.sqrt(sum(x*x for x in q)), 3))
assert len(p[0]) == DIM and len(q) == DIM
"'Expected: dims 1024, count 2, normalized ≈ 1.0.
Task 4: index.py + search.py — Qdrant integration
Files:
- Create:
/home/levander/kb-vectors/index.py - Create:
/home/levander/kb-vectors/search.py
Interfaces:
-
index.py:
get_client(),ensure_collection(client),index_manual(client, docs_dir, folder, manual) -> int,index_all(client, docs_dir) -> dict,COLLECTION,point_count(client) -
search.py:
search(client, query, limit=8, folder=None, manual=None) -> list[dict] -
Step 1: Write index.py
Create /home/levander/kb-vectors/index.py:
import os
import uuid
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, FieldCondition, Filter, MatchValue, PointStruct, VectorParams
from chunker import chunk_manual
from embed import DIM, embed_passages
COLLECTION = "manuals"
def get_client():
return QdrantClient(host="127.0.0.1", port=6333)
def ensure_collection(client):
if not client.collection_exists(COLLECTION):
client.create_collection(
COLLECTION,
vectors_config=VectorParams(size=DIM, distance=Distance.COSINE),
)
def _point_id(chunk):
key = chunk["manual_id"] + "/" + chunk["page"] + "/" + str(chunk["chunk_index"])
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
def index_manual(client, docs_dir, folder, manual):
ensure_collection(client)
manual_dir = os.path.join(docs_dir, folder, manual)
chunks = chunk_manual(manual_dir, folder, manual)
if not chunks:
return 0
vectors = embed_passages([chunk["text"] for chunk in chunks])
points = [
PointStruct(id=_point_id(chunk), vector=vector, payload=chunk)
for chunk, vector in zip(chunks, vectors)
]
client.delete(
COLLECTION,
points_selector=Filter(
must=[FieldCondition(key="manual_id", match=MatchValue(value=folder + "/" + manual))]
),
)
client.upsert(COLLECTION, points)
return len(points)
def index_all(client, docs_dir):
counts = {}
for folder in sorted(os.listdir(docs_dir)):
folder_path = os.path.join(docs_dir, folder)
if not os.path.isdir(folder_path):
continue
for manual in sorted(os.listdir(folder_path)):
if os.path.isdir(os.path.join(folder_path, manual)):
counts[folder + "/" + manual] = index_manual(client, docs_dir, folder, manual)
return counts
def point_count(client):
return client.count(COLLECTION).count- Step 2: Write search.py
Create /home/levander/kb-vectors/search.py:
from qdrant_client.models import FieldCondition, Filter, MatchValue
from embed import embed_query
from index import COLLECTION
def search(client, query, limit=8, folder=None, manual=None):
conditions = []
if folder:
conditions.append(FieldCondition(key="folder", match=MatchValue(value=folder)))
if manual:
conditions.append(FieldCondition(key="manual_id", match=MatchValue(value=manual)))
query_filter = Filter(must=conditions) if conditions else None
response = client.query_points(
COLLECTION,
query=embed_query(query),
limit=limit,
query_filter=query_filter,
with_payload=True,
)
hits = []
for point in response.points:
payload = dict(point.payload)
payload["score"] = point.score
hits.append(payload)
return hits- Step 3: Index one manual and verify idempotency + count
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/kb-vectors && venv/bin/python -c "
from index import get_client, index_manual, point_count
c = get_client()
docs = \"/home/levander/knowledgebase/manuals-src/docs\"
n1 = index_manual(c, docs, \"suzuki-vitara\", \"5door-supplement\")
total1 = point_count(c)
n2 = index_manual(c, docs, \"suzuki-vitara\", \"5door-supplement\")
total2 = point_count(c)
print(\"indexed:\", n1, \"total after 1st:\", total1, \"| after re-index:\", total2)
assert total1 == total2, \"re-index duplicated points\"
print(\"IDEMPOTENT OK\")
"'Expected: indexed: N, equal totals, IDEMPOTENT OK.
- Step 4: Real search — the quality proof
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/kb-vectors && venv/bin/python -c "
from index import get_client
from search import search
c = get_client()
for q in [\"how to bleed the brakes\", \"torque specification\", \"differential\"]:
hits = search(c, q, limit=3, folder=\"suzuki-vitara\")
print(\"\n Q:\", q)
for h in hits:
print(\" \", round(h[\"score\"],3), h[\"manual_id\"], \"|\", h[\"heading\"][:50], \"| imgs:\", len(h[\"images\"]))
"'Expected: relevant hits (right manual/section, plausible headings), scores descending, image counts present. This is the real proof the pipeline works.
Task 5: kbvec.py CLI + full index + end-to-end
Files:
- Create:
/home/levander/kb-vectors/kbvec.py
Interfaces:
-
CLI:
index --all/index --manual <folder/manual>,search "<query>" [--folder F] [--manual F/M],stats -
Step 1: Write kbvec.py
Create /home/levander/kb-vectors/kbvec.py:
import argparse
from index import get_client, index_all, index_manual, point_count
from search import search
DOCS = "/home/levander/knowledgebase/manuals-src/docs"
def main():
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
index_parser = sub.add_parser("index")
index_parser.add_argument("--all", action="store_true")
index_parser.add_argument("--manual")
search_parser = sub.add_parser("search")
search_parser.add_argument("query")
search_parser.add_argument("--folder")
search_parser.add_argument("--manual")
search_parser.add_argument("--limit", type=int, default=8)
sub.add_parser("stats")
args = parser.parse_args()
client = get_client()
if args.command == "index":
if args.manual:
folder, manual = args.manual.split("/", 1)
print(index_manual(client, DOCS, folder, manual), "points:", args.manual)
else:
for name, count in index_all(client, DOCS).items():
print(count, name)
print("total:", point_count(client))
elif args.command == "search":
for hit in search(client, args.query, limit=args.limit, folder=args.folder, manual=args.manual):
print(round(hit["score"], 3), hit["page_url"], "|", hit["heading"][:60])
elif args.command == "stats":
print("total points:", point_count(client))
if __name__ == "__main__":
main()- Step 2: Index ALL current manuals
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/kb-vectors && venv/bin/python kbvec.py index --all'Expected: a per-manual point count for every manual currently in the KB (5door-supplement, supplement-61a40, workshop-1988-1998, owners-1995, and any others landed by then), and a non-zero total. (Takes a few minutes on CPU.)
- Step 3: End-to-end CLI search + filter
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/kb-vectors && echo "=== all manuals ===" && venv/bin/python kbvec.py search "how to replace the differential" --limit 5 && echo "=== vitara only ===" && venv/bin/python kbvec.py search "brake bleeding procedure" --folder suzuki-vitara --limit 5 && echo "=== stats ===" && venv/bin/python kbvec.py stats'Expected: relevant results across manuals; the filtered query returns only suzuki-vitara/* page URLs; stats shows the total. Confirm the differential query surfaces the workshop manual’s differential section.
- Step 4: Confirm nothing was disturbed
/usr/bin/ssh levander@telep-mainframe 'systemctl is-active knowledgebase.service; docker ps --filter name=kb-qdrant --format "{{.Names}} {{.Status}}"; systemctl is-active frigate 2>/dev/null || docker ps --filter name=frigate --format "{{.Names}} {{.Status}}"'Expected: knowledgebase active, kb-qdrant Up, Frigate healthy — the vectorizer touched none of them.
Self-review notes
Spec coverage: Qdrant container (Task 1), local bge embeddings with query/passage prefixing (Task 3), chunker reusing KB sections with images+metadata, token-bounded (Task 2), index with idempotent per-manual replace + deterministic IDs (Task 4), semantic search with folder/manual filter (Task 4/5), CLI + full index + real quality proof (Task 5), read-only over the KB (verified Task 5 Step 4).
Deliberate deviations: git commit steps omitted (user’s rule). CPU torch chosen over GPU to avoid the CUDA-13/driver-550 trap and OCR/Frigate GPU contention (design allowed GPU-when-free; CPU is the robust default at this scale). Unit tests are on the pure chunker; embed/index/search are proven by real integration verification steps (they need the model + Qdrant, not mockable meaningfully).