embedsync 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
embedsync/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Incremental vector index synchronization."""
2
+
3
+ __version__ = "0.4.0"
embedsync/chunking.py ADDED
@@ -0,0 +1,39 @@
1
+ """Stable-ish chunking for documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class Chunk:
11
+ chunk_id: str
12
+ doc_id: str
13
+ content: str
14
+ index: int
15
+
16
+
17
+ def chunk_document(doc_id: str, content: str, size: int = 500) -> list[Chunk]:
18
+ """Split on paragraph boundaries, then pad to size.
19
+
20
+ ponytail: hash of (doc_id, index, first 64 chars) — upgrade to simhash merge in M4.
21
+ """
22
+ paragraphs = [p.strip() for p in content.split("\n\n") if p.strip()]
23
+ if not paragraphs:
24
+ paragraphs = [content]
25
+ pieces: list[str] = []
26
+ buf = ""
27
+ for para in paragraphs:
28
+ if buf and len(buf) + len(para) > size:
29
+ pieces.append(buf)
30
+ buf = para
31
+ else:
32
+ buf = f"{buf}\n\n{para}".strip() if buf else para
33
+ if buf:
34
+ pieces.append(buf)
35
+ chunks: list[Chunk] = []
36
+ for index, text in enumerate(pieces):
37
+ digest = hashlib.sha256(f"{doc_id}:{index}:{text[:64]}".encode()).hexdigest()[:16]
38
+ chunks.append(Chunk(chunk_id=digest, doc_id=doc_id, content=text, index=index))
39
+ return chunks
embedsync/cli.py ADDED
@@ -0,0 +1,111 @@
1
+ """CLI for embedsync."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from embedsync import __version__
10
+ from embedsync.config import Settings
11
+ from embedsync.destinations.jsonl import JsonlDestination
12
+ from embedsync.destinations.memory import MemoryDestination
13
+ from embedsync.embedders import resolve_embedder
14
+ from embedsync.sources.local import LocalFileSource
15
+ from embedsync.state.store import StateStore
16
+ from embedsync.sync.engine import execute_sync, plan_sync
17
+
18
+ console = Console()
19
+
20
+
21
+ @click.group()
22
+ @click.version_option(__version__)
23
+ def main() -> None:
24
+ """Incremental sync between documents and vector indexes."""
25
+
26
+
27
+ @main.command("health")
28
+ def health() -> None:
29
+ console.print(f"[green]embedsync {__version__} OK[/green]")
30
+
31
+
32
+ def _store(state_db: str | None) -> tuple[StateStore, str]:
33
+ settings = Settings()
34
+ path = state_db or settings.state_db
35
+ return StateStore(path), path
36
+
37
+
38
+ def _destination(dest_spec: str) -> MemoryDestination | JsonlDestination:
39
+ if dest_spec == "memory":
40
+ return MemoryDestination()
41
+ if dest_spec.startswith("jsonl:"):
42
+ return JsonlDestination(Path(dest_spec.split(":", 1)[1]))
43
+ raise click.UsageError("destination must be 'memory' or 'jsonl:/path'")
44
+
45
+
46
+ @main.command("plan")
47
+ @click.argument("source_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
48
+ @click.option("--state-db", default=None, help="SQLite state database path")
49
+ def plan_cmd(source_dir: Path, state_db: str | None) -> None:
50
+ store, path = _store(state_db)
51
+ try:
52
+ source = LocalFileSource(source_dir)
53
+ sync_plan = plan_sync(source, store)
54
+ except ValueError as exc:
55
+ console.print(f"[red]Error:[/red] {exc}")
56
+ raise SystemExit(2) from exc
57
+
58
+ table = Table(title="Sync Plan (dry)")
59
+ table.add_column("Action")
60
+ table.add_column("Doc ID")
61
+ table.add_column("Chunks")
62
+ for action in sync_plan.adds:
63
+ table.add_row("[green]ADD[/green]", action.doc_id, str(action.chunk_count))
64
+ for action in sync_plan.updates:
65
+ table.add_row("[yellow]UPDATE[/yellow]", action.doc_id, str(action.chunk_count))
66
+ for action in sync_plan.deletes:
67
+ table.add_row("[red]DELETE[/red]", action.doc_id, "0")
68
+ console.print(table)
69
+ console.print(f"Total: {sync_plan.total} action(s) state={path}")
70
+ store.close()
71
+
72
+
73
+ @main.command("run")
74
+ @click.argument("source_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
75
+ @click.option("--dry-run", is_flag=True)
76
+ @click.option("--state-db", default=None)
77
+ @click.option("--embedder", default="hash")
78
+ @click.option("--destination", "dest_spec", default="memory", help="memory | jsonl:/path")
79
+ def run_cmd(
80
+ source_dir: Path,
81
+ dry_run: bool,
82
+ state_db: str | None,
83
+ embedder: str,
84
+ dest_spec: str,
85
+ ) -> None:
86
+ store, path = _store(state_db)
87
+ try:
88
+ dest = _destination(dest_spec)
89
+ report = execute_sync(
90
+ source=LocalFileSource(source_dir),
91
+ store=store,
92
+ destination=dest,
93
+ dry_run=dry_run,
94
+ embedder=resolve_embedder(embedder),
95
+ )
96
+ except (ValueError, click.UsageError) as exc:
97
+ store.close()
98
+ if isinstance(exc, click.UsageError):
99
+ raise
100
+ console.print(f"[red]Error:[/red] {exc}")
101
+ raise SystemExit(2) from exc
102
+ suffix = " (dry-run)" if dry_run else ""
103
+ console.print(
104
+ f"Applied {len(report.actions)} action(s), "
105
+ f"wrote {report.embeddings_written} embedding(s){suffix} state={path}"
106
+ )
107
+ store.close()
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()
embedsync/config.py ADDED
@@ -0,0 +1,11 @@
1
+ """Configuration."""
2
+
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ model_config = SettingsConfigDict(env_prefix="EMBEDSYNC_", env_file=".env", extra="ignore")
8
+
9
+ state_db: str = ".embedsync/state.db"
10
+ log_level: str = "INFO"
11
+ dry_run: bool = False
File without changes
@@ -0,0 +1,55 @@
1
+ """JSONL destination — local stand-in until pgvector is wired."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from embedsync.destinations.memory import SyncAction
9
+
10
+
11
+ class JsonlDestination:
12
+ def __init__(self, path: Path) -> None:
13
+ self.path = path
14
+ self.path.parent.mkdir(parents=True, exist_ok=True)
15
+ if not self.path.exists():
16
+ self.path.write_text("", encoding="utf-8")
17
+
18
+ def apply(self, action: SyncAction, embeddings: list[list[float]], dry_run: bool = False) -> None:
19
+ if dry_run:
20
+ return
21
+ rows = self._load()
22
+ if action.action == "delete":
23
+ rows = [r for r in rows if r.get("doc_id") != action.doc_id]
24
+ else:
25
+ if action.action == "add":
26
+ rows = [r for r in rows if r.get("doc_id") != action.doc_id]
27
+ else:
28
+ drop = set(action.removed_chunk_ids) | {c.chunk_id for c in action.chunks}
29
+ rows = [
30
+ r
31
+ for r in rows
32
+ if not (r.get("doc_id") == action.doc_id and r.get("chunk_id") in drop)
33
+ ]
34
+ for chunk, vector in zip(action.chunks, embeddings, strict=False):
35
+ rows.append(
36
+ {
37
+ "chunk_id": chunk.chunk_id,
38
+ "doc_id": action.doc_id,
39
+ "content": chunk.content,
40
+ "embedding": vector,
41
+ }
42
+ )
43
+ self.path.write_text(
44
+ "\n".join(json.dumps(r) for r in rows) + ("\n" if rows else ""),
45
+ encoding="utf-8",
46
+ )
47
+
48
+ def _load(self) -> list[dict[object, object]]:
49
+ if not self.path.exists() or self.path.stat().st_size == 0:
50
+ return []
51
+ out: list[dict[object, object]] = []
52
+ for line in self.path.read_text(encoding="utf-8").splitlines():
53
+ if line.strip():
54
+ out.append(json.loads(line))
55
+ return out
@@ -0,0 +1,52 @@
1
+ """Destination protocol and in-memory implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Protocol
7
+
8
+ from embedsync.chunking import Chunk
9
+
10
+
11
+ @dataclass
12
+ class SyncAction:
13
+ action: str # add | update | delete
14
+ doc_id: str
15
+ chunk_count: int = 0
16
+ chunks: list[Chunk] = field(default_factory=list)
17
+ removed_chunk_ids: list[str] = field(default_factory=list)
18
+
19
+
20
+ @dataclass
21
+ class DestinationReport:
22
+ actions: list[SyncAction] = field(default_factory=list)
23
+ embeddings_written: int = 0
24
+
25
+
26
+ class Destination(Protocol):
27
+ def apply(self, action: SyncAction, embeddings: list[list[float]], dry_run: bool = False) -> None:
28
+ ...
29
+
30
+
31
+ class MemoryDestination:
32
+ """In-memory destination for dry-run and testing."""
33
+
34
+ def __init__(self) -> None:
35
+ self.indexed: dict[str, int] = {}
36
+ self.vectors: dict[str, dict[str, list[float]]] = {}
37
+
38
+ def apply(self, action: SyncAction, embeddings: list[list[float]], dry_run: bool = False) -> None:
39
+ if dry_run:
40
+ return
41
+ if action.action == "delete":
42
+ self.indexed.pop(action.doc_id, None)
43
+ self.vectors.pop(action.doc_id, None)
44
+ return
45
+ bucket = self.vectors.setdefault(action.doc_id, {})
46
+ if action.action == "add":
47
+ bucket.clear()
48
+ for chunk_id in action.removed_chunk_ids:
49
+ bucket.pop(chunk_id, None)
50
+ for chunk, vector in zip(action.chunks, embeddings, strict=False):
51
+ bucket[chunk.chunk_id] = vector
52
+ self.indexed[action.doc_id] = action.chunk_count
embedsync/embedders.py ADDED
@@ -0,0 +1,88 @@
1
+ """Pluggable embedding functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import os
9
+ import urllib.error
10
+ import urllib.request
11
+ from typing import Protocol
12
+
13
+
14
+ class Embedder(Protocol):
15
+ dimension: int
16
+
17
+ def embed(self, texts: list[str]) -> list[list[float]]:
18
+ ...
19
+
20
+
21
+ class HashEmbedder:
22
+ """Deterministic embedding for tests and offline dry-runs (not semantic)."""
23
+
24
+ def __init__(self, dimension: int = 8) -> None:
25
+ self.dimension = dimension
26
+
27
+ def embed(self, texts: list[str]) -> list[list[float]]:
28
+ vectors: list[list[float]] = []
29
+ for text in texts:
30
+ digest = hashlib.sha256(text.encode()).digest()
31
+ raw = [(digest[i % len(digest)] / 255.0) * 2 - 1 for i in range(self.dimension)]
32
+ norm = math.sqrt(sum(x * x for x in raw)) or 1.0
33
+ vectors.append([x / norm for x in raw])
34
+ return vectors
35
+
36
+
37
+ class OllamaEmbedder:
38
+ """Call a local Ollama /api/embeddings endpoint (stdlib urllib)."""
39
+
40
+ def __init__(
41
+ self,
42
+ model: str = "nomic-embed-text",
43
+ host: str | None = None,
44
+ dimension: int | None = None,
45
+ timeout: float = 60.0,
46
+ ) -> None:
47
+ self.model = model
48
+ self.host = (host or os.environ.get("OLLAMA_HOST") or "http://127.0.0.1:11434").rstrip("/")
49
+ self.dimension = dimension or 0
50
+ self.timeout = timeout
51
+
52
+ def embed(self, texts: list[str]) -> list[list[float]]:
53
+ vectors: list[list[float]] = []
54
+ for text in texts:
55
+ payload = json.dumps({"model": self.model, "prompt": text}).encode()
56
+ req = urllib.request.Request(
57
+ f"{self.host}/api/embeddings",
58
+ data=payload,
59
+ headers={"Content-Type": "application/json"},
60
+ method="POST",
61
+ )
62
+ try:
63
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
64
+ body = json.loads(resp.read().decode())
65
+ except urllib.error.URLError as exc:
66
+ raise RuntimeError(f"Ollama embed failed: {exc}") from exc
67
+ vector = body.get("embedding")
68
+ if not isinstance(vector, list) or not vector:
69
+ raise RuntimeError("Ollama response missing embedding vector")
70
+ if self.dimension and len(vector) != self.dimension:
71
+ raise RuntimeError(
72
+ f"Ollama returned dim {len(vector)}, expected {self.dimension}"
73
+ )
74
+ if not self.dimension:
75
+ self.dimension = len(vector)
76
+ vectors.append([float(x) for x in vector])
77
+ return vectors
78
+
79
+
80
+ def resolve_embedder(name: str) -> Embedder:
81
+ if name in {"hash", "test"}:
82
+ return HashEmbedder()
83
+ if name == "ollama" or name.startswith("ollama:"):
84
+ model = name.split(":", 1)[1] if ":" in name else "nomic-embed-text"
85
+ return OllamaEmbedder(model=model)
86
+ raise ValueError(
87
+ f"Unknown embedder '{name}'. Use hash, ollama, or ollama:<model>."
88
+ )
File without changes
@@ -0,0 +1,34 @@
1
+ """Source document providers."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass
8
+ class SourceDocument:
9
+ doc_id: str
10
+ content: str
11
+ metadata: dict[str, str]
12
+
13
+
14
+ class LocalFileSource:
15
+ """Read documents from a local directory."""
16
+
17
+ def __init__(self, directory: Path, glob: str = "**/*.md") -> None:
18
+ self.directory = directory
19
+ self.glob = glob
20
+
21
+ def list_documents(self) -> list[SourceDocument]:
22
+ docs: list[SourceDocument] = []
23
+ for path in self.directory.glob(self.glob):
24
+ if not path.is_file():
25
+ continue
26
+ rel = str(path.relative_to(self.directory))
27
+ docs.append(
28
+ SourceDocument(
29
+ doc_id=rel,
30
+ content=path.read_text(encoding="utf-8"),
31
+ metadata={"path": rel},
32
+ )
33
+ )
34
+ return docs
File without changes
@@ -0,0 +1,93 @@
1
+ """Sync state persistence."""
2
+
3
+ import hashlib
4
+ import sqlite3
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class DocumentState:
11
+ doc_id: str
12
+ content_hash: str
13
+ chunk_count: int = 0
14
+
15
+
16
+ class StateStore:
17
+ """SQLite-backed document state tracking."""
18
+
19
+ def __init__(self, db_path: str) -> None:
20
+ self.path = Path(db_path)
21
+ self.path.parent.mkdir(parents=True, exist_ok=True)
22
+ self._conn = sqlite3.connect(str(self.path))
23
+ self._conn.execute(
24
+ """
25
+ CREATE TABLE IF NOT EXISTS documents (
26
+ doc_id TEXT PRIMARY KEY,
27
+ content_hash TEXT NOT NULL,
28
+ chunk_count INTEGER DEFAULT 0
29
+ )
30
+ """
31
+ )
32
+ self._conn.execute(
33
+ """
34
+ CREATE TABLE IF NOT EXISTS chunks (
35
+ chunk_id TEXT PRIMARY KEY,
36
+ doc_id TEXT NOT NULL,
37
+ content_hash TEXT NOT NULL
38
+ )
39
+ """
40
+ )
41
+ self._conn.commit()
42
+
43
+ def get(self, doc_id: str) -> DocumentState | None:
44
+ row = self._conn.execute(
45
+ "SELECT doc_id, content_hash, chunk_count FROM documents WHERE doc_id = ?",
46
+ (doc_id,),
47
+ ).fetchone()
48
+ if row is None:
49
+ return None
50
+ return DocumentState(doc_id=row[0], content_hash=row[1], chunk_count=row[2])
51
+
52
+ def upsert(self, state: DocumentState) -> None:
53
+ self._conn.execute(
54
+ """
55
+ INSERT INTO documents (doc_id, content_hash, chunk_count)
56
+ VALUES (?, ?, ?)
57
+ ON CONFLICT(doc_id) DO UPDATE SET content_hash=excluded.content_hash,
58
+ chunk_count=excluded.chunk_count
59
+ """,
60
+ (state.doc_id, state.content_hash, state.chunk_count),
61
+ )
62
+ self._conn.commit()
63
+
64
+ def delete(self, doc_id: str) -> None:
65
+ self._conn.execute("DELETE FROM chunks WHERE doc_id = ?", (doc_id,))
66
+ self._conn.execute("DELETE FROM documents WHERE doc_id = ?", (doc_id,))
67
+ self._conn.commit()
68
+
69
+ def chunks_for(self, doc_id: str) -> dict[str, str]:
70
+ rows = self._conn.execute(
71
+ "SELECT chunk_id, content_hash FROM chunks WHERE doc_id = ?",
72
+ (doc_id,),
73
+ ).fetchall()
74
+ return {r[0]: r[1] for r in rows}
75
+
76
+ def replace_chunks(self, doc_id: str, chunks: list[tuple[str, str]]) -> None:
77
+ self._conn.execute("DELETE FROM chunks WHERE doc_id = ?", (doc_id,))
78
+ self._conn.executemany(
79
+ "INSERT INTO chunks (chunk_id, doc_id, content_hash) VALUES (?, ?, ?)",
80
+ [(chunk_id, doc_id, digest) for chunk_id, digest in chunks],
81
+ )
82
+ self._conn.commit()
83
+
84
+ def all_ids(self) -> set[str]:
85
+ rows = self._conn.execute("SELECT doc_id FROM documents").fetchall()
86
+ return {r[0] for r in rows}
87
+
88
+ def close(self) -> None:
89
+ self._conn.close()
90
+
91
+
92
+ def content_hash(text: str) -> str:
93
+ return hashlib.sha256(text.encode()).hexdigest()
File without changes
@@ -0,0 +1,107 @@
1
+ """Core sync engine — diff source against state, plan actions, embed deltas."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ import structlog
6
+
7
+ from embedsync.chunking import chunk_document
8
+ from embedsync.destinations.memory import Destination, DestinationReport, MemoryDestination, SyncAction
9
+ from embedsync.embedders import Embedder, HashEmbedder
10
+ from embedsync.sources.local import LocalFileSource
11
+ from embedsync.state.store import DocumentState, StateStore, content_hash
12
+
13
+ log = structlog.get_logger()
14
+
15
+
16
+ @dataclass
17
+ class SyncPlan:
18
+ adds: list[SyncAction] = field(default_factory=list)
19
+ updates: list[SyncAction] = field(default_factory=list)
20
+ deletes: list[SyncAction] = field(default_factory=list)
21
+
22
+ @property
23
+ def total(self) -> int:
24
+ return len(self.adds) + len(self.updates) + len(self.deletes)
25
+
26
+
27
+ def plan_sync(source: LocalFileSource, store: StateStore) -> SyncPlan:
28
+ """Compute add/update/delete plan without touching the destination."""
29
+ plan = SyncPlan()
30
+ current_ids: set[str] = set()
31
+ docs = {doc.doc_id: doc for doc in source.list_documents()}
32
+
33
+ for doc_id, doc in docs.items():
34
+ current_ids.add(doc_id)
35
+ digest = content_hash(doc.content)
36
+ existing = store.get(doc_id)
37
+ chunks = chunk_document(doc_id, doc.content)
38
+ action = SyncAction(
39
+ "add" if existing is None else "update",
40
+ doc_id,
41
+ chunk_count=len(chunks),
42
+ chunks=chunks,
43
+ )
44
+ if existing is None:
45
+ plan.adds.append(action)
46
+ elif existing.content_hash != digest:
47
+ action.action = "update"
48
+ plan.updates.append(action)
49
+
50
+ for stale_id in store.all_ids() - current_ids:
51
+ plan.deletes.append(SyncAction("delete", stale_id))
52
+
53
+ log.info("sync_planned", adds=len(plan.adds), updates=len(plan.updates), deletes=len(plan.deletes))
54
+ return plan
55
+
56
+
57
+ def execute_sync(
58
+ source: LocalFileSource,
59
+ store: StateStore,
60
+ destination: Destination | None = None,
61
+ dry_run: bool = False,
62
+ embedder: Embedder | None = None,
63
+ ) -> DestinationReport:
64
+ dest: Destination = destination or MemoryDestination()
65
+ encoder = embedder or HashEmbedder()
66
+ plan = plan_sync(source, store)
67
+ report = DestinationReport()
68
+ docs = {d.doc_id: d for d in source.list_documents()}
69
+
70
+ for action in plan.adds + plan.updates:
71
+ old = store.chunks_for(action.doc_id)
72
+ new_hashes = {chunk.chunk_id: content_hash(chunk.content) for chunk in action.chunks}
73
+ changed = [chunk for chunk in action.chunks if old.get(chunk.chunk_id) != new_hashes[chunk.chunk_id]]
74
+ removed = [chunk_id for chunk_id in old if chunk_id not in new_hashes]
75
+ write_chunks = action.chunks if action.action == "add" else changed
76
+ texts = [c.content for c in write_chunks]
77
+ vectors = encoder.embed(texts) if texts else []
78
+ dest_action = SyncAction(
79
+ action.action,
80
+ action.doc_id,
81
+ chunk_count=action.chunk_count,
82
+ chunks=write_chunks,
83
+ removed_chunk_ids=removed,
84
+ )
85
+ report.actions.append(action)
86
+ report.embeddings_written += 0 if dry_run else len(vectors)
87
+ dest.apply(dest_action, vectors, dry_run=dry_run)
88
+ if not dry_run:
89
+ store.upsert(
90
+ DocumentState(
91
+ doc_id=action.doc_id,
92
+ content_hash=content_hash(docs[action.doc_id].content),
93
+ chunk_count=action.chunk_count,
94
+ )
95
+ )
96
+ store.replace_chunks(
97
+ action.doc_id,
98
+ [(chunk.chunk_id, content_hash(chunk.content)) for chunk in action.chunks],
99
+ )
100
+
101
+ for action in plan.deletes:
102
+ report.actions.append(action)
103
+ dest.apply(action, [], dry_run=dry_run)
104
+ if not dry_run:
105
+ store.delete(action.doc_id)
106
+
107
+ return report
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.5
2
+ Name: embedsync
3
+ Version: 0.4.0
4
+ Summary: Incremental synchronization between source documents and vector indexes
5
+ Project-URL: Homepage, https://github.com/yashshah9/embedsync
6
+ Project-URL: Repository, https://github.com/yashshah9/embedsync
7
+ Project-URL: Issues, https://github.com/yashshah9/embedsync/issues
8
+ Author-email: Yash Shah <yash376351@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: click>=8.1
13
+ Requires-Dist: pydantic-settings>=2.2
14
+ Requires-Dist: pydantic>=2.6
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: rich>=13.7
17
+ Requires-Dist: structlog>=24.1
18
+ Provides-Extra: dev
19
+ Requires-Dist: mypy>=1.9; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Requires-Dist: ruff>=0.4; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # embedsync
25
+
26
+ Incremental synchronization between **source documents** and **vector indexes** — detect changes, re-embed only deltas, and delete stale chunks.
27
+
28
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
29
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
30
+ [![CI](https://github.com/yashshah9/embedsync/actions/workflows/ci.yml/badge.svg)](https://github.com/yashshah9/embedsync/actions/workflows/ci.yml)
31
+
32
+ > **Status:** v0.4 — hash + Ollama embedders, paragraph chunks, JSONL destination, chunk-level re-embed.
33
+
34
+ ## 60-second try
35
+
36
+ ```bash
37
+ docker compose run --rm plan # plan sync for examples/docs
38
+ docker compose run --rm test # pytest
39
+ ```
40
+
41
+ ## Why this vs alternatives
42
+
43
+ | Approach | Strength | Gap |
44
+ |----------|----------|-----|
45
+ | **embedsync** | Content-hash deltas + pluggable embedders | Destinations still local (JSONL/memory) |
46
+ | Full re-embed pipelines | Simple mentally | Expensive; misses deletes |
47
+ | Framework ingestion (e.g. LlamaIndex) | Rich connectors | Change detection is DIY |
48
+ | One-off sync scripts | Fits one repo | No shared plan/state model |
49
+
50
+ ## Problem
51
+
52
+ RAG indexes rot when documents change. Full re-embeds are expensive and miss deletes. Every team rebuilds change detection from scratch.
53
+
54
+ ## Key features (v0.4)
55
+
56
+ - Content-hash change detection per document
57
+ - Sync plan: add / update / delete actions
58
+ - Hash embedder for offline/CI (`--embedder hash`)
59
+ - Ollama embedder (`--embedder ollama` or `ollama:nomic-embed-text`)
60
+ - JSONL or in-memory destination
61
+ - Unchanged docs/chunks skip re-embedding on the next run
62
+
63
+ ## Architecture
64
+
65
+ ```
66
+ embedsync run ./docs
67
+ ├── LocalFileSource
68
+ ├── StateStore (SQLite)
69
+ ├── plan_sync() → diff
70
+ └── Destination (Memory → pgvector next)
71
+ ```
72
+
73
+ ## Installation
74
+
75
+ ```bash
76
+ pip install embedsync
77
+ pip install -e ".[dev]"
78
+ ```
79
+
80
+ ## Usage
81
+
82
+ ```bash
83
+ embedsync health
84
+ embedsync plan examples/docs --state-db /tmp/embedsync-demo.db
85
+ embedsync run examples/docs --dry-run --state-db /tmp/embedsync-demo.db
86
+ embedsync run examples/docs --embedder hash --destination memory --state-db /tmp/embedsync-demo.db
87
+ embedsync run examples/docs --embedder hash --destination jsonl:/tmp/index.jsonl
88
+ # Requires a running Ollama with an embedding model:
89
+ embedsync run examples/docs --embedder ollama --destination jsonl:/tmp/index.jsonl
90
+ embedsync run examples/docs --embedder ollama:nomic-embed-text --destination memory
91
+ ```
92
+
93
+ ## Docker
94
+
95
+ ```bash
96
+ docker compose run --rm test
97
+ docker compose run --rm plan
98
+ ```
99
+
100
+ ## Configuration
101
+
102
+ | Variable | Default | Description |
103
+ |----------|---------|-------------|
104
+ | `EMBEDSYNC_STATE_DB` | `.embedsync/state.db` | State database path |
105
+ | `EMBEDSYNC_LOG_LEVEL` | `INFO` | Log level |
106
+
107
+ Ollama uses `OLLAMA_HOST` when set (otherwise the embedder default host).
108
+
109
+ ## Roadmap
110
+
111
+ - [x] Pluggable embedder protocol + hash backend
112
+ - [x] JSONL destination (local stand-in)
113
+ - [x] Chunk-level stable IDs across edits
114
+ - [x] Ollama embedder (`--embedder ollama`)
115
+ - [ ] pgvector and Qdrant destinations
116
+ - [ ] Notion and sitemap sources
117
+
118
+ ## License
119
+
120
+ MIT
121
+
122
+ ## Known limitations (v0.4)
123
+
124
+ - Hash embeddings are not semantic — use `--embedder ollama` for local semantic vectors
125
+ - JSONL is not a vector DB
126
+ - Local markdown files only
127
+ - Re-runs reuse `.embedsync/state.db`; pass `--state-db` for an isolated plan
128
+ - Ollama must already be running and have the embedding model pulled
@@ -0,0 +1,19 @@
1
+ embedsync/__init__.py,sha256=AcwPcc5nsyPa74u6KuiX9tLOr_Yy-06ic520bUQBq_Y,71
2
+ embedsync/chunking.py,sha256=VCWQdiSs0okcXuL83xSuAph8Wx9IAQXbDgE_vR5LmYw,1135
3
+ embedsync/cli.py,sha256=mvh3ys2Lno5fWiS_EnHwPF7Rr8rOXdnfoL3AmWG9hWI,3632
4
+ embedsync/config.py,sha256=kBEusbxpQlWANqaCTAeMfxVgJh7iP4NHf6wtAJ5Nm_A,310
5
+ embedsync/embedders.py,sha256=i2uHI7kI9osd4cEb4MboMgkBnL-_Rp39gpyFR6Qao8o,3052
6
+ embedsync/destinations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ embedsync/destinations/jsonl.py,sha256=Wwy_Ns7kFymbCef881_W7oqykew_todSvfEn8jFY72Q,2018
8
+ embedsync/destinations/memory.py,sha256=2lHqG_ji_81_4WrE3G1f8_VW8kOuvvTYHmBTpivSA1E,1614
9
+ embedsync/sources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ embedsync/sources/local.py,sha256=yR-zhVNmNbzB7OazUvYy7q2tr4murRD9Js1sZbBl3Bw,907
11
+ embedsync/state/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ embedsync/state/store.py,sha256=sF7ROxKTZPZ4cpT7Y6q8ThBvKf4a-oPq-hlSRHLToDc,3001
13
+ embedsync/sync/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ embedsync/sync/engine.py,sha256=IeE-cg-2VGrPlawi4yo5BSW8Q-jUgGj1j-kIDqyrIK8,3914
15
+ embedsync-0.4.0.dist-info/METADATA,sha256=xR87KH0TXqvjfFSxkuJwjXIkxe1UGVJeyx5bVZ2mAK0,4255
16
+ embedsync-0.4.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
17
+ embedsync-0.4.0.dist-info/entry_points.txt,sha256=LqgRBazDjPccjWy_1xNKcvTBZGHQ9nHTR7cgjBQWdhM,49
18
+ embedsync-0.4.0.dist-info/licenses/LICENSE,sha256=RHgdJwl3QH1HrtSvNLAzpPLh5tRz047xaVmZDiFBMXU,1079
19
+ embedsync-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ embedsync = embedsync.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 embedsync contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.