Files
bastion-agent/bastion_agent/embed.py
T
2026-09-26 18:31:40 +00:00

418 lines
16 KiB
Python

"""bastion agent `chunk` + `embed` stages, and the local `search` that proves them.
chunk sanitized store -> <out>/chunks.jsonl
Overlapping windows sized in approximate tokens (manifest
embed.chunk.size_tokens / overlap_tokens). "Token" here is a
deterministic regex unit (a word or a punctuation mark), not a
model tokenizer: the stdlib has none, and the same text must chunk
identically on every machine. Chunk id = sha256(doc sha, window
text) — content-addressed, so an unchanged document keeps its
vectors; a changed document has a new sha and is embedded again.
embed chunks -> <out>/vectors.db (SQLite, stdlib)
The endpoint is resolved from the environment at run time
(EMBED_URL, default the local Ollama port), never the manifest;
the manifest pins the MODEL and DIM. Before the first real call
the stage embeds a probe and refuses to run if the served
dimension is not the manifest's — a wrong model never writes a
vector. Two wire formats, auto-detected: Ollama /api/embed and
OpenAI-style /v1/embeddings (llama.cpp server, TEI).
Rows are keyed (chunk_id, model): switching models ADDS rows and
never deletes the old ones, so going back to an earlier model
costs nothing and no vector is ever thrown away.
search embed the query the same way, cosine over every vector of the
manifest's model, return the top-k sanitized chunks. Pure Python;
fine to tens of thousands of chunks, which is the point where a
real index is a follow-up.
"""
import array
import datetime as _dt
import hashlib
import json
import math
import os
import re
import sqlite3
import tempfile
import urllib.error
import urllib.request
from .manifest import signing_digest
from .sanitize import store_path as sanitized_store_path
CHUNK_VERSION = 1
DEFAULT_EMBED_URL = "http://127.0.0.1:11434" # local Ollama; a sensor, resolved at run time
EMBED_BATCH = 32
EMBED_TIMEOUT = 300.0
PROBE_TEXT = "freeagent embedding probe"
TOKEN_RE = re.compile(r"\w+|[^\w\s]", re.UNICODE)
class EmbedError(Exception):
"""Fail-closed stop: the endpoint is unreachable or serves the wrong model."""
# ── chunking ────────────────────────────────────────────────────────────────
def count_tokens(text):
return len(TOKEN_RE.findall(text))
def chunk_text(text, size_tokens, overlap_tokens):
"""Yield (start, end, window_text). Windows are cut at token
boundaries; the step is size - overlap tokens; the last window may be
shorter. Every character of text is inside at least one window."""
spans = [(m.start(), m.end()) for m in TOKEN_RE.finditer(text)]
if not spans:
return
step = max(1, size_tokens - overlap_tokens)
i = 0
n = len(spans)
while True:
j = min(n, i + size_tokens)
start = 0 if i == 0 else spans[i][0]
end = len(text) if j == n else spans[j - 1][1]
yield start, end, text[start:end]
if j >= n:
return
i += step
def chunk_id(doc_sha, window_text):
h = hashlib.sha256()
h.update(doc_sha.encode("ascii"))
h.update(b"\x00")
h.update(window_text.encode("utf-8"))
return h.hexdigest()
def _atomic_write_text(path, text):
d = os.path.dirname(path)
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=d)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
fh.write(text)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def load_sanitized_index(out_dir):
path = os.path.join(out_dir, "sanitize.jsonl")
if not os.path.exists(path):
raise FileNotFoundError("no sanitize index at %s — run the sanitize stage first" % path)
with open(path, encoding="utf-8") as fh:
return [json.loads(l) for l in fh]
def chunk(manifest, out_dir, now=None):
cfg = manifest.embed
if cfg is None:
raise EmbedError("manifest has no embed section / stage")
records = load_sanitized_index(out_dir)
counts = {"docs": 0, "chunks": 0, "skipped": 0}
seen_docs = set()
rows = []
for rec in records:
if rec.get("status") != "ok":
counts["skipped"] += 1
continue
sha = rec["sha256"]
if sha in seen_docs:
continue # duplicate content chunks once; paths resolve through sanitize.jsonl
seen_docs.add(sha)
counts["docs"] += 1
with open(sanitized_store_path(out_dir, sha), encoding="utf-8") as fh:
text = fh.read()
for idx, (start, end, window) in enumerate(
chunk_text(text, cfg.chunk_size_tokens, cfg.chunk_overlap_tokens)):
rows.append({"chunk_id": chunk_id(sha, window), "doc": sha, "idx": idx,
"start": start, "end": end, "chars": len(window),
"tokens": count_tokens(window), "v": CHUNK_VERSION})
counts["chunks"] += 1
rows.sort(key=lambda r: (r["doc"], r["idx"]))
_atomic_write_text(os.path.join(out_dir, "chunks.jsonl"), "".join(
json.dumps(r, sort_keys=True) + "\n" for r in rows))
summary = {
"freeagent": 1, "stage": "chunk", "chunk_version": CHUNK_VERSION,
"manifest_id": manifest.id,
"manifest_digest": "sha256:" + signing_digest(manifest.raw),
"chunked_at": now or _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"size_tokens": cfg.chunk_size_tokens, "overlap_tokens": cfg.chunk_overlap_tokens,
"counts": counts,
}
_atomic_write_text(os.path.join(out_dir, "chunk.summary.json"),
json.dumps(summary, indent=2, sort_keys=True) + "\n")
return summary
# ── the vector store ────────────────────────────────────────────────────────
_SCHEMA = """
CREATE TABLE IF NOT EXISTS chunks (
chunk_id TEXT PRIMARY KEY,
doc TEXT NOT NULL,
idx INTEGER NOT NULL,
start INTEGER NOT NULL,
end INTEGER NOT NULL,
text TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS chunks_doc ON chunks(doc, idx);
CREATE TABLE IF NOT EXISTS vectors (
chunk_id TEXT NOT NULL,
model TEXT NOT NULL,
dim INTEGER NOT NULL,
vec BLOB NOT NULL,
created TEXT NOT NULL,
PRIMARY KEY (chunk_id, model)
);
CREATE INDEX IF NOT EXISTS vectors_model ON vectors(model);
"""
def vectors_path(out_dir):
return os.path.join(out_dir, "vectors.db")
def open_store(out_dir):
os.makedirs(out_dir, exist_ok=True)
conn = sqlite3.connect(vectors_path(out_dir), timeout=30)
conn.executescript(_SCHEMA)
return conn
def pack(vec):
a = array.array("f", vec)
return a.tobytes()
def unpack(blob):
a = array.array("f")
a.frombytes(blob)
return a
# ── the endpoint (a sensor) ─────────────────────────────────────────────────
class Endpoint:
"""Local embedding endpoint. `api` is 'ollama' or 'openai'; None
auto-detects on the probe."""
def __init__(self, url, model, api=None, timeout=EMBED_TIMEOUT):
self.url = url.rstrip("/")
self.model = model
self.api = api
self.timeout = timeout
def _post(self, path, body):
req = urllib.request.Request(
self.url + path, data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return json.load(resp), None
except urllib.error.HTTPError as e:
detail = ""
try:
detail = e.read().decode("utf-8", "replace")[:200]
except Exception:
pass
return None, "HTTP %d %s" % (e.code, detail)
except (OSError, ValueError) as e:
return None, str(e)
def _ollama(self, texts):
data, err = self._post("/api/embed", {"model": self.model, "input": texts})
if err:
return None, err
try:
return [list(map(float, v)) for v in data["embeddings"]], None
except (KeyError, TypeError, ValueError) as e:
return None, "malformed ollama response: %s" % e
def _openai(self, texts):
data, err = self._post("/v1/embeddings", {"model": self.model, "input": texts})
if err:
return None, err
try:
items = sorted(data["data"], key=lambda d: d.get("index", 0))
return [list(map(float, d["embedding"])) for d in items], None
except (KeyError, TypeError, ValueError) as e:
return None, "malformed embeddings response: %s" % e
def embed(self, texts):
if self.api == "ollama":
vecs, err = self._ollama(texts)
elif self.api == "openai":
vecs, err = self._openai(texts)
else:
vecs, err = self._ollama(texts)
if vecs is not None:
self.api = "ollama"
else:
vecs, err2 = self._openai(texts)
if vecs is not None:
self.api = "openai"
else:
err = "ollama: %s; openai: %s" % (err, err2)
if vecs is None:
raise EmbedError("embedding endpoint %s (model %s): %s" % (self.url, self.model, err))
if len(vecs) != len(texts):
raise EmbedError("endpoint returned %d vectors for %d inputs" % (len(vecs), len(texts)))
return vecs
def probe(self, want_dim):
vec = self.embed([PROBE_TEXT])[0]
if len(vec) != want_dim:
raise EmbedError("model %s serves %d-dim vectors; manifest pins %d — refusing to write"
% (self.model, len(vec), want_dim))
return len(vec)
def describe(self):
return "%s@%s(%s)" % (self.model, self.url, self.api or "auto")
def build_endpoint(manifest, env):
url = env.get("EMBED_URL", DEFAULT_EMBED_URL).strip() or DEFAULT_EMBED_URL
api = env.get("EMBED_API", "").strip().lower() or None
if api not in (None, "ollama", "openai"):
raise EmbedError("EMBED_API=%r is not ollama|openai" % api)
return Endpoint(url, manifest.embed.model, api=api,
timeout=float(env.get("EMBED_TIMEOUT", EMBED_TIMEOUT)))
# ── embed stage ─────────────────────────────────────────────────────────────
def load_chunks(out_dir):
path = os.path.join(out_dir, "chunks.jsonl")
if not os.path.exists(path):
raise FileNotFoundError("no chunks at %s — run the chunk stage first" % path)
with open(path, encoding="utf-8") as fh:
return [json.loads(l) for l in fh]
def embed(manifest, out_dir, env=None, endpoint=None, now=None, batch=None):
cfg = manifest.embed
if cfg is None:
raise EmbedError("manifest has no embed section / stage")
env = dict(os.environ) if env is None else env
ep = endpoint or build_endpoint(manifest, env)
batch = batch or int(env.get("EMBED_BATCH", EMBED_BATCH))
chunks = load_chunks(out_dir)
ep.probe(cfg.dim)
conn = open_store(out_dir)
counts = {"chunks": len(chunks), "unique": 0, "embedded": 0, "reused": 0, "docs": 0}
try:
have = {r[0] for r in conn.execute("SELECT chunk_id FROM vectors WHERE model=?", (cfg.model,))}
# chunk texts come from the sanitized store, sliced by recorded offsets
texts = {}
docs = {}
for c in chunks:
docs.setdefault(c["doc"], []).append(c)
counts["docs"] = len(docs)
for doc, cs in docs.items():
with open(sanitized_store_path(out_dir, doc), encoding="utf-8") as fh:
text = fh.read()
for c in cs:
texts[c["chunk_id"]] = text[c["start"]:c["end"]]
# `chunks` is derived from THIS sweep (rebuilt each run); `vectors`
# is the durable store and is only ever added to.
with conn:
conn.execute("DELETE FROM chunks")
conn.executemany(
"INSERT OR REPLACE INTO chunks(chunk_id, doc, idx, start, end, text) VALUES (?,?,?,?,?,?)",
[(c["chunk_id"], c["doc"], c["idx"], c["start"], c["end"], texts[c["chunk_id"]])
for c in chunks])
# identical windows (a repeated paragraph) share a chunk id: embed once
unique = {}
for c in chunks:
unique.setdefault(c["chunk_id"], c)
counts["unique"] = len(unique)
todo = [c for c in unique.values() if c["chunk_id"] not in have]
counts["reused"] = len(unique) - len(todo)
stamp = now or _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
for i in range(0, len(todo), batch):
group = todo[i:i + batch]
vecs = ep.embed([texts[c["chunk_id"]] for c in group])
rows = []
for c, v in zip(group, vecs):
if len(v) != cfg.dim:
raise EmbedError("vector of dim %d for chunk %s; manifest pins %d"
% (len(v), c["chunk_id"][:12], cfg.dim))
rows.append((c["chunk_id"], cfg.model, cfg.dim, pack(v), stamp))
with conn:
conn.executemany(
"INSERT OR REPLACE INTO vectors(chunk_id, model, dim, vec, created) VALUES (?,?,?,?,?)",
rows)
counts["embedded"] += len(rows)
# vectors for chunks that no longer exist in this sweep are kept
# (never dump); report how many so the host can see the drift
stale = conn.execute(
"SELECT COUNT(*) FROM vectors v WHERE v.model=? AND NOT EXISTS "
"(SELECT 1 FROM chunks c WHERE c.chunk_id=v.chunk_id)", (cfg.model,)).fetchone()[0]
models = [dict(model=m, dim=d, vectors=n) for m, d, n in conn.execute(
"SELECT model, dim, COUNT(*) FROM vectors GROUP BY model, dim ORDER BY model")]
finally:
conn.close()
summary = {
"freeagent": 1, "stage": "embed",
"manifest_id": manifest.id,
"manifest_digest": "sha256:" + signing_digest(manifest.raw),
"embedded_at": now or _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"endpoint": ep.describe(), "model": cfg.model, "dim": cfg.dim,
"counts": counts, "stale_vectors": stale, "models_in_store": models,
"store": vectors_path(out_dir),
}
_atomic_write_text(os.path.join(out_dir, "embed.summary.json"),
json.dumps(summary, indent=2, sort_keys=True) + "\n")
return summary
# ── search ──────────────────────────────────────────────────────────────────
def cosine(a, b):
dot = na = nb = 0.0
for x, y in zip(a, b):
dot += x * y
na += x * x
nb += y * y
if na == 0.0 or nb == 0.0:
return 0.0
return dot / math.sqrt(na * nb)
def search(manifest, out_dir, query, k=5, env=None, endpoint=None):
cfg = manifest.embed
if cfg is None:
raise EmbedError("manifest has no embed section")
env = dict(os.environ) if env is None else env
ep = endpoint or build_endpoint(manifest, env)
qv = ep.embed([query])[0]
if len(qv) != cfg.dim:
raise EmbedError("query vector dim %d != manifest dim %d" % (len(qv), cfg.dim))
conn = open_store(out_dir)
try:
scored = []
for chunk_id_, blob in conn.execute("SELECT chunk_id, vec FROM vectors WHERE model=?", (cfg.model,)):
scored.append((cosine(qv, unpack(blob)), chunk_id_))
scored.sort(reverse=True)
out = []
for score, cid in scored[:k]:
row = conn.execute("SELECT doc, idx, start, end, text FROM chunks WHERE chunk_id=?", (cid,)).fetchone()
if row is None:
continue
out.append({"score": round(score, 6), "chunk_id": cid, "doc": row[0], "idx": row[1],
"start": row[2], "end": row[3], "text": row[4]})
return out
finally:
conn.close()