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 +3 -0
- embedsync/chunking.py +39 -0
- embedsync/cli.py +111 -0
- embedsync/config.py +11 -0
- embedsync/destinations/__init__.py +0 -0
- embedsync/destinations/jsonl.py +55 -0
- embedsync/destinations/memory.py +52 -0
- embedsync/embedders.py +88 -0
- embedsync/sources/__init__.py +0 -0
- embedsync/sources/local.py +34 -0
- embedsync/state/__init__.py +0 -0
- embedsync/state/store.py +93 -0
- embedsync/sync/__init__.py +0 -0
- embedsync/sync/engine.py +107 -0
- embedsync-0.4.0.dist-info/METADATA +128 -0
- embedsync-0.4.0.dist-info/RECORD +19 -0
- embedsync-0.4.0.dist-info/WHEEL +4 -0
- embedsync-0.4.0.dist-info/entry_points.txt +2 -0
- embedsync-0.4.0.dist-info/licenses/LICENSE +21 -0
embedsync/__init__.py
ADDED
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
|
embedsync/state/store.py
ADDED
|
@@ -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
|
embedsync/sync/engine.py
ADDED
|
@@ -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)
|
|
29
|
+
[](https://www.python.org/downloads/)
|
|
30
|
+
[](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,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.
|