Skip to content

Commit d22ecec

Browse files
Frank Guoclaude
andcommitted
feat: add nomic-embed-text deep semantic embeddings
Embed nomic-embed-text-v1.5 (Q8_0 GGUF, 134MB) directly in the binary via go:embed. Three-way hybrid scoring (BM25 0.3 + LSA 0.2 + Nomic 0.5) with graceful 2-way fallback on unsupported platforms. - New nomic/ package with platform build tags and direct CGO bindings to llama.cpp (Metal on macOS, CPU on Linux) - session_embeddings PK changed to (session_id, model) for coexistence - Incremental index update at checkpoint time for new sessions - All llama.cpp stderr noise suppressed for clean CLI output - Supported platforms: darwin/arm64, linux/amd64 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ad48107 commit d22ecec

30 files changed

Lines changed: 982 additions & 73 deletions

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
cmd/rekal/cli/nomic/models/*.gguf.gz filter=lfs diff=lfs merge=lfs -text

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@
2222
# Local config (optional; uncomment if you don't want to track)
2323
# .config/
2424
.rekal/
25+
26+
# Build dependencies (llama.cpp)
27+
.deps/

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ This repo contains the CLI for Rekal — gives your agent precise memory.
2828
- `version.go`: Version constant (set via ldflags)
2929
- `versioncheck/`: Auto-update notification
3030
- `db/`: DuckDB backend (open, close, schema)
31+
- `lsa/`: Latent Semantic Analysis embeddings
32+
- `nomic/`: Nomic-embed-text deep semantic embeddings (platform build tags)
3133
- `integration_test/`: Integration tests (`//go:build integration`)
3234

3335
### Specifications (`docs/spec/`)
@@ -140,7 +142,7 @@ All commands except `init` and `clean` must call both:
140142

141143
Two databases in `.rekal/`:
142144
- `data.db` — source of truth (sessions, checkpoints, files_touched, checkpoint_sessions)
143-
- `index.db` — derived index (turns_ft, tool_calls_index, files_index, session_facets, file_cooccurrence)
145+
- `index.db` — derived index (turns_ft, tool_calls_index, files_index, session_facets, file_cooccurrence, session_embeddings with LSA + nomic vectors)
144146

145147
Use the `db` package to open connections:
146148
```go

README.md

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -21,47 +21,48 @@ Your agent starts every session knowing *why* the code looks the way it does.
2121

2222
## What Makes Rekal Different
2323

24-
- **Security first** — Everything stays local. Nothing leaves the boundaries of git. No external services, no cloud APIs, no telemetry. A single binary with zero runtime dependencies beyond git itself.
24+
- **Security first** — Everything stays local. Nothing leaves the boundaries of git. No external services, no cloud APIs, no telemetry. A single binary with zero runtime dependencies beyond git itself. Even the embedding model (nomic-embed-text-v1.5) ships inside the binary — no downloads, no servers, no setup.
2525
- **Immutable by design** — Session snapshots are append-only. Content-hash deduplication means two developers always write to disjoint rows — merge conflicts are structurally impossible. Rekal never updates or deletes a session row.
2626
- **Team-shared memory**`rekal push` and `rekal sync` share session context across your entire team through git. Every developer's agent benefits from every other developer's prior sessions.
2727
- **Git-native** — No external infrastructure. Rekal data lives on standard orphan branches, syncs through your existing remote, and uses git's object store for point-in-time recovery. Every checkpoint is anchored to a commit SHA.
28-
- **DuckDB-powered** — Full-text search (BM25), LSA vector embeddings, and file co-occurrence graphs built on DuckDB. The index is local-only and rebuilt on demand from the shared data.
28+
- **DuckDB-powered** — Full-text search (BM25), LSA vector embeddings, and nomic-embed-text deep semantic embeddings — all running locally inside a single binary. Three-way hybrid scoring (BM25 + LSA + Nomic) with graceful fallback on unsupported platforms. File co-occurrence graphs built on DuckDB. The index is local-only and rebuilt on demand from the shared data.
2929
- **Agent-first** — Progressive context loading. `rekal <query>` returns scored snippets and metadata — just enough for the agent to decide what matters. `rekal query --session <id>` drills into a specific session for full turns. The agent controls how much context it loads.
3030
- **Signal, not bulk** — A 2-10 MB session file becomes a ~300 byte payload. The wire format is a custom binary codec with zstd compression, string interning via varint references, and append-only framing.
3131

3232
## How It Works
3333

3434
```mermaid
35-
flowchart TB
36-
subgraph capture ["Capture (post-commit hook)"]
37-
A["AI Session<br/>prompts, responses,<br/>tool calls, files"] -->|"rekal checkpoint"| B["data.db<br/><i>DuckDB — append-only</i>"]
35+
flowchart LR
36+
subgraph capture ["Capture"]
37+
A["AI Session"] -->|"rekal checkpoint<br/>(post-commit)"| B[("data.db<br/>append-only")]
3838
end
3939
40-
subgraph index ["Index (local-only, rebuilt on demand)"]
41-
B -->|"rekal index"| C["index.db<br/><i>DuckDB — derived</i>"]
42-
C --- D["BM25 full-text search"]
43-
C --- E["LSA vector embeddings"]
44-
C --- F["File co-occurrence graph"]
45-
C --- G["Session facets &amp; filters"]
40+
subgraph transport ["Transport"]
41+
B -->|"rekal push"| C["Wire Format<br/>zstd + varint interning"]
42+
C -->|"git push<br/>rekal/&lt;email&gt;"| D[("Remote<br/>orphan branch")]
4643
end
4744
48-
subgraph transport ["Transport (git orphan branches)"]
49-
B -->|"rekal push"| H["Wire Format<br/><i>rekal.body + dict.bin</i><br/>zstd · varint interning · append-only"]
50-
H -->|"git push origin<br/>rekal/&lt;email&gt;"| I["Remote<br/><i>per-user orphan branch</i>"]
51-
I -->|"rekal sync"| C
45+
subgraph index ["Index"]
46+
B -->|"rekal index"| E[("index.db<br/>local-only")]
47+
D -->|"rekal sync"| E
48+
E --- F["BM25 FTS"]
49+
E --- G["LSA Embeddings"]
50+
E --- N["Nomic Deep Embeddings"]
51+
E --- H["Co-occurrence"]
52+
E --- I["Facets"]
5253
end
5354
54-
subgraph query ["Query (agent-driven)"]
55-
J["rekal 'keyword'"] -->|"hybrid BM25 + LSA"| C
56-
C -->|"scored JSON"| K["Agent"]
57-
K -->|"rekal query --session &lt;id&gt;"| B
55+
subgraph query ["Query"]
56+
J["rekal 'keyword'"] -->|"hybrid search"| E
57+
E -->|"scored JSON"| K["Agent"]
58+
K -->|"rekal query<br/>--session &lt;id&gt;"| B
5859
B -->|"full conversation"| K
5960
end
6061
61-
style capture fill:#1a1a2e,stroke:#e94560,color:#eee
62-
style index fill:#1a1a2e,stroke:#0f3460,color:#eee
63-
style transport fill:#1a1a2e,stroke:#16213e,color:#eee
64-
style query fill:#1a1a2e,stroke:#533483,color:#eee
62+
style capture fill:#fff5f5,stroke:#e94560,color:#333
63+
style transport fill:#f0fdf4,stroke:#22c55e,color:#333
64+
style index fill:#f0f4ff,stroke:#3b82f6,color:#333
65+
style query fill:#faf5ff,stroke:#a855f7,color:#333
6566
```
6667

6768
When you commit, Rekal automatically snapshots your active AI session into a local DuckDB database. `rekal push` shares it with your team on a per-user orphan branch — your git history stays clean.
@@ -77,9 +78,11 @@ When you commit, Rekal automatically snapshots your active AI session into a loc
7778
# Install
7879
curl -fsSL https://raw.githubusercontent.com/rekal-dev/cli/main/scripts/install.sh | bash
7980

81+
# Install to a specific directory
82+
curl -fsSL https://raw.githubusercontent.com/rekal-dev/cli/main/scripts/install.sh | bash -s -- --target /opt/bin
8083
```
8184

82-
Install location: `~/.local/bin` (override with `REKAL_INSTALL_DIR`).
85+
Install location: `~/.local/bin` (override with `--target <dir>` or `REKAL_INSTALL_DIR`).
8386

8487
```bash
8588
# Initialize in a git repo
@@ -104,7 +107,7 @@ When a newer release is available, the CLI prints an update notice after each co
104107
| `rekal sync [--self]` | Sync team context from remote rekal branches |
105108
| `rekal index` | Rebuild the index DB from the data DB |
106109
| `rekal log [--limit N]` | Show recent checkpoints |
107-
| `rekal [filters...] [query]` | Recall — hybrid search (BM25 + LSA) over sessions |
110+
| `rekal [filters...] [query]` | Recall — hybrid search (BM25 + LSA + Nomic) over sessions |
108111
| `rekal query --session <id> [--full]` | Drill into a session (turns, tool calls, files) |
109112
| `rekal query "<sql>" [--index]` | Run raw SQL against the data or index DB |
110113

@@ -161,7 +164,7 @@ rekal --file src/billing/ "why discount logic"
161164
Rekal uses two local DuckDB databases and a compact binary wire format:
162165

163166
- **Data DB** (`.rekal/data.db`) — Append-only shared truth. Normalized tables: sessions, turns, tool calls, checkpoints, files touched. The local query interface via `rekal query`.
164-
- **Index DB** (`.rekal/index.db`) — Local-only search intelligence. Full-text indexes (BM25), LSA vector embeddings, file co-occurrence graphs. Never synced. Rebuild anytime with `rekal index`.
167+
- **Index DB** (`.rekal/index.db`) — Local-only search intelligence. Full-text indexes (BM25), LSA vector embeddings, nomic-embed-text deep semantic embeddings, file co-occurrence graphs. Never synced. Rebuild anytime with `rekal index`.
165168
- **Wire format** (`rekal.body` + `dict.bin`) — Stored on per-user orphan branches (`rekal/<email>`). Append-only binary frames with zstd compression. This is what gets pushed/synced via git — the DuckDB databases are rebuilt from it.
166169

167170
The wire format can be inspected from any point in time using git:

cmd/rekal/cli/checkpoint.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"math/rand"
99
"os"
1010
"os/exec"
11+
"path/filepath"
1112
"strings"
1213
"time"
1314

@@ -243,6 +244,12 @@ func doCheckpoint(gitRoot string, w io.Writer) error {
243244
}
244245
}
245246

247+
// Incrementally update the index for newly captured sessions.
248+
if err := updateIndexIncremental(gitRoot, sessionIDs, checkpointID, w); err != nil {
249+
// Non-fatal — index can be rebuilt later with 'rekal index'.
250+
fmt.Fprintf(w, "rekal: warning: incremental index update failed: %v\n", err)
251+
}
252+
246253
fmt.Fprintf(w, "rekal: %d session(s) captured\n", inserted)
247254
return nil
248255
}
@@ -291,3 +298,39 @@ func sha256Hex(data []byte) string {
291298
h := sha256.Sum256(data)
292299
return hex.EncodeToString(h[:])
293300
}
301+
302+
// updateIndexIncremental adds newly captured sessions to the index DB
303+
// without a full rebuild. Handles: turns_ft, tool_calls_index, session_facets,
304+
// files_index, and nomic embeddings. LSA is skipped (requires full corpus).
305+
// FTS pragma_create_fts_index is not re-run — new rows in turns_ft are
306+
// automatically indexed by DuckDB's FTS.
307+
func updateIndexIncremental(gitRoot string, sessionIDs []string, checkpointID string, w io.Writer) error {
308+
indexPath := filepath.Join(gitRoot, ".rekal", "index.db")
309+
if _, err := os.Stat(indexPath); err != nil {
310+
// No index DB yet — skip incremental update. Next 'rekal index' or 'rekal sync' will build it.
311+
return nil
312+
}
313+
314+
indexDB, err := db.OpenIndex(gitRoot)
315+
if err != nil {
316+
return err
317+
}
318+
defer indexDB.Close()
319+
320+
// Populate index tables for new sessions.
321+
if err := db.PopulateIndexIncremental(indexDB, gitRoot, sessionIDs, checkpointID); err != nil {
322+
return fmt.Errorf("populate index: %w", err)
323+
}
324+
325+
// Nomic embeddings for new sessions (non-fatal).
326+
sessionContent, err := db.QuerySessionContentByIDs(indexDB, sessionIDs)
327+
if err != nil || len(sessionContent) == 0 {
328+
return err
329+
}
330+
331+
if err := buildNomicEmbeddings(indexDB, sessionContent, w); err != nil {
332+
fmt.Fprintf(w, "rekal: warning: nomic embeddings skipped: %v\n", err)
333+
}
334+
335+
return nil
336+
}

cmd/rekal/cli/db/indexer.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,119 @@ func float64SliceToDuckDB(v []float64) string {
268268
return b.String()
269269
}
270270

271+
// QueryEmbeddings returns session_id → embedding vector for a given model.
272+
func QueryEmbeddings(d *sql.DB, model string) (map[string][]float64, error) {
273+
rows, err := d.Query("SELECT session_id, embedding FROM session_embeddings WHERE model = $1", model)
274+
if err != nil {
275+
return nil, fmt.Errorf("query embeddings: %w", err)
276+
}
277+
defer rows.Close() //nolint:errcheck
278+
279+
result := make(map[string][]float64)
280+
for rows.Next() {
281+
var sid string
282+
var emb []float64
283+
if err := rows.Scan(&sid, &emb); err != nil {
284+
return nil, fmt.Errorf("scan embedding: %w", err)
285+
}
286+
result[sid] = emb
287+
}
288+
return result, rows.Err()
289+
}
290+
291+
// PopulateIndexIncremental adds new sessions to the index without a full rebuild.
292+
// sessionIDs are the newly captured sessions. checkpointID is the new checkpoint.
293+
func PopulateIndexIncremental(d *sql.DB, gitRoot string, sessionIDs []string, checkpointID string) error {
294+
dataPath := filepath.Join(gitRoot, ".rekal", "data.db")
295+
296+
if _, err := d.Exec(fmt.Sprintf("ATTACH '%s' AS data_db (READ_ONLY)", dataPath)); err != nil {
297+
return fmt.Errorf("attach data_db: %w", err)
298+
}
299+
defer d.Exec("DETACH data_db") //nolint:errcheck
300+
301+
for _, sid := range sessionIDs {
302+
// turns_ft
303+
if _, err := d.Exec(`
304+
INSERT INTO turns_ft (id, session_id, turn_index, role, content, ts)
305+
SELECT id, session_id, turn_index, role, content, CAST(ts AS VARCHAR)
306+
FROM data_db.turns WHERE session_id = $1
307+
`, sid); err != nil {
308+
return fmt.Errorf("incremental turns_ft: %w", err)
309+
}
310+
311+
// tool_calls_index
312+
if _, err := d.Exec(`
313+
INSERT INTO tool_calls_index (id, session_id, call_order, tool, path, cmd_prefix)
314+
SELECT id, session_id, call_order, tool, path, cmd_prefix
315+
FROM data_db.tool_calls WHERE session_id = $1
316+
`, sid); err != nil {
317+
return fmt.Errorf("incremental tool_calls_index: %w", err)
318+
}
319+
320+
// session_facets
321+
if _, err := d.Exec(`
322+
INSERT INTO session_facets (
323+
session_id, user_email, git_branch, actor_type, agent_id,
324+
captured_at, turn_count, tool_call_count, file_count,
325+
checkpoint_id, git_sha
326+
)
327+
SELECT
328+
s.id, s.user_email,
329+
COALESCE(c.git_branch, s.branch),
330+
s.actor_type, s.agent_id, s.captured_at,
331+
(SELECT count(*) FROM data_db.turns t WHERE t.session_id = s.id),
332+
(SELECT count(*) FROM data_db.tool_calls tc WHERE tc.session_id = s.id),
333+
COALESCE(fc.cnt, 0),
334+
c.id, c.git_sha
335+
FROM data_db.sessions s
336+
LEFT JOIN data_db.checkpoint_sessions cs ON cs.session_id = s.id
337+
LEFT JOIN data_db.checkpoints c ON c.id = cs.checkpoint_id
338+
LEFT JOIN (
339+
SELECT cs2.session_id, count(DISTINCT ft.file_path) AS cnt
340+
FROM data_db.checkpoint_sessions cs2
341+
JOIN data_db.files_touched ft ON ft.checkpoint_id = cs2.checkpoint_id
342+
WHERE cs2.session_id = $1
343+
GROUP BY cs2.session_id
344+
) fc ON fc.session_id = s.id
345+
WHERE s.id = $1
346+
`, sid); err != nil {
347+
return fmt.Errorf("incremental session_facets: %w", err)
348+
}
349+
}
350+
351+
// files_index for the new checkpoint
352+
if _, err := d.Exec(`
353+
INSERT INTO files_index (checkpoint_id, session_id, file_path, change_type)
354+
SELECT ft.checkpoint_id, cs.session_id, ft.file_path, ft.change_type
355+
FROM data_db.files_touched ft
356+
JOIN data_db.checkpoint_sessions cs ON cs.checkpoint_id = ft.checkpoint_id
357+
WHERE ft.checkpoint_id = $1
358+
`, checkpointID); err != nil {
359+
return fmt.Errorf("incremental files_index: %w", err)
360+
}
361+
362+
return nil
363+
}
364+
365+
// QuerySessionContentByIDs returns session_id → concatenated turn content for specific sessions.
366+
func QuerySessionContentByIDs(d *sql.DB, sessionIDs []string) (map[string]string, error) {
367+
result := make(map[string]string, len(sessionIDs))
368+
for _, sid := range sessionIDs {
369+
var content string
370+
err := d.QueryRow(`
371+
SELECT string_agg(content, ' ' ORDER BY turn_index)
372+
FROM turns_ft WHERE session_id = $1
373+
`, sid).Scan(&content)
374+
if err != nil && err != sql.ErrNoRows {
375+
return nil, fmt.Errorf("query session content for %s: %w", sid, err)
376+
}
377+
if content != "" {
378+
result[sid] = content
379+
}
380+
}
381+
return result, nil
382+
}
383+
271384
// QuerySessionContent returns session_id → concatenated turn content for LSA.
272385
func QuerySessionContent(d *sql.DB) (map[string]string, error) {
273386
rows, err := d.Query(`

cmd/rekal/cli/db/schema.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,11 @@ CREATE TABLE IF NOT EXISTS file_cooccurrence (
138138
);
139139
140140
CREATE TABLE IF NOT EXISTS session_embeddings (
141-
session_id VARCHAR PRIMARY KEY,
141+
session_id VARCHAR NOT NULL,
142142
embedding FLOAT[],
143143
model VARCHAR NOT NULL,
144-
generated_at TIMESTAMP NOT NULL
144+
generated_at TIMESTAMP NOT NULL,
145+
PRIMARY KEY (session_id, model)
145146
);
146147
147148
CREATE TABLE IF NOT EXISTS index_state (

cmd/rekal/cli/index_cmd.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package cli
22

33
import (
4+
"database/sql"
45
"fmt"
6+
"io"
57
"strconv"
68

79
"github.com/rekal-dev/cli/cmd/rekal/cli/db"
810
"github.com/rekal-dev/cli/cmd/rekal/cli/lsa"
11+
"github.com/rekal-dev/cli/cmd/rekal/cli/nomic"
912
"github.com/spf13/cobra"
1013
)
1114

@@ -17,7 +20,8 @@ func newIndexCmd() *cobra.Command {
1720
1821
The index is local-only and never synced. It contains:
1922
- Full-text search index (BM25) over conversation turns
20-
- LSA vector embeddings for semantic similarity
23+
- LSA vector embeddings for lightweight semantic similarity
24+
- Nomic deep semantic embeddings (on supported platforms)
2125
- Session facets (author, branch, actor, counts) for fast filtering
2226
- File co-occurrence graph
2327
- Tool call indexes
@@ -108,7 +112,12 @@ func runIndex(cmd *cobra.Command, gitRoot string) error {
108112
return fmt.Errorf("store embeddings: %w", err)
109113
}
110114
embeddingDim = model.Dim
111-
fmt.Fprintf(w, "stored %d embeddings (%d dimensions)\n", len(vectors), embeddingDim)
115+
fmt.Fprintf(w, "stored %d LSA embeddings (%d dimensions)\n", len(vectors), embeddingDim)
116+
}
117+
118+
// Nomic pass (non-fatal).
119+
if err := buildNomicEmbeddings(indexDB, sessionContent, w); err != nil {
120+
fmt.Fprintf(w, "warning: nomic embeddings skipped: %v\n", err)
112121
}
113122
}
114123

@@ -129,3 +138,29 @@ func runIndex(cmd *cobra.Command, gitRoot string) error {
129138
fmt.Fprintf(w, "index rebuilt: %d sessions, %d turns\n", sessionCount, turnCount)
130139
return nil
131140
}
141+
142+
// buildNomicEmbeddings generates nomic-embed-text embeddings for all sessions
143+
// and stores them in the index DB. Non-fatal: returns error on any failure.
144+
func buildNomicEmbeddings(indexDB *sql.DB, sessionContent map[string]string, w io.Writer) error {
145+
if !nomic.Supported() {
146+
return nil
147+
}
148+
149+
fmt.Fprintln(w, "building nomic deep semantic embeddings...")
150+
embedder, err := nomic.NewEmbedder()
151+
if err != nil {
152+
return err
153+
}
154+
defer embedder.Close()
155+
156+
vectors, err := embedder.EmbedSessions(sessionContent)
157+
if err != nil {
158+
return err
159+
}
160+
161+
if err := db.StoreEmbeddings(indexDB, vectors, nomic.ModelName); err != nil {
162+
return err
163+
}
164+
fmt.Fprintf(w, "stored %d nomic embeddings (%d dimensions)\n", len(vectors), nomic.EmbedDim)
165+
return nil
166+
}

0 commit comments

Comments
 (0)