@zabaca/lattice 0.3.4 → 1.0.1

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.
Files changed (3) hide show
  1. package/README.md +68 -64
  2. package/dist/main.js +280 -256
  3. package/package.json +3 -4
package/README.md CHANGED
@@ -24,30 +24,31 @@ That's it. Two commands to build a knowledge base.
24
24
  | Feature | Lattice | Other GraphRAG Tools |
25
25
  |---------|---------|---------------------|
26
26
  | **LLM for extraction** | Your Claude Code subscription | Separate API key + costs |
27
- | **Setup time** | 5 minutes | 30+ minutes |
28
- | **Containers** | 1 (FalkorDB) | 2-3 (DB + vector + graph) |
27
+ | **Setup time** | 2 minutes | 30+ minutes |
28
+ | **Database** | Embedded DuckDB (zero config) | Docker containers required |
29
+ | **External dependencies** | None | 2-3 (DB + vector + graph) |
29
30
  | **API keys needed** | 1 (Voyage AI for embeddings) | 2-3 (LLM + embedding + rerank) |
30
31
  | **Workflow** | `/research` → `/graph-sync` | Custom scripts |
31
32
 
32
33
  ---
33
34
 
34
- ## Quick Start (5 Minutes)
35
+ ## Quick Start (2 Minutes)
35
36
 
36
37
  ### What You Need
37
38
 
38
39
  - **Claude Code** (you probably already have it)
39
- - **Docker** (for FalkorDB)
40
40
  - **Voyage AI API key** ([get one here](https://www.voyageai.com/) - embeddings only, ~$0.01/1M tokens)
41
41
 
42
- ### 1. Install & Start
42
+ ### 1. Install
43
43
 
44
44
  ```bash
45
- bun add -g @zabaca/lattice # Install CLI
46
- docker run -d -p 6379:6379 falkordb/falkordb # Start database
47
- export VOYAGE_API_KEY=your-key-here # Set API key
48
- lattice init --global # Install Claude Code commands
45
+ bun add -g @zabaca/lattice # Install CLI
46
+ export VOYAGE_API_KEY=your-key-here # Set API key
47
+ lattice init --global # Install Claude Code commands
49
48
  ```
50
49
 
50
+ That's it. No Docker. No containers. DuckDB is embedded.
51
+
51
52
  ### 2. Start Researching
52
53
 
53
54
  ```bash
@@ -67,7 +68,7 @@ The `/research` command will:
67
68
  The `/graph-sync` command will:
68
69
  - Detect all new/changed documents
69
70
  - Extract entities using Claude Code (your subscription)
70
- - Sync to FalkorDB for semantic search
71
+ - Sync to DuckDB for semantic search
71
72
 
72
73
  ---
73
74
 
@@ -152,6 +153,24 @@ Semantic search across the knowledge graph.
152
153
 
153
154
  ```bash
154
155
  lattice search "query" # Search all entity types
156
+ lattice search "query" -l Tool # Filter by label
157
+ ```
158
+
159
+ ### `lattice sql`
160
+
161
+ Execute raw SQL queries against DuckDB.
162
+
163
+ ```bash
164
+ lattice sql "SELECT * FROM nodes LIMIT 10"
165
+ lattice sql "SELECT label, COUNT(*) FROM nodes GROUP BY label"
166
+ ```
167
+
168
+ ### `lattice rels`
169
+
170
+ Show relationships for a node.
171
+
172
+ ```bash
173
+ lattice rels "TypeScript" # Show all relationships for an entity
155
174
  ```
156
175
 
157
176
  ### `lattice validate`
@@ -182,15 +201,24 @@ lattice ontology # Show entity types and relationship types
182
201
  | Variable | Description | Default |
183
202
  |----------|-------------|---------|
184
203
  | `VOYAGE_API_KEY` | Voyage AI API key for embeddings | *required* |
185
- | `FALKORDB_HOST` | FalkorDB server hostname | `localhost` |
186
- | `FALKORDB_PORT` | FalkorDB server port | `6379` |
204
+ | `DUCKDB_PATH` | Path to DuckDB database file | `./.lattice.duckdb` |
205
+ | `EMBEDDING_DIMENSIONS` | Embedding vector dimensions | `512` |
206
+
207
+ ### Database Location
208
+
209
+ Lattice stores its knowledge graph in a single `.lattice.duckdb` file in your docs directory. This file contains:
210
+ - All extracted entities (nodes)
211
+ - Relationships between entities
212
+ - Vector embeddings for semantic search
213
+
214
+ You can back up, copy, or version control this file like any other.
187
215
 
188
216
  <details>
189
217
  <summary><b>How It Works (Technical Details)</b></summary>
190
218
 
191
219
  ### Entity Extraction
192
220
 
193
- When you run `/graph-sync`, Claude Code extracts entities from your documents and writes them to YAML frontmatter. The Lattice CLI then syncs this to FalkorDB.
221
+ When you run `/graph-sync`, Claude Code extracts entities from your documents and writes them to YAML frontmatter. The Lattice CLI then syncs this to DuckDB.
194
222
 
195
223
  ```yaml
196
224
  ---
@@ -208,57 +236,35 @@ relationships:
208
236
 
209
237
  You don't need to write this manually — Claude Code handles it automatically.
210
238
 
211
- </details>
212
-
213
- ---
214
-
215
- ## Infrastructure
216
-
217
- <details>
218
- <summary><b>Docker Compose (Alternative Setup)</b></summary>
219
-
220
- If you prefer Docker Compose over a single `docker run` command:
221
-
222
- ```yaml
223
- version: '3.8'
224
-
225
- services:
226
- falkordb:
227
- image: falkordb/falkordb:latest
228
- ports:
229
- - "6379:6379"
230
- volumes:
231
- - falkordb-data:/data
232
- restart: unless-stopped
233
-
234
- volumes:
235
- falkordb-data:
239
+ ### Database Schema
240
+
241
+ Lattice uses two main tables:
242
+
243
+ ```sql
244
+ -- Nodes (entities)
245
+ CREATE TABLE nodes (
246
+ label VARCHAR NOT NULL, -- Entity type: Document, Technology, etc.
247
+ name VARCHAR NOT NULL, -- Unique identifier
248
+ properties JSON, -- Additional metadata
249
+ embedding FLOAT[512], -- Vector for semantic search
250
+ PRIMARY KEY(label, name)
251
+ );
252
+
253
+ -- Relationships
254
+ CREATE TABLE relationships (
255
+ source_label VARCHAR NOT NULL,
256
+ source_name VARCHAR NOT NULL,
257
+ relation_type VARCHAR NOT NULL,
258
+ target_label VARCHAR NOT NULL,
259
+ target_name VARCHAR NOT NULL,
260
+ properties JSON,
261
+ PRIMARY KEY(source_label, source_name, relation_type, target_label, target_name)
262
+ );
236
263
  ```
237
264
 
238
- ```bash
239
- docker-compose up -d
240
- ```
265
+ ### Vector Search
241
266
 
242
- </details>
243
-
244
- <details>
245
- <summary><b>Kubernetes (k3s)</b></summary>
246
-
247
- For production deployments, use the provided k3s manifests:
248
-
249
- ```bash
250
- kubectl apply -f infra/k3s/namespace.yaml
251
- kubectl apply -f infra/k3s/pv.yaml
252
- kubectl apply -f infra/k3s/pvc.yaml
253
- kubectl apply -f infra/k3s/deployment.yaml
254
- kubectl apply -f infra/k3s/service.yaml
255
-
256
- # Optional: NodePort for external access
257
- kubectl apply -f infra/k3s/nodeport-service.yaml
258
-
259
- # Optional: Ingress
260
- kubectl apply -f infra/k3s/ingress.yaml
261
- ```
267
+ Lattice uses DuckDB's VSS extension for HNSW-based vector similarity search with cosine distance.
262
268
 
263
269
  </details>
264
270
 
@@ -273,7 +279,6 @@ kubectl apply -f infra/k3s/ingress.yaml
273
279
 
274
280
  - Node.js >= 18.0.0
275
281
  - Bun (recommended) or npm
276
- - Docker (for FalkorDB)
277
282
 
278
283
  ### Setup
279
284
 
@@ -282,7 +287,6 @@ git clone https://github.com/Zabaca/lattice.git
282
287
  cd lattice
283
288
  bun install
284
289
  cp .env.example .env
285
- docker-compose -f infra/docker-compose.yaml up -d
286
290
  ```
287
291
 
288
292
  ### Running Locally
@@ -329,4 +333,4 @@ MIT License - see [LICENSE](LICENSE) for details.
329
333
 
330
334
  ---
331
335
 
332
- Built with [FalkorDB](https://www.falkordb.com/), [Voyage AI](https://www.voyageai.com/), and [Claude Code](https://claude.ai/code)
336
+ Built with [DuckDB](https://duckdb.org/), [Voyage AI](https://www.voyageai.com/), and [Claude Code](https://claude.ai/code)
package/dist/main.js CHANGED
@@ -140,10 +140,9 @@ import { glob } from "glob";
140
140
 
141
141
  // src/schemas/config.schemas.ts
142
142
  import { z } from "zod";
143
- var FalkorDBConfigSchema = z.object({
144
- host: z.string().min(1).default("localhost"),
145
- port: z.coerce.number().int().positive().default(6379),
146
- graphName: z.string().min(1).default("research_knowledge")
143
+ var DuckDBConfigSchema = z.object({
144
+ dbPath: z.string().optional(),
145
+ embeddingDimensions: z.coerce.number().int().positive().default(512)
147
146
  });
148
147
  var EmbeddingConfigSchema = z.object({
149
148
  provider: z.enum(["openai", "voyage", "nomic", "mock"]).default("voyage"),
@@ -643,7 +642,7 @@ var VoyageEmbeddingResponseSchema = z3.object({
643
642
  })),
644
643
  model: z3.string(),
645
644
  usage: z3.object({
646
- total_tokens: z3.number().int().positive()
645
+ total_tokens: z3.number().int().nonnegative()
647
646
  })
648
647
  });
649
648
 
@@ -790,82 +789,124 @@ EmbeddingService = __legacyDecorateClassTS([
790
789
  ], EmbeddingService);
791
790
 
792
791
  // src/graph/graph.service.ts
792
+ import { DuckDBInstance } from "@duckdb/node-api";
793
793
  import { Injectable as Injectable6, Logger as Logger3 } from "@nestjs/common";
794
794
  import { ConfigService as ConfigService2 } from "@nestjs/config";
795
- import Redis from "ioredis";
796
795
  class GraphService {
797
796
  configService;
798
797
  logger = new Logger3(GraphService.name);
799
- redis = null;
800
- config;
798
+ instance = null;
799
+ connection = null;
800
+ dbPath;
801
801
  connecting = null;
802
+ vectorIndexes = new Set;
803
+ embeddingDimensions;
802
804
  constructor(configService) {
803
805
  this.configService = configService;
804
- this.config = FalkorDBConfigSchema.parse({
805
- host: this.configService.get("FALKORDB_HOST"),
806
- port: this.configService.get("FALKORDB_PORT"),
807
- graphName: this.configService.get("GRAPH_NAME")
808
- });
806
+ this.dbPath = this.configService.get("DUCKDB_PATH") || "./.lattice.duckdb";
807
+ this.embeddingDimensions = this.configService.get("EMBEDDING_DIMENSIONS") || 512;
809
808
  }
810
809
  async onModuleDestroy() {
811
810
  await this.disconnect();
812
811
  }
813
812
  async ensureConnected() {
814
- if (this.redis) {
815
- return this.redis;
813
+ if (this.connection) {
814
+ return this.connection;
816
815
  }
817
816
  if (this.connecting) {
818
817
  await this.connecting;
819
- return this.redis;
818
+ if (!this.connection) {
819
+ throw new Error("Connection failed to establish");
820
+ }
821
+ return this.connection;
820
822
  }
821
823
  this.connecting = this.connect();
822
824
  await this.connecting;
823
825
  this.connecting = null;
824
- return this.redis;
826
+ if (!this.connection) {
827
+ throw new Error("Connection failed to establish");
828
+ }
829
+ return this.connection;
825
830
  }
826
831
  async connect() {
827
832
  try {
828
- this.redis = new Redis({
829
- host: this.config.host,
830
- port: this.config.port,
831
- maxRetriesPerRequest: 3,
832
- retryStrategy: (times) => {
833
- if (times > 3) {
834
- return null;
835
- }
836
- const delay = Math.min(times * 50, 2000);
837
- return delay;
838
- },
839
- lazyConnect: true
840
- });
841
- this.redis.on("error", (err) => {
842
- this.logger.debug(`Redis connection error: ${err.message}`);
833
+ this.instance = await DuckDBInstance.create(this.dbPath, {
834
+ allow_unsigned_extensions: "true"
843
835
  });
844
- await this.redis.ping();
845
- this.logger.log(`Connected to FalkorDB at ${this.config.host}:${this.config.port}`);
836
+ this.connection = await this.instance.connect();
837
+ await this.connection.run("INSTALL vss; LOAD vss;");
838
+ await this.connection.run("SET hnsw_enable_experimental_persistence = true;");
839
+ try {
840
+ await this.connection.run("SET custom_extension_repository = 'http://duckpgq.s3.eu-north-1.amazonaws.com';");
841
+ await this.connection.run("FORCE INSTALL 'duckpgq';");
842
+ await this.connection.run("LOAD 'duckpgq';");
843
+ this.logger.log("DuckPGQ extension loaded successfully");
844
+ } catch (e) {
845
+ this.logger.warn(`DuckPGQ extension not available: ${e instanceof Error ? e.message : String(e)}`);
846
+ }
847
+ await this.initializeSchema();
848
+ this.logger.log(`Connected to DuckDB at ${this.dbPath}`);
846
849
  } catch (error) {
847
- this.redis = null;
848
- this.logger.error(`Failed to connect to FalkorDB: ${error instanceof Error ? error.message : String(error)}`);
850
+ this.connection = null;
851
+ this.instance = null;
852
+ this.logger.error(`Failed to connect to DuckDB: ${error instanceof Error ? error.message : String(error)}`);
849
853
  throw error;
850
854
  }
851
855
  }
852
856
  async disconnect() {
853
- if (this.redis) {
854
- await this.redis.quit();
855
- this.logger.log("Disconnected from FalkorDB");
856
- }
857
- }
858
- async query(cypher, _params) {
857
+ if (this.connection) {
858
+ this.connection.closeSync();
859
+ this.connection = null;
860
+ this.logger.log("Disconnected from DuckDB");
861
+ }
862
+ if (this.instance) {
863
+ this.instance = null;
864
+ }
865
+ }
866
+ async initializeSchema() {
867
+ if (!this.connection) {
868
+ throw new Error("Cannot initialize schema: not connected");
869
+ }
870
+ const conn = this.connection;
871
+ await conn.run(`
872
+ CREATE TABLE IF NOT EXISTS nodes (
873
+ label VARCHAR NOT NULL,
874
+ name VARCHAR NOT NULL,
875
+ properties JSON,
876
+ embedding FLOAT[${this.embeddingDimensions}],
877
+ created_at TIMESTAMP DEFAULT NOW(),
878
+ updated_at TIMESTAMP DEFAULT NOW(),
879
+ PRIMARY KEY(label, name)
880
+ )
881
+ `);
882
+ await conn.run(`
883
+ CREATE TABLE IF NOT EXISTS relationships (
884
+ source_label VARCHAR NOT NULL,
885
+ source_name VARCHAR NOT NULL,
886
+ relation_type VARCHAR NOT NULL,
887
+ target_label VARCHAR NOT NULL,
888
+ target_name VARCHAR NOT NULL,
889
+ properties JSON,
890
+ created_at TIMESTAMP DEFAULT NOW(),
891
+ PRIMARY KEY(source_label, source_name, relation_type, target_label, target_name)
892
+ )
893
+ `);
894
+ await conn.run("CREATE INDEX IF NOT EXISTS idx_nodes_label ON nodes(label)");
895
+ await conn.run("CREATE INDEX IF NOT EXISTS idx_nodes_label_name ON nodes(label, name)");
896
+ await conn.run("CREATE INDEX IF NOT EXISTS idx_rels_source ON relationships(source_label, source_name)");
897
+ await conn.run("CREATE INDEX IF NOT EXISTS idx_rels_target ON relationships(target_label, target_name)");
898
+ }
899
+ async query(sql, _params) {
859
900
  try {
860
- const redis = await this.ensureConnected();
861
- const result = await redis.call("GRAPH.QUERY", this.config.graphName, cypher);
862
- const resultArray = Array.isArray(result) ? result : [];
901
+ const conn = await this.ensureConnected();
902
+ const reader = await conn.runAndReadAll(sql);
903
+ const rows = reader.getRows();
863
904
  return {
864
- resultSet: Array.isArray(resultArray[1]) ? resultArray[1] : [],
865
- stats: this.parseStats(result)
905
+ resultSet: rows,
906
+ stats: undefined
866
907
  };
867
908
  } catch (error) {
868
- this.logger.error(`Cypher query failed: ${error instanceof Error ? error.message : String(error)}`);
909
+ this.logger.error(`Query failed: ${error instanceof Error ? error.message : String(error)}`);
869
910
  throw error;
870
911
  }
871
912
  }
@@ -875,18 +916,15 @@ class GraphService {
875
916
  if (!name) {
876
917
  throw new Error("Node must have a 'name' property");
877
918
  }
878
- const escapedName = this.escapeCypher(String(name));
879
- const escapedLabel = this.escapeCypher(label);
880
- const propAssignments = Object.entries({
881
- name,
882
- ...otherProps
883
- }).map(([key, value]) => {
884
- const escapedKey = this.escapeCypher(key);
885
- const escapedValue = this.escapeCypherValue(value);
886
- return `n.\`${escapedKey}\` = ${escapedValue}`;
887
- }).join(", ");
888
- const cypher = `MERGE (n:\`${escapedLabel}\` { name: '${escapedName}' }) ` + `SET ${propAssignments}`;
889
- await this.query(cypher);
919
+ const conn = await this.ensureConnected();
920
+ const propsJson = JSON.stringify(otherProps);
921
+ await conn.run(`
922
+ INSERT INTO nodes (label, name, properties)
923
+ VALUES ('${this.escape(String(label))}', '${this.escape(String(name))}', '${this.escape(propsJson)}')
924
+ ON CONFLICT (label, name) DO UPDATE SET
925
+ properties = EXCLUDED.properties,
926
+ updated_at = NOW()
927
+ `);
890
928
  } catch (error) {
891
929
  this.logger.error(`Failed to upsert node: ${error instanceof Error ? error.message : String(error)}`);
892
930
  throw error;
@@ -894,21 +932,31 @@ class GraphService {
894
932
  }
895
933
  async upsertRelationship(sourceLabel, sourceName, relation, targetLabel, targetName, properties) {
896
934
  try {
897
- const escapedSourceLabel = this.escapeCypher(sourceLabel);
898
- const escapedSourceName = this.escapeCypher(sourceName);
899
- const escapedRelation = this.escapeCypher(relation);
900
- const escapedTargetLabel = this.escapeCypher(targetLabel);
901
- const escapedTargetName = this.escapeCypher(targetName);
902
- let relPropAssignments = "";
903
- if (properties && Object.keys(properties).length > 0) {
904
- relPropAssignments = ` SET ` + Object.entries(properties).map(([key, value]) => {
905
- const escapedKey = this.escapeCypher(key);
906
- const escapedValue = this.escapeCypherValue(value);
907
- return `r.\`${escapedKey}\` = ${escapedValue}`;
908
- }).join(", ");
909
- }
910
- const cypher = `MERGE (source:\`${escapedSourceLabel}\` { name: '${escapedSourceName}' }) ` + `MERGE (target:\`${escapedTargetLabel}\` { name: '${escapedTargetName}' }) ` + `MERGE (source)-[r:\`${escapedRelation}\`]->(target)` + relPropAssignments;
911
- await this.query(cypher);
935
+ const conn = await this.ensureConnected();
936
+ await conn.run(`
937
+ INSERT INTO nodes (label, name, properties)
938
+ VALUES ('${this.escape(sourceLabel)}', '${this.escape(sourceName)}', '{}')
939
+ ON CONFLICT (label, name) DO NOTHING
940
+ `);
941
+ await conn.run(`
942
+ INSERT INTO nodes (label, name, properties)
943
+ VALUES ('${this.escape(targetLabel)}', '${this.escape(targetName)}', '{}')
944
+ ON CONFLICT (label, name) DO NOTHING
945
+ `);
946
+ const propsJson = properties ? JSON.stringify(properties) : "{}";
947
+ await conn.run(`
948
+ INSERT INTO relationships (source_label, source_name, relation_type, target_label, target_name, properties)
949
+ VALUES (
950
+ '${this.escape(sourceLabel)}',
951
+ '${this.escape(sourceName)}',
952
+ '${this.escape(relation)}',
953
+ '${this.escape(targetLabel)}',
954
+ '${this.escape(targetName)}',
955
+ '${this.escape(propsJson)}'
956
+ )
957
+ ON CONFLICT (source_label, source_name, relation_type, target_label, target_name) DO UPDATE SET
958
+ properties = EXCLUDED.properties
959
+ `);
912
960
  } catch (error) {
913
961
  this.logger.error(`Failed to upsert relationship: ${error instanceof Error ? error.message : String(error)}`);
914
962
  throw error;
@@ -916,10 +964,16 @@ class GraphService {
916
964
  }
917
965
  async deleteNode(label, name) {
918
966
  try {
919
- const escapedLabel = this.escapeCypher(label);
920
- const escapedName = this.escapeCypher(name);
921
- const cypher = `MATCH (n:\`${escapedLabel}\` { name: '${escapedName}' }) ` + `DETACH DELETE n`;
922
- await this.query(cypher);
967
+ const conn = await this.ensureConnected();
968
+ await conn.run(`
969
+ DELETE FROM relationships
970
+ WHERE (source_label = '${this.escape(label)}' AND source_name = '${this.escape(name)}')
971
+ OR (target_label = '${this.escape(label)}' AND target_name = '${this.escape(name)}')
972
+ `);
973
+ await conn.run(`
974
+ DELETE FROM nodes
975
+ WHERE label = '${this.escape(label)}' AND name = '${this.escape(name)}'
976
+ `);
923
977
  } catch (error) {
924
978
  this.logger.error(`Failed to delete node: ${error instanceof Error ? error.message : String(error)}`);
925
979
  throw error;
@@ -927,9 +981,11 @@ class GraphService {
927
981
  }
928
982
  async deleteDocumentRelationships(documentPath) {
929
983
  try {
930
- const escapedPath = this.escapeCypher(documentPath);
931
- const cypher = `MATCH ()-[r { documentPath: '${escapedPath}' }]-() DELETE r`;
932
- await this.query(cypher);
984
+ const conn = await this.ensureConnected();
985
+ await conn.run(`
986
+ DELETE FROM relationships
987
+ WHERE properties->>'documentPath' = '${this.escape(documentPath)}'
988
+ `);
933
989
  } catch (error) {
934
990
  this.logger.error(`Failed to delete document relationships: ${error instanceof Error ? error.message : String(error)}`);
935
991
  throw error;
@@ -937,11 +993,18 @@ class GraphService {
937
993
  }
938
994
  async findNodesByLabel(label, limit) {
939
995
  try {
940
- const escapedLabel = this.escapeCypher(label);
996
+ const conn = await this.ensureConnected();
941
997
  const limitClause = limit ? ` LIMIT ${limit}` : "";
942
- const cypher = `MATCH (n:\`${escapedLabel}\`) RETURN n${limitClause}`;
943
- const result = await this.query(cypher);
944
- return (result.resultSet || []).map((row) => row[0]);
998
+ const reader = await conn.runAndReadAll(`
999
+ SELECT name, properties
1000
+ FROM nodes
1001
+ WHERE label = '${this.escape(label)}'${limitClause}
1002
+ `);
1003
+ return reader.getRows().map((row) => {
1004
+ const [name, properties] = row;
1005
+ const props = properties ? JSON.parse(properties) : {};
1006
+ return { name, ...props };
1007
+ });
945
1008
  } catch (error) {
946
1009
  this.logger.error(`Failed to find nodes by label: ${error instanceof Error ? error.message : String(error)}`);
947
1010
  return [];
@@ -949,10 +1012,17 @@ class GraphService {
949
1012
  }
950
1013
  async findRelationships(nodeName) {
951
1014
  try {
952
- const escapedName = this.escapeCypher(nodeName);
953
- const cypher = `MATCH (n { name: '${escapedName}' })-[r]-(m) ` + `RETURN type(r), m.name`;
954
- const result = await this.query(cypher);
955
- return result.resultSet || [];
1015
+ const conn = await this.ensureConnected();
1016
+ const reader = await conn.runAndReadAll(`
1017
+ SELECT relation_type, target_name, source_name
1018
+ FROM relationships
1019
+ WHERE source_name = '${this.escape(nodeName)}'
1020
+ OR target_name = '${this.escape(nodeName)}'
1021
+ `);
1022
+ return reader.getRows().map((row) => {
1023
+ const [relType, targetName, sourceName] = row;
1024
+ return [relType, sourceName === nodeName ? targetName : sourceName];
1025
+ });
956
1026
  } catch (error) {
957
1027
  this.logger.error(`Failed to find relationships: ${error instanceof Error ? error.message : String(error)}`);
958
1028
  return [];
@@ -960,27 +1030,39 @@ class GraphService {
960
1030
  }
961
1031
  async createVectorIndex(label, property, dimensions) {
962
1032
  try {
963
- const escapedLabel = this.escapeCypher(label);
964
- const escapedProperty = this.escapeCypher(property);
965
- const cypher = `CREATE VECTOR INDEX FOR (n:\`${escapedLabel}\`) ON (n.\`${escapedProperty}\`) OPTIONS { dimension: ${dimensions}, similarityFunction: 'cosine' }`;
966
- await this.query(cypher);
1033
+ const indexKey = `${label}_${property}`;
1034
+ if (this.vectorIndexes.has(indexKey)) {
1035
+ return;
1036
+ }
1037
+ const conn = await this.ensureConnected();
1038
+ try {
1039
+ await conn.run(`
1040
+ CREATE INDEX idx_embedding_${this.escape(label)}
1041
+ ON nodes USING HNSW (embedding)
1042
+ WITH (metric = 'cosine')
1043
+ `);
1044
+ } catch {
1045
+ this.logger.debug(`Vector index on ${label}.${property} already exists`);
1046
+ }
1047
+ this.vectorIndexes.add(indexKey);
967
1048
  this.logger.log(`Created vector index on ${label}.${property} with ${dimensions} dimensions`);
968
1049
  } catch (error) {
969
1050
  const errorMessage = error instanceof Error ? error.message : String(error);
970
- if (!errorMessage.includes("already indexed")) {
1051
+ if (!errorMessage.includes("already exists")) {
971
1052
  this.logger.error(`Failed to create vector index: ${errorMessage}`);
972
1053
  throw error;
973
1054
  }
974
- this.logger.debug(`Vector index on ${label}.${property} already exists`);
975
1055
  }
976
1056
  }
977
1057
  async updateNodeEmbedding(label, name, embedding) {
978
1058
  try {
979
- const escapedLabel = this.escapeCypher(label);
980
- const escapedName = this.escapeCypher(name);
1059
+ const conn = await this.ensureConnected();
981
1060
  const vectorStr = `[${embedding.join(", ")}]`;
982
- const cypher = `MATCH (n:\`${escapedLabel}\` { name: '${escapedName}' }) ` + `SET n.embedding = vecf32(${vectorStr})`;
983
- await this.query(cypher);
1061
+ await conn.run(`
1062
+ UPDATE nodes
1063
+ SET embedding = ${vectorStr}::FLOAT[${this.embeddingDimensions}]
1064
+ WHERE label = '${this.escape(label)}' AND name = '${this.escape(name)}'
1065
+ `);
984
1066
  } catch (error) {
985
1067
  this.logger.error(`Failed to update node embedding: ${error instanceof Error ? error.message : String(error)}`);
986
1068
  throw error;
@@ -988,111 +1070,66 @@ class GraphService {
988
1070
  }
989
1071
  async vectorSearch(label, queryVector, k = 10) {
990
1072
  try {
991
- const escapedLabel = this.escapeCypher(label);
1073
+ const conn = await this.ensureConnected();
992
1074
  const vectorStr = `[${queryVector.join(", ")}]`;
993
- const cypher = `CALL db.idx.vector.queryNodes('${escapedLabel}', 'embedding', ${k}, vecf32(${vectorStr})) ` + `YIELD node, score ` + `RETURN node.name AS name, node.title AS title, (2 - score) / 2 AS similarity ` + `ORDER BY similarity DESC`;
994
- const result = await this.query(cypher);
995
- return (result.resultSet || []).map((row) => ({
996
- name: row[0],
997
- title: row[1],
998
- score: row[2]
999
- }));
1075
+ const reader = await conn.runAndReadAll(`
1076
+ SELECT
1077
+ name,
1078
+ properties->>'title' as title,
1079
+ array_cosine_similarity(embedding, ${vectorStr}::FLOAT[${this.embeddingDimensions}]) as similarity
1080
+ FROM nodes
1081
+ WHERE label = '${this.escape(label)}'
1082
+ AND embedding IS NOT NULL
1083
+ ORDER BY similarity DESC
1084
+ LIMIT ${k}
1085
+ `);
1086
+ return reader.getRows().map((row) => {
1087
+ const [name, title, similarity] = row;
1088
+ return {
1089
+ name,
1090
+ title: title || undefined,
1091
+ score: similarity
1092
+ };
1093
+ });
1000
1094
  } catch (error) {
1001
1095
  this.logger.error(`Vector search failed: ${error instanceof Error ? error.message : String(error)}`);
1002
1096
  throw error;
1003
1097
  }
1004
1098
  }
1005
1099
  async vectorSearchAll(queryVector, k = 10) {
1006
- const allLabels = [
1007
- "Document",
1008
- "Concept",
1009
- "Process",
1010
- "Tool",
1011
- "Technology",
1012
- "Organization",
1013
- "Topic",
1014
- "Person"
1015
- ];
1016
1100
  const allResults = [];
1017
- for (const label of allLabels) {
1018
- try {
1019
- const escapedLabel = this.escapeCypher(label);
1020
- const vectorStr = `[${queryVector.join(", ")}]`;
1021
- const cypher = `CALL db.idx.vector.queryNodes('${escapedLabel}', 'embedding', ${k}, vecf32(${vectorStr})) ` + `YIELD node, score ` + `RETURN node.name AS name, node.title AS title, node.description AS description, (2 - score) / 2 AS similarity ` + `ORDER BY similarity DESC`;
1022
- const result = await this.query(cypher);
1023
- const labelResults = (result.resultSet || []).map((row) => ({
1024
- name: row[0],
1101
+ const conn = await this.ensureConnected();
1102
+ const vectorStr = `[${queryVector.join(", ")}]`;
1103
+ try {
1104
+ const reader = await conn.runAndReadAll(`
1105
+ SELECT
1106
+ name,
1107
+ label,
1108
+ properties->>'title' as title,
1109
+ properties->>'description' as description,
1110
+ array_cosine_similarity(embedding, ${vectorStr}::FLOAT[${this.embeddingDimensions}]) as similarity
1111
+ FROM nodes
1112
+ WHERE embedding IS NOT NULL
1113
+ ORDER BY similarity DESC
1114
+ LIMIT ${k}
1115
+ `);
1116
+ for (const row of reader.getRows()) {
1117
+ const [name, label, title, description, similarity] = row;
1118
+ allResults.push({
1119
+ name,
1025
1120
  label,
1026
- title: row[1],
1027
- description: row[2],
1028
- score: row[3]
1029
- }));
1030
- allResults.push(...labelResults);
1031
- } catch (error) {
1032
- this.logger.debug(`Vector search for ${label} skipped: ${error instanceof Error ? error.message : String(error)}`);
1121
+ title: title || undefined,
1122
+ description: description || undefined,
1123
+ score: similarity
1124
+ });
1033
1125
  }
1126
+ } catch (error) {
1127
+ this.logger.debug(`Vector search failed: ${error instanceof Error ? error.message : String(error)}`);
1034
1128
  }
1035
1129
  return allResults.sort((a, b) => b.score - a.score).slice(0, k);
1036
1130
  }
1037
- escapeCypher(value) {
1038
- return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, "\\\"");
1039
- }
1040
- escapeCypherValue(value) {
1041
- if (value === null || value === undefined) {
1042
- return "null";
1043
- }
1044
- if (typeof value === "string") {
1045
- const escaped = this.escapeCypher(value);
1046
- return `'${escaped}'`;
1047
- }
1048
- if (typeof value === "number" || typeof value === "boolean") {
1049
- return String(value);
1050
- }
1051
- if (Array.isArray(value)) {
1052
- return `[${value.map((v) => this.escapeCypherValue(v)).join(", ")}]`;
1053
- }
1054
- if (typeof value === "object") {
1055
- const pairs = Object.entries(value).map(([k, v]) => `${k}: ${this.escapeCypherValue(v)}`).join(", ");
1056
- return `{${pairs}}`;
1057
- }
1058
- return String(value);
1059
- }
1060
- parseStats(result) {
1061
- if (!Array.isArray(result) || result.length < 3) {
1062
- return;
1063
- }
1064
- const statsStr = result[2];
1065
- if (!statsStr || typeof statsStr !== "string") {
1066
- return;
1067
- }
1068
- const stats = {
1069
- nodesCreated: 0,
1070
- nodesDeleted: 0,
1071
- relationshipsCreated: 0,
1072
- relationshipsDeleted: 0,
1073
- propertiesSet: 0
1074
- };
1075
- const nodeCreatedMatch = statsStr.match(/Nodes created: (\d+)/);
1076
- if (nodeCreatedMatch) {
1077
- stats.nodesCreated = parseInt(nodeCreatedMatch[1], 10);
1078
- }
1079
- const nodeDeletedMatch = statsStr.match(/Nodes deleted: (\d+)/);
1080
- if (nodeDeletedMatch) {
1081
- stats.nodesDeleted = parseInt(nodeDeletedMatch[1], 10);
1082
- }
1083
- const relCreatedMatch = statsStr.match(/Relationships created: (\d+)/);
1084
- if (relCreatedMatch) {
1085
- stats.relationshipsCreated = parseInt(relCreatedMatch[1], 10);
1086
- }
1087
- const relDeletedMatch = statsStr.match(/Relationships deleted: (\d+)/);
1088
- if (relDeletedMatch) {
1089
- stats.relationshipsDeleted = parseInt(relDeletedMatch[1], 10);
1090
- }
1091
- const propSetMatch = statsStr.match(/Properties set: (\d+)/);
1092
- if (propSetMatch) {
1093
- stats.propertiesSet = parseInt(propSetMatch[1], 10);
1094
- }
1095
- return stats;
1131
+ escape(value) {
1132
+ return value.replace(/'/g, "''");
1096
1133
  }
1097
1134
  }
1098
1135
  GraphService = __legacyDecorateClassTS([
@@ -1218,49 +1255,19 @@ class RelsCommand extends CommandRunner3 {
1218
1255
  async run(inputs) {
1219
1256
  const name = inputs[0];
1220
1257
  try {
1221
- const escapedName = name.replace(/'/g, "\\'");
1222
- const cypher = `MATCH (a { name: '${escapedName}' })-[r]-(b) RETURN a, r, b`;
1223
- const result = await this.graphService.query(cypher);
1224
- const results = result.resultSet || [];
1258
+ const relationships = await this.graphService.findRelationships(name);
1225
1259
  console.log(`
1226
1260
  === Relationships for "${name}" ===
1227
1261
  `);
1228
- if (results.length === 0) {
1262
+ if (relationships.length === 0) {
1229
1263
  console.log(`No relationships found.
1230
1264
  `);
1231
1265
  process.exit(0);
1232
1266
  }
1233
- const incoming = [];
1234
- const outgoing = [];
1235
- for (const row of results) {
1236
- const [source, rel, target] = row;
1237
- const sourceObj = Object.fromEntries(source);
1238
- const targetObj = Object.fromEntries(target);
1239
- const relObj = Object.fromEntries(rel);
1240
- const sourceProps = Object.fromEntries(sourceObj.properties || []);
1241
- const targetProps = Object.fromEntries(targetObj.properties || []);
1242
- const sourceName = sourceProps.name || "unknown";
1243
- const targetName = targetProps.name || "unknown";
1244
- const relType = relObj.type || "UNKNOWN";
1245
- if (sourceName === name) {
1246
- outgoing.push(` -[${relType}]-> ${targetName}`);
1247
- } else {
1248
- incoming.push(` <-[${relType}]- ${sourceName}`);
1249
- }
1250
- }
1251
- if (outgoing.length > 0) {
1252
- console.log("Outgoing:");
1253
- for (const r of outgoing) {
1254
- console.log(r);
1255
- }
1256
- }
1257
- if (incoming.length > 0) {
1258
- if (outgoing.length > 0)
1259
- console.log();
1260
- console.log("Incoming:");
1261
- for (const r of incoming) {
1262
- console.log(r);
1263
- }
1267
+ console.log("Relationships:");
1268
+ for (const rel of relationships) {
1269
+ const [relType, targetName] = rel;
1270
+ console.log(` -[${relType}]-> ${targetName}`);
1264
1271
  }
1265
1272
  console.log();
1266
1273
  process.exit(0);
@@ -1282,7 +1289,7 @@ RelsCommand = __legacyDecorateClassTS([
1282
1289
  ])
1283
1290
  ], RelsCommand);
1284
1291
 
1285
- class CypherCommand extends CommandRunner3 {
1292
+ class SqlCommand extends CommandRunner3 {
1286
1293
  graphService;
1287
1294
  constructor(graphService) {
1288
1295
  super();
@@ -1293,9 +1300,10 @@ class CypherCommand extends CommandRunner3 {
1293
1300
  try {
1294
1301
  const result = await this.graphService.query(query);
1295
1302
  console.log(`
1296
- === Cypher Query Results ===
1303
+ === SQL Query Results ===
1297
1304
  `);
1298
- console.log(JSON.stringify(result, null, 2));
1305
+ const replacer = (_key, value) => typeof value === "bigint" ? Number(value) : value;
1306
+ console.log(JSON.stringify(result, replacer, 2));
1299
1307
  console.log();
1300
1308
  process.exit(0);
1301
1309
  } catch (error) {
@@ -1304,17 +1312,17 @@ class CypherCommand extends CommandRunner3 {
1304
1312
  }
1305
1313
  }
1306
1314
  }
1307
- CypherCommand = __legacyDecorateClassTS([
1315
+ SqlCommand = __legacyDecorateClassTS([
1308
1316
  Injectable7(),
1309
1317
  Command3({
1310
- name: "cypher",
1318
+ name: "sql",
1311
1319
  arguments: "<query>",
1312
- description: "Execute raw Cypher query"
1320
+ description: "Execute raw SQL query against DuckDB"
1313
1321
  }),
1314
1322
  __legacyMetadataTS("design:paramtypes", [
1315
1323
  typeof GraphService === "undefined" ? Object : GraphService
1316
1324
  ])
1317
- ], CypherCommand);
1325
+ ], SqlCommand);
1318
1326
  // src/commands/status.command.ts
1319
1327
  import { Injectable as Injectable12 } from "@nestjs/common";
1320
1328
  import { Command as Command4, CommandRunner as CommandRunner4, Option as Option3 } from "nest-commander";
@@ -1432,6 +1440,7 @@ ManifestService = __legacyDecorateClassTS([
1432
1440
 
1433
1441
  // src/sync/sync.service.ts
1434
1442
  import { Injectable as Injectable11, Logger as Logger5 } from "@nestjs/common";
1443
+
1435
1444
  // src/pure/embedding-text.ts
1436
1445
  function composeDocumentEmbeddingText(doc) {
1437
1446
  const parts = [];
@@ -1645,10 +1654,13 @@ class CascadeService {
1645
1654
  }
1646
1655
  async findAffectedByRename(entityName, _newName) {
1647
1656
  try {
1648
- const escapedName = this.escapeForCypher(entityName);
1657
+ const escapedName = this.escapeForSql(entityName);
1649
1658
  const query = `
1650
- MATCH (e {name: '${escapedName}'})-[:APPEARS_IN]->(d:Document)
1651
- RETURN d.name, d.title
1659
+ SELECT DISTINCT n.name, n.properties->>'title' as title
1660
+ FROM nodes n
1661
+ INNER JOIN relationships r ON r.target_label = n.label AND r.target_name = n.name
1662
+ WHERE r.source_name = '${escapedName}'
1663
+ AND n.label = 'Document'
1652
1664
  `.trim();
1653
1665
  const result = await this.graph.query(query);
1654
1666
  return (result.resultSet || []).map((row) => ({
@@ -1665,10 +1677,13 @@ class CascadeService {
1665
1677
  }
1666
1678
  async findAffectedByDeletion(entityName) {
1667
1679
  try {
1668
- const escapedName = this.escapeForCypher(entityName);
1680
+ const escapedName = this.escapeForSql(entityName);
1669
1681
  const query = `
1670
- MATCH (e {name: '${escapedName}'})-[:APPEARS_IN]->(d:Document)
1671
- RETURN d.name, d.title
1682
+ SELECT DISTINCT n.name, n.properties->>'title' as title
1683
+ FROM nodes n
1684
+ INNER JOIN relationships r ON r.target_label = n.label AND r.target_name = n.name
1685
+ WHERE r.source_name = '${escapedName}'
1686
+ AND n.label = 'Document'
1672
1687
  `.trim();
1673
1688
  const result = await this.graph.query(query);
1674
1689
  return (result.resultSet || []).map((row) => ({
@@ -1685,10 +1700,13 @@ class CascadeService {
1685
1700
  }
1686
1701
  async findAffectedByTypeChange(entityName, oldType, newType) {
1687
1702
  try {
1688
- const escapedName = this.escapeForCypher(entityName);
1703
+ const escapedName = this.escapeForSql(entityName);
1689
1704
  const query = `
1690
- MATCH (e {name: '${escapedName}'})-[:APPEARS_IN]->(d:Document)
1691
- RETURN d.name, d.title
1705
+ SELECT DISTINCT n.name, n.properties->>'title' as title
1706
+ FROM nodes n
1707
+ INNER JOIN relationships r ON r.target_label = n.label AND r.target_name = n.name
1708
+ WHERE r.source_name = '${escapedName}'
1709
+ AND n.label = 'Document'
1692
1710
  `.trim();
1693
1711
  const result = await this.graph.query(query);
1694
1712
  return (result.resultSet || []).map((row) => ({
@@ -1705,10 +1723,13 @@ class CascadeService {
1705
1723
  }
1706
1724
  async findAffectedByRelationshipChange(entityName) {
1707
1725
  try {
1708
- const escapedName = this.escapeForCypher(entityName);
1726
+ const escapedName = this.escapeForSql(entityName);
1709
1727
  const query = `
1710
- MATCH (e {name: '${escapedName}'})-[r]->(d:Document)
1711
- RETURN d.name, d.title, type(r) as relType
1728
+ SELECT DISTINCT n.name, n.properties->>'title' as title, r.relation_type
1729
+ FROM nodes n
1730
+ INNER JOIN relationships r ON r.target_label = n.label AND r.target_name = n.name
1731
+ WHERE r.source_name = '${escapedName}'
1732
+ AND n.label = 'Document'
1712
1733
  `.trim();
1713
1734
  const result = await this.graph.query(query);
1714
1735
  return (result.resultSet || []).map((row) => ({
@@ -1725,10 +1746,13 @@ class CascadeService {
1725
1746
  }
1726
1747
  async findAffectedByDocumentDeletion(documentPath) {
1727
1748
  try {
1728
- const escapedPath = this.escapeForCypher(documentPath);
1749
+ const escapedPath = this.escapeForSql(documentPath);
1729
1750
  const query = `
1730
- MATCH (d:Document)-[r]->(deleted:Document {name: '${escapedPath}'})
1731
- RETURN d.name, type(r) as relType
1751
+ SELECT DISTINCT n.name, r.relation_type
1752
+ FROM nodes n
1753
+ INNER JOIN relationships r ON r.source_label = n.label AND r.source_name = n.name
1754
+ WHERE r.target_name = '${escapedPath}'
1755
+ AND n.label = 'Document'
1732
1756
  `.trim();
1733
1757
  const result = await this.graph.query(query);
1734
1758
  return (result.resultSet || []).map((row) => ({
@@ -1815,8 +1839,8 @@ class CascadeService {
1815
1839
  return action;
1816
1840
  }
1817
1841
  }
1818
- escapeForCypher(value) {
1819
- return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, "\\\"");
1842
+ escapeForSql(value) {
1843
+ return value.replace(/'/g, "''");
1820
1844
  }
1821
1845
  }
1822
1846
  CascadeService = __legacyDecorateClassTS([
@@ -2840,9 +2864,9 @@ class QueryService {
2840
2864
  constructor(graphService) {
2841
2865
  this.graphService = graphService;
2842
2866
  }
2843
- async query(cypher) {
2844
- this.logger.debug(`Executing query: ${cypher}`);
2845
- return await this.graphService.query(cypher);
2867
+ async query(sql) {
2868
+ this.logger.debug(`Executing query: ${sql}`);
2869
+ return await this.graphService.query(sql);
2846
2870
  }
2847
2871
  }
2848
2872
  QueryService = __legacyDecorateClassTS([
@@ -2908,7 +2932,7 @@ AppModule = __legacyDecorateClassTS([
2908
2932
  StatusCommand,
2909
2933
  SearchCommand,
2910
2934
  RelsCommand,
2911
- CypherCommand,
2935
+ SqlCommand,
2912
2936
  ValidateCommand,
2913
2937
  OntologyCommand,
2914
2938
  InitCommand
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zabaca/lattice",
3
- "version": "0.3.4",
3
+ "version": "1.0.1",
4
4
  "description": "Human-initiated, AI-powered knowledge graph for markdown documentation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "keywords": [
28
28
  "knowledge-graph",
29
- "falkordb",
29
+ "duckdb",
30
30
  "documentation",
31
31
  "markdown",
32
32
  "ai",
@@ -43,13 +43,13 @@
43
43
  "url": "https://github.com/Zabaca/lattice.git"
44
44
  },
45
45
  "dependencies": {
46
+ "@duckdb/node-api": "1.3.1-alpha.23",
46
47
  "@nestjs/common": "^10.0.0",
47
48
  "@nestjs/config": "^3.0.0",
48
49
  "@nestjs/core": "^10.0.0",
49
50
  "chalk": "^5.3.0",
50
51
  "glob": "^10.3.0",
51
52
  "gray-matter": "^4.0.3",
52
- "ioredis": "^5.3.0",
53
53
  "nest-commander": "^3.12.0",
54
54
  "reflect-metadata": "^0.2.0",
55
55
  "rxjs": "^7.8.0",
@@ -59,7 +59,6 @@
59
59
  },
60
60
  "devDependencies": {
61
61
  "@biomejs/biome": "^2.2.6",
62
- "@tkoehlerlg/bun-mock-extended": "^1.0.0",
63
62
  "@types/bun": "latest",
64
63
  "typescript": "^5.0.0"
65
64
  }