Skip to content

Commit 3fba3d2

Browse files
authored
feat: batch embedding pipeline (#292)
1 parent c22a598 commit 3fba3d2

21 files changed

Lines changed: 987 additions & 0 deletions

File tree

context_use/cli/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def _batch_detail_from_state(state: State | None) -> str:
3030
MemoryEmbedCompleteState,
3131
MemoryGenerateCompleteState,
3232
)
33+
from context_use.thread_embedding.states import ThreadEmbedCompleteState
3334

3435
if isinstance(state, FailedState):
3536
message = state.error_message.strip()
@@ -50,6 +51,9 @@ def _batch_detail_from_state(state: State | None) -> str:
5051
if isinstance(state, DescGenerateCompleteState):
5152
return f"{state.descriptions_count} descriptions generated"
5253

54+
if isinstance(state, ThreadEmbedCompleteState):
55+
return f"{state.embedded_count} threads embedded"
56+
5357
return ""
5458

5559

context_use/cli/commands/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from context_use.cli.commands.agent import AgentGroup
55
from context_use.cli.commands.config import ConfigGroup
66
from context_use.cli.commands.describe import DescribeCommand
7+
from context_use.cli.commands.embed import EmbedCommand
78
from context_use.cli.commands.ingest import IngestCommand
89
from context_use.cli.commands.memories import MemoriesGroup
910
from context_use.cli.commands.pipeline import PipelineCommand
@@ -15,6 +16,7 @@
1516
PipelineCommand,
1617
IngestCommand,
1718
DescribeCommand,
19+
EmbedCommand,
1820
ResetCommand,
1921
]
2022

context_use/cli/commands/embed.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
from datetime import UTC, datetime, timedelta
5+
from typing import TYPE_CHECKING
6+
7+
from context_use.cli import output as out
8+
from context_use.cli.base import ApiCommand, create_batch_reporter, run_batches
9+
from context_use.cli.config import Config
10+
11+
if TYPE_CHECKING:
12+
from context_use import ContextUse
13+
14+
15+
class EmbedCommand(ApiCommand):
16+
name = "embed"
17+
help = "Embed thread content for semantic search"
18+
description = (
19+
"Generate vector embeddings for all unprocessed threads. "
20+
"Asset threads require descriptions first (run 'describe' beforehand). "
21+
"Use --last-days or --since to limit the date range."
22+
)
23+
llm_mode = "batch"
24+
25+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
26+
parser.add_argument(
27+
"--last-days",
28+
type=int,
29+
default=None,
30+
help="Only process threads from the last N days",
31+
)
32+
parser.add_argument(
33+
"--since",
34+
type=str,
35+
default=None,
36+
help="Only process threads after this date (YYYY-MM-DD)",
37+
)
38+
39+
async def run(
40+
self,
41+
cfg: Config,
42+
ctx: ContextUse,
43+
args: argparse.Namespace,
44+
) -> None:
45+
since = self._resolve_since(args)
46+
47+
out.header("Embedding threads")
48+
out.info("Embeds all threads that have not been embedded yet.")
49+
if since:
50+
out.kv("Since", since.strftime("%Y-%m-%d"))
51+
print()
52+
53+
batches = await ctx.create_thread_embedding_batches(since=since)
54+
55+
if not batches:
56+
out.info("No threads to embed.")
57+
return
58+
59+
await run_batches(
60+
ctx,
61+
batches,
62+
reporter_factory=create_batch_reporter,
63+
)
64+
65+
out.success("Thread embeddings generated")
66+
out.kv("Batches", len(batches))
67+
print()
68+
69+
def _resolve_since(self, args: argparse.Namespace) -> datetime | None:
70+
if args.since:
71+
return datetime.fromisoformat(args.since).replace(tzinfo=UTC)
72+
if args.last_days is not None:
73+
return datetime.now(UTC) - timedelta(days=args.last_days)
74+
return None

context_use/core.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,37 @@ async def create_asset_description_batches(
235235
groups = [ThreadGroup(threads=[t], group_id=t.id) for t in asset_threads]
236236
return await AssetDescriptionBatchFactory.create_batches(groups, self._store)
237237

238+
# ── Thread embedding batches ─────────────────────────────────────
239+
240+
async def create_thread_embedding_batches(
241+
self,
242+
*,
243+
task_id: str | None = None,
244+
since: datetime | None = None,
245+
before: datetime | None = None,
246+
) -> list[Batch]:
247+
"""Create batches for thread embedding.
248+
249+
Every thread with embeddable content is included. Asset threads
250+
without a description yet are silently skipped (their
251+
``get_embeddable_content()`` returns ``None``).
252+
"""
253+
from context_use.thread_embedding.factory import ThreadEmbeddingBatchFactory
254+
255+
threads = await self._store.get_unprocessed_threads(
256+
batch_category=BatchCategory.thread_embedding.value,
257+
task_id=task_id,
258+
since=since,
259+
before=before,
260+
)
261+
262+
embeddable = [t for t in threads if t.get_embeddable_content() is not None]
263+
if not embeddable:
264+
return []
265+
266+
groups = [ThreadGroup(threads=[t], group_id=t.id) for t in embeddable]
267+
return await ThreadEmbeddingBatchFactory.create_batches(groups, self._store)
268+
238269
# ── Memory batches ────────────────────────────────────────────────
239270

240271
async def create_memory_batches(
@@ -452,3 +483,4 @@ def _ensure_managers_registered() -> None:
452483
"""Import manager modules to trigger their @register_batch_manager decorators."""
453484
import context_use.asset_description.manager # noqa: F401
454485
import context_use.memories.manager # noqa: F401
486+
import context_use.thread_embedding.manager # noqa: F401

context_use/models/batch.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class BatchCategory(enum.StrEnum):
2020

2121
memories = "memories"
2222
asset_description = "asset_description"
23+
thread_embedding = "thread_embedding"
2324

2425

2526
@dataclass

context_use/models/thread.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ def get_raw_content(self) -> str:
8282
"""Return semantic content from the payload, ignoring any enriched content."""
8383
return self._parsed_payload.get_content() or ""
8484

85+
def get_embeddable_content(self) -> str | None:
86+
"""Return text suitable for embedding, or ``None`` to skip.
87+
88+
Asset threads use the enriched ``content`` (set by the describe
89+
pipeline). If no description exists yet the thread is not ready
90+
for embedding. Non-asset threads use the raw payload content.
91+
"""
92+
if self.is_asset:
93+
return self.content
94+
raw = self.get_raw_content()
95+
return raw or None
96+
8597
def get_participant_label(self) -> str:
8698
return self._parsed_payload.get_participant_label()
8799

context_use/store/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,15 @@ async def search_memories(
256256
"""Search memories by semantic similarity, optionally filtered by date range."""
257257
...
258258

259+
# ── Thread Embeddings ─────────────────────────────────────────────
260+
261+
@abstractmethod
262+
async def upsert_thread_embedding(
263+
self, thread_id: str, embedding: list[float]
264+
) -> None:
265+
"""Insert or replace the embedding vector for a thread."""
266+
...
267+
259268
# ── Memory Facets ────────────────────────────────────────────────
260269

261270
@abstractmethod

context_use/store/sqlite/schema.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,27 @@ def from_row(row: Row) -> MemoryFacet:
381381
)
382382

383383

384+
class VecThreadRow:
385+
table = "vec_threads"
386+
387+
@classmethod
388+
def ddl(cls, embedding_dimensions: int) -> str:
389+
return (
390+
"CREATE VIRTUAL TABLE IF NOT EXISTS vec_threads "
391+
f"USING vec0(\n"
392+
f" thread_id TEXT PRIMARY KEY,\n"
393+
f" embedding float[{embedding_dimensions}] "
394+
f"distance_metric=cosine\n"
395+
f")"
396+
)
397+
398+
@staticmethod
399+
def serialize(embedding: list[float]) -> bytes:
400+
from sqlite_vec import serialize_float32
401+
402+
return serialize_float32(embedding)
403+
404+
384405
class VecFacetRow:
385406
table = "vec_facets"
386407

@@ -420,5 +441,6 @@ def all_ddl_statements(embedding_dimensions: int) -> list[str]:
420441
stmts.append(model.ddl())
421442
stmts.extend(model.indices())
422443
stmts.append(VecMemoryRow.ddl(embedding_dimensions))
444+
stmts.append(VecThreadRow.ddl(embedding_dimensions))
423445
stmts.append(VecFacetRow.ddl(embedding_dimensions))
424446
return stmts

context_use/store/sqlite/store.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
ThreadRow,
3434
VecFacetRow,
3535
VecMemoryRow,
36+
VecThreadRow,
3637
all_ddl_statements,
3738
now_utc_iso,
3839
parse_dt,
@@ -650,6 +651,20 @@ async def search_memories(
650651
top_k=top_k,
651652
)
652653

654+
async def upsert_thread_embedding(
655+
self, thread_id: str, embedding: list[float]
656+
) -> None:
657+
db = await self._conn()
658+
await db.execute(
659+
"DELETE FROM vec_threads WHERE thread_id = ?",
660+
(thread_id,),
661+
)
662+
await db.execute(
663+
"INSERT INTO vec_threads (thread_id, embedding) VALUES (?, ?)",
664+
(thread_id, VecThreadRow.serialize(embedding)),
665+
)
666+
await self._commit_unless_atomic()
667+
653668
async def create_memory_facet(self, facet: MemoryFacet) -> MemoryFacet:
654669
db = await self._conn()
655670
await db.execute(

context_use/thread_embedding/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)