logosdb 0.7.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.
- package/README.md +143 -0
- package/binding.gyp +65 -0
- package/lib/index.js +184 -0
- package/package.json +59 -0
- package/src/node_logosdb.cpp +405 -0
- package/test/test.js +320 -0
- package/types/index.d.ts +138 -0
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# LogosDB - Node.js Bindings
|
|
2
|
+
|
|
3
|
+
Fast semantic vector database (HNSW + mmap) for Node.js. Zero-copy memory-mapped storage with local-only deployment.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Memory-efficient**: Uses mmap() — RAM scales with queries, not data size
|
|
8
|
+
- **Fast**: HNSW approximate nearest neighbor (O(log n) queries)
|
|
9
|
+
- **Local-only**: No cloud dependencies, data never leaves your machine
|
|
10
|
+
- **TypeScript support**: Full type definitions included
|
|
11
|
+
- **Cross-platform**: Linux, macOS, Windows (x64, arm64)
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install logosdb
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Prebuilt binaries are provided for common platforms. If a prebuilt binary is not available, npm will compile from source (requires Python, C++ compiler, and CMake).
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```javascript
|
|
24
|
+
const { DB, DIST_COSINE } = require('logosdb');
|
|
25
|
+
|
|
26
|
+
// Create database
|
|
27
|
+
const db = new DB('/tmp/mydb', {
|
|
28
|
+
dim: 384, // Vector dimension
|
|
29
|
+
distance: DIST_COSINE // Auto-normalizes vectors
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Insert documents with embeddings
|
|
33
|
+
const embedding = [0.1, 0.2, /* ... 384 floats ... */];
|
|
34
|
+
const id = db.put(embedding, 'My document text', '2025-01-01T00:00:00Z');
|
|
35
|
+
|
|
36
|
+
// Search
|
|
37
|
+
const query = [0.15, 0.25, /* ... */];
|
|
38
|
+
const hits = db.search(query, 5);
|
|
39
|
+
|
|
40
|
+
for (const hit of hits) {
|
|
41
|
+
console.log(`${hit.score.toFixed(4)} ${hit.text}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Close
|
|
45
|
+
db.close();
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
### `new DB(path, options)`
|
|
51
|
+
|
|
52
|
+
Create a new database instance.
|
|
53
|
+
|
|
54
|
+
**Options:**
|
|
55
|
+
- `dim` (number): Vector dimension (default: 128)
|
|
56
|
+
- `maxElements` (number): Maximum capacity (default: 1,000,000)
|
|
57
|
+
- `efConstruction` (number): HNSW build quality (default: 200)
|
|
58
|
+
- `M` (number): HNSW graph degree (default: 16)
|
|
59
|
+
- `efSearch` (number): HNSW search width (default: 50)
|
|
60
|
+
- `distance` (number): Distance metric (`DIST_IP`, `DIST_COSINE`, `DIST_L2`)
|
|
61
|
+
|
|
62
|
+
### `db.put(embedding, text?, timestamp?)`
|
|
63
|
+
|
|
64
|
+
Insert a vector. Returns the assigned row ID.
|
|
65
|
+
|
|
66
|
+
### `db.search(queryEmbedding, topK?)`
|
|
67
|
+
|
|
68
|
+
Search for similar vectors. Returns array of `SearchHit` objects.
|
|
69
|
+
|
|
70
|
+
### `db.searchTsRange(queryEmbedding, options)`
|
|
71
|
+
|
|
72
|
+
Search with timestamp filter.
|
|
73
|
+
|
|
74
|
+
**Options:**
|
|
75
|
+
- `topK` (number): Number of results
|
|
76
|
+
- `tsFrom` (string): Start timestamp (ISO 8601)
|
|
77
|
+
- `tsTo` (string): End timestamp (ISO 8601)
|
|
78
|
+
- `candidateK` (number): Internal multiplier for filtering
|
|
79
|
+
|
|
80
|
+
### `db.update(id, embedding, text?, timestamp?)`
|
|
81
|
+
|
|
82
|
+
Update a row (marks old as deleted, creates new). Returns new ID.
|
|
83
|
+
|
|
84
|
+
### `db.delete(id)`
|
|
85
|
+
|
|
86
|
+
Delete a row by ID.
|
|
87
|
+
|
|
88
|
+
### `db.count()` / `db.countLive()`
|
|
89
|
+
|
|
90
|
+
Get total/live row counts.
|
|
91
|
+
|
|
92
|
+
### `db.close()`
|
|
93
|
+
|
|
94
|
+
Close the database.
|
|
95
|
+
|
|
96
|
+
## Distance Metrics
|
|
97
|
+
|
|
98
|
+
- `DIST_IP` (0): Inner product (default, requires L2-normalized vectors)
|
|
99
|
+
- `DIST_COSINE` (1): Cosine similarity (auto-normalizes)
|
|
100
|
+
- `DIST_L2` (2): Euclidean distance
|
|
101
|
+
|
|
102
|
+
## Memory Model
|
|
103
|
+
|
|
104
|
+
LogosDB uses memory-mapped files:
|
|
105
|
+
|
|
106
|
+
| Dataset | Disk | Typical Query RAM |
|
|
107
|
+
|---------|------|-------------------|
|
|
108
|
+
| 100K × 384-dim | 153 MB | <20 MB |
|
|
109
|
+
| 1M × 384-dim | 1.5 GB | <100 MB |
|
|
110
|
+
| 10M × 384-dim | 15 GB | <200 MB |
|
|
111
|
+
|
|
112
|
+
RAM scales with query patterns, not dataset size.
|
|
113
|
+
|
|
114
|
+
## TypeScript
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
import { DB, SearchHit, DIST_COSINE } from 'logosdb';
|
|
118
|
+
|
|
119
|
+
const db = new DB('/tmp/mydb', { dim: 384, distance: DIST_COSINE });
|
|
120
|
+
const hits: SearchHit[] = db.search(embedding, 5);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Building from Source
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npm install --build-from-source
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Requirements:
|
|
130
|
+
- Python 3.x
|
|
131
|
+
- C++17 compiler (GCC, Clang, MSVC)
|
|
132
|
+
- CMake 3.15+
|
|
133
|
+
- Node.js 16+
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT — see [LICENSE](../LICENSE)
|
|
138
|
+
|
|
139
|
+
## Links
|
|
140
|
+
|
|
141
|
+
- GitHub: https://github.com/jose-compu/logosdb
|
|
142
|
+
- Issues: https://github.com/jose-compu/logosdb/issues
|
|
143
|
+
- Python bindings: `pip install logosdb`
|
package/binding.gyp
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"variables": {
|
|
3
|
+
"openssl_fips%": ""
|
|
4
|
+
},
|
|
5
|
+
"targets": [
|
|
6
|
+
{
|
|
7
|
+
"target_name": "logosdb",
|
|
8
|
+
"sources": [
|
|
9
|
+
"src/node_logosdb.cpp",
|
|
10
|
+
"../src/logosdb.cpp",
|
|
11
|
+
"../src/storage.cpp",
|
|
12
|
+
"../src/metadata.cpp",
|
|
13
|
+
"../src/hnsw_index.cpp",
|
|
14
|
+
"../src/wal.cpp",
|
|
15
|
+
"../src/platform.cpp"
|
|
16
|
+
],
|
|
17
|
+
"include_dirs": [
|
|
18
|
+
"<!@(node -p \"require('node-addon-api').include\")",
|
|
19
|
+
"../include",
|
|
20
|
+
"../src",
|
|
21
|
+
"../third_party"
|
|
22
|
+
],
|
|
23
|
+
"cflags!": ["-fno-exceptions"],
|
|
24
|
+
"cflags_cc!": ["-fno-exceptions"],
|
|
25
|
+
"cflags_cc": [
|
|
26
|
+
"-std=c++17",
|
|
27
|
+
"-fexceptions"
|
|
28
|
+
],
|
|
29
|
+
"conditions": [
|
|
30
|
+
["OS=='mac'", {
|
|
31
|
+
"xcode_settings": {
|
|
32
|
+
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
|
|
33
|
+
"CLANG_CXX_LIBRARY": "libc++",
|
|
34
|
+
"MACOSX_DEPLOYMENT_TARGET": "11.0",
|
|
35
|
+
"OTHER_CPLUSPLUSFLAGS": [
|
|
36
|
+
"-std=c++17",
|
|
37
|
+
"-fexceptions"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}],
|
|
41
|
+
["OS=='linux'", {
|
|
42
|
+
"cflags_cc": [
|
|
43
|
+
"-std=c++17",
|
|
44
|
+
"-fexceptions"
|
|
45
|
+
],
|
|
46
|
+
"ldflags": [
|
|
47
|
+
"-Wl,--gc-sections"
|
|
48
|
+
]
|
|
49
|
+
}],
|
|
50
|
+
["OS=='win'", {
|
|
51
|
+
"msvs_settings": {
|
|
52
|
+
"VCCLCompilerTool": {
|
|
53
|
+
"ExceptionHandling": 1,
|
|
54
|
+
"AdditionalOptions": ["/std:c++17"]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}]
|
|
58
|
+
],
|
|
59
|
+
"defines": [
|
|
60
|
+
"NAPI_CPP_EXCEPTIONS",
|
|
61
|
+
"NAPI_VERSION=8"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogosDB - Fast semantic vector database (HNSW + mmap)
|
|
3
|
+
* Node.js bindings
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const bindings = require('bindings')('logosdb');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Search result hit
|
|
12
|
+
* @typedef {Object} SearchHit
|
|
13
|
+
* @property {number} id - Row ID
|
|
14
|
+
* @property {number} score - Similarity score (0-1)
|
|
15
|
+
* @property {string|null} text - Associated text
|
|
16
|
+
* @property {string|null} timestamp - ISO 8601 timestamp
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* LogosDB database instance
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const { DB } = require('logosdb');
|
|
24
|
+
*
|
|
25
|
+
* const db = new DB('/tmp/mydb', { dim: 128 });
|
|
26
|
+
*
|
|
27
|
+
* // Insert with embedding
|
|
28
|
+
* const id = db.put(embedding, 'My text', '2025-01-01T00:00:00Z');
|
|
29
|
+
*
|
|
30
|
+
* // Search
|
|
31
|
+
* const hits = db.search(queryEmbedding, 5);
|
|
32
|
+
* console.log(hits[0].text, hits[0].score);
|
|
33
|
+
*/
|
|
34
|
+
class DB {
|
|
35
|
+
/**
|
|
36
|
+
* Create a new LogosDB instance
|
|
37
|
+
* @param {string} path - Database directory path
|
|
38
|
+
* @param {Object} [options] - Database options
|
|
39
|
+
* @param {number} [options.dim=128] - Vector dimension
|
|
40
|
+
* @param {number} [options.maxElements=1000000] - Maximum capacity
|
|
41
|
+
* @param {number} [options.efConstruction=200] - HNSW construction parameter
|
|
42
|
+
* @param {number} [options.M=16] - HNSW M parameter
|
|
43
|
+
* @param {number} [options.efSearch=50] - HNSW search parameter
|
|
44
|
+
* @param {number} [options.distance=DIST_IP] - Distance metric (DIST_IP, DIST_COSINE, DIST_L2)
|
|
45
|
+
*/
|
|
46
|
+
constructor(path, options = {}) {
|
|
47
|
+
this._db = new bindings.DB(path, options);
|
|
48
|
+
this._closed = false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Insert a vector with optional text and timestamp
|
|
53
|
+
* @param {number[]} embedding - Float vector
|
|
54
|
+
* @param {string} [text] - Optional text metadata
|
|
55
|
+
* @param {string} [timestamp] - Optional ISO 8601 timestamp
|
|
56
|
+
* @returns {number} Assigned row ID
|
|
57
|
+
*/
|
|
58
|
+
put(embedding, text, timestamp) {
|
|
59
|
+
this._checkClosed();
|
|
60
|
+
return this._db.put(embedding, text, timestamp);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Search for similar vectors
|
|
65
|
+
* @param {number[]} queryEmbedding - Query vector
|
|
66
|
+
* @param {number} [topK=10] - Number of results
|
|
67
|
+
* @returns {SearchHit[]} Search results
|
|
68
|
+
*/
|
|
69
|
+
search(queryEmbedding, topK = 10) {
|
|
70
|
+
this._checkClosed();
|
|
71
|
+
return this._db.search(queryEmbedding, topK);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Search with timestamp range filter
|
|
76
|
+
* @param {number[]} queryEmbedding - Query vector
|
|
77
|
+
* @param {Object} options - Search options
|
|
78
|
+
* @param {number} [options.topK=10] - Number of results
|
|
79
|
+
* @param {string} [options.tsFrom] - Start timestamp (inclusive)
|
|
80
|
+
* @param {string} [options.tsTo] - End timestamp (inclusive)
|
|
81
|
+
* @param {number} [options.candidateK] - Internal candidate multiplier
|
|
82
|
+
* @returns {SearchHit[]} Search results
|
|
83
|
+
*/
|
|
84
|
+
searchTsRange(queryEmbedding, options = {}) {
|
|
85
|
+
this._checkClosed();
|
|
86
|
+
return this._db.searchTsRange(queryEmbedding, options);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Update an existing row (marks old as deleted, creates new)
|
|
91
|
+
* @param {number} id - Row ID to update
|
|
92
|
+
* @param {number[]} embedding - New embedding
|
|
93
|
+
* @param {string} [text] - New text
|
|
94
|
+
* @param {string} [timestamp] - New timestamp
|
|
95
|
+
* @returns {number} New row ID
|
|
96
|
+
*/
|
|
97
|
+
update(id, embedding, text, timestamp) {
|
|
98
|
+
this._checkClosed();
|
|
99
|
+
return this._db.update(id, embedding, text, timestamp);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Delete a row by ID
|
|
104
|
+
* @param {number} id - Row ID to delete
|
|
105
|
+
*/
|
|
106
|
+
delete(id) {
|
|
107
|
+
this._checkClosed();
|
|
108
|
+
this._db.delete(id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get total row count (including deleted)
|
|
113
|
+
* @returns {number}
|
|
114
|
+
*/
|
|
115
|
+
count() {
|
|
116
|
+
this._checkClosed();
|
|
117
|
+
return this._db.count();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get live row count (excluding deleted)
|
|
122
|
+
* @returns {number}
|
|
123
|
+
*/
|
|
124
|
+
countLive() {
|
|
125
|
+
this._checkClosed();
|
|
126
|
+
return this._db.countLive();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get vector dimension
|
|
131
|
+
* @returns {number}
|
|
132
|
+
*/
|
|
133
|
+
dim() {
|
|
134
|
+
return this._db.dim();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Close the database
|
|
139
|
+
*/
|
|
140
|
+
close() {
|
|
141
|
+
if (!this._closed) {
|
|
142
|
+
this._db.close();
|
|
143
|
+
this._closed = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_checkClosed() {
|
|
148
|
+
if (this._closed) {
|
|
149
|
+
throw new Error('Database is closed');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Inner product distance (default, requires L2-normalized vectors)
|
|
156
|
+
* @constant {number}
|
|
157
|
+
*/
|
|
158
|
+
const DIST_IP = bindings.DIST_IP;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Cosine similarity (auto-normalizes vectors)
|
|
162
|
+
* @constant {number}
|
|
163
|
+
*/
|
|
164
|
+
const DIST_COSINE = bindings.DIST_COSINE;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Euclidean distance (L2 space)
|
|
168
|
+
* @constant {number}
|
|
169
|
+
*/
|
|
170
|
+
const DIST_L2 = bindings.DIST_L2;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Library version string
|
|
174
|
+
* @constant {string}
|
|
175
|
+
*/
|
|
176
|
+
const VERSION = bindings.VERSION;
|
|
177
|
+
|
|
178
|
+
module.exports = {
|
|
179
|
+
DB,
|
|
180
|
+
DIST_IP,
|
|
181
|
+
DIST_COSINE,
|
|
182
|
+
DIST_L2,
|
|
183
|
+
VERSION,
|
|
184
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "logosdb",
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"description": "Fast semantic vector database (HNSW + mmap) - Node.js bindings",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "types/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"install": "prebuild-install || node-gyp rebuild",
|
|
9
|
+
"build": "node-gyp rebuild",
|
|
10
|
+
"test": "mocha test/test.js",
|
|
11
|
+
"prebuild": "prebuild --runtime napi --all --strip",
|
|
12
|
+
"prebuild-upload": "prebuild --runtime napi --all --strip --upload-all"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/jose-compu/logosdb.git"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"vector-database",
|
|
20
|
+
"hnsw",
|
|
21
|
+
"embeddings",
|
|
22
|
+
"semantic-search",
|
|
23
|
+
"nearest-neighbor",
|
|
24
|
+
"native-addon"
|
|
25
|
+
],
|
|
26
|
+
"author": "Jose",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/jose-compu/logosdb/issues"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/jose-compu/logosdb#readme",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"bindings": "^1.5.0",
|
|
34
|
+
"node-addon-api": "^7.0.0",
|
|
35
|
+
"prebuild-install": "^7.1.1"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"mocha": "^10.2.0",
|
|
39
|
+
"prebuild": "^12.1.0"
|
|
40
|
+
},
|
|
41
|
+
"binary": {
|
|
42
|
+
"napi_versions": [
|
|
43
|
+
8
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"gypfile": true,
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=16.0.0"
|
|
49
|
+
},
|
|
50
|
+
"os": [
|
|
51
|
+
"darwin",
|
|
52
|
+
"linux",
|
|
53
|
+
"win32"
|
|
54
|
+
],
|
|
55
|
+
"cpu": [
|
|
56
|
+
"x64",
|
|
57
|
+
"arm64"
|
|
58
|
+
]
|
|
59
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js N-API bindings for LogosDB
|
|
3
|
+
*
|
|
4
|
+
* Provides JavaScript bindings for the LogosDB C++ API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
#include <napi.h>
|
|
8
|
+
#include <logosdb/logosdb.h>
|
|
9
|
+
#include <cstring>
|
|
10
|
+
#include <string>
|
|
11
|
+
#include <vector>
|
|
12
|
+
|
|
13
|
+
// Helper to convert JS array to float vector
|
|
14
|
+
static std::vector<float> JsArrayToFloatVector(const Napi::Array& arr) {
|
|
15
|
+
std::vector<float> vec;
|
|
16
|
+
vec.reserve(arr.Length());
|
|
17
|
+
for (size_t i = 0; i < arr.Length(); i++) {
|
|
18
|
+
vec.push_back(arr.Get(i).As<Napi::Number>().FloatValue());
|
|
19
|
+
}
|
|
20
|
+
return vec;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Helper to convert SearchHit to JS object
|
|
24
|
+
static Napi::Object SearchHitToJsObject(Napi::Env env, const logosdb_search_result_t* result, int index) {
|
|
25
|
+
Napi::Object obj = Napi::Object::New(env);
|
|
26
|
+
|
|
27
|
+
uint64_t id = logosdb_result_id(result, index);
|
|
28
|
+
float score = logosdb_result_score(result, index);
|
|
29
|
+
const char* text = logosdb_result_text(result, index);
|
|
30
|
+
const char* timestamp = logosdb_result_timestamp(result, index);
|
|
31
|
+
|
|
32
|
+
obj.Set("id", Napi::Number::New(env, static_cast<double>(id)));
|
|
33
|
+
obj.Set("score", Napi::Number::New(env, score));
|
|
34
|
+
if (text) {
|
|
35
|
+
obj.Set("text", Napi::String::New(env, text));
|
|
36
|
+
} else {
|
|
37
|
+
obj.Set("text", env.Null());
|
|
38
|
+
}
|
|
39
|
+
if (timestamp) {
|
|
40
|
+
obj.Set("timestamp", Napi::String::New(env, timestamp));
|
|
41
|
+
} else {
|
|
42
|
+
obj.Set("timestamp", env.Null());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return obj;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// DB wrapper class
|
|
49
|
+
class DBWrapper : public Napi::ObjectWrap<DBWrapper> {
|
|
50
|
+
public:
|
|
51
|
+
static Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
|
52
|
+
Napi::Function func = DefineClass(env, "DB", {
|
|
53
|
+
InstanceMethod("put", &DBWrapper::Put),
|
|
54
|
+
InstanceMethod("search", &DBWrapper::Search),
|
|
55
|
+
InstanceMethod("searchTsRange", &DBWrapper::SearchTsRange),
|
|
56
|
+
InstanceMethod("update", &DBWrapper::Update),
|
|
57
|
+
InstanceMethod("delete", &DBWrapper::Delete),
|
|
58
|
+
InstanceMethod("count", &DBWrapper::Count),
|
|
59
|
+
InstanceMethod("countLive", &DBWrapper::CountLive),
|
|
60
|
+
InstanceMethod("dim", &DBWrapper::Dim),
|
|
61
|
+
InstanceMethod("close", &DBWrapper::Close),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
exports.Set("DB", func);
|
|
65
|
+
return exports;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
DBWrapper(const Napi::CallbackInfo& info) : Napi::ObjectWrap<DBWrapper>(info) {
|
|
69
|
+
Napi::Env env = info.Env();
|
|
70
|
+
|
|
71
|
+
if (info.Length() < 1 || !info[0].IsString()) {
|
|
72
|
+
throw Napi::TypeError::New(env, "Path (string) expected as first argument");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
std::string path = info[0].As<Napi::String>().Utf8Value();
|
|
76
|
+
int dim = 128; // default
|
|
77
|
+
size_t max_elements = 1000000;
|
|
78
|
+
int ef_construction = 200;
|
|
79
|
+
int M = 16;
|
|
80
|
+
int ef_search = 50;
|
|
81
|
+
int distance = 0; // LOGOSDB_DIST_IP
|
|
82
|
+
|
|
83
|
+
// Parse options object if provided
|
|
84
|
+
if (info.Length() > 1 && info[1].IsObject()) {
|
|
85
|
+
Napi::Object options = info[1].As<Napi::Object>();
|
|
86
|
+
|
|
87
|
+
if (options.Has("dim")) {
|
|
88
|
+
dim = options.Get("dim").As<Napi::Number>().Int32Value();
|
|
89
|
+
}
|
|
90
|
+
if (options.Has("maxElements")) {
|
|
91
|
+
max_elements = static_cast<size_t>(
|
|
92
|
+
options.Get("maxElements").As<Napi::Number>().Int64Value()
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (options.Has("efConstruction")) {
|
|
96
|
+
ef_construction = options.Get("efConstruction").As<Napi::Number>().Int32Value();
|
|
97
|
+
}
|
|
98
|
+
if (options.Has("M")) {
|
|
99
|
+
M = options.Get("M").As<Napi::Number>().Int32Value();
|
|
100
|
+
}
|
|
101
|
+
if (options.Has("efSearch")) {
|
|
102
|
+
ef_search = options.Get("efSearch").As<Napi::Number>().Int32Value();
|
|
103
|
+
}
|
|
104
|
+
if (options.Has("distance")) {
|
|
105
|
+
distance = options.Get("distance").As<Napi::Number>().Int32Value();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Create options
|
|
110
|
+
logosdb_options_t* opts = logosdb_options_create();
|
|
111
|
+
logosdb_options_set_dim(opts, dim);
|
|
112
|
+
logosdb_options_set_max_elements(opts, max_elements);
|
|
113
|
+
logosdb_options_set_ef_construction(opts, ef_construction);
|
|
114
|
+
logosdb_options_set_M(opts, M);
|
|
115
|
+
logosdb_options_set_ef_search(opts, ef_search);
|
|
116
|
+
logosdb_options_set_distance(opts, distance);
|
|
117
|
+
|
|
118
|
+
char* err = nullptr;
|
|
119
|
+
db_ = logosdb_open(path.c_str(), opts, &err);
|
|
120
|
+
logosdb_options_destroy(opts);
|
|
121
|
+
|
|
122
|
+
if (!db_) {
|
|
123
|
+
std::string error_msg = err ? err : "Unknown error opening database";
|
|
124
|
+
if (err) free(err);
|
|
125
|
+
throw Napi::Error::New(env, error_msg);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
dim_ = dim;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
~DBWrapper() {
|
|
132
|
+
if (db_) {
|
|
133
|
+
logosdb_close(db_);
|
|
134
|
+
db_ = nullptr;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private:
|
|
139
|
+
logosdb_t* db_ = nullptr;
|
|
140
|
+
int dim_ = 0;
|
|
141
|
+
|
|
142
|
+
Napi::Value Put(const Napi::CallbackInfo& info) {
|
|
143
|
+
Napi::Env env = info.Env();
|
|
144
|
+
|
|
145
|
+
if (info.Length() < 1 || !info[0].IsArray()) {
|
|
146
|
+
throw Napi::TypeError::New(env, "Embedding (array) expected as first argument");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
Napi::Array embeddingArr = info[0].As<Napi::Array>();
|
|
150
|
+
std::vector<float> embedding = JsArrayToFloatVector(embeddingArr);
|
|
151
|
+
|
|
152
|
+
if ((int)embedding.size() != dim_) {
|
|
153
|
+
throw Napi::Error::New(env, "Embedding dimension mismatch");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const char* text = nullptr;
|
|
157
|
+
const char* timestamp = nullptr;
|
|
158
|
+
|
|
159
|
+
if (info.Length() > 1 && info[1].IsString()) {
|
|
160
|
+
text_str_ = info[1].As<Napi::String>().Utf8Value();
|
|
161
|
+
text = text_str_.c_str();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (info.Length() > 2 && info[2].IsString()) {
|
|
165
|
+
ts_str_ = info[2].As<Napi::String>().Utf8Value();
|
|
166
|
+
timestamp = ts_str_.c_str();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
char* err = nullptr;
|
|
170
|
+
uint64_t id = logosdb_put(db_, embedding.data(), dim_, text, timestamp, &err);
|
|
171
|
+
|
|
172
|
+
if (id == UINT64_MAX) {
|
|
173
|
+
std::string error_msg = err ? err : "Failed to insert";
|
|
174
|
+
if (err) free(err);
|
|
175
|
+
throw Napi::Error::New(env, error_msg);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (err) free(err);
|
|
179
|
+
|
|
180
|
+
return Napi::Number::New(env, static_cast<double>(id));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Storage for string references during put calls
|
|
184
|
+
std::string text_str_;
|
|
185
|
+
std::string ts_str_;
|
|
186
|
+
|
|
187
|
+
Napi::Value Search(const Napi::CallbackInfo& info) {
|
|
188
|
+
Napi::Env env = info.Env();
|
|
189
|
+
|
|
190
|
+
if (info.Length() < 1 || !info[0].IsArray()) {
|
|
191
|
+
throw Napi::TypeError::New(env, "Query embedding (array) expected as first argument");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
Napi::Array queryArr = info[0].As<Napi::Array>();
|
|
195
|
+
std::vector<float> query = JsArrayToFloatVector(queryArr);
|
|
196
|
+
|
|
197
|
+
if ((int)query.size() != dim_) {
|
|
198
|
+
throw Napi::Error::New(env, "Query dimension mismatch");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
int top_k = 10;
|
|
202
|
+
if (info.Length() > 1 && info[1].IsNumber()) {
|
|
203
|
+
top_k = info[1].As<Napi::Number>().Int32Value();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
char* err = nullptr;
|
|
207
|
+
logosdb_search_result_t* result = logosdb_search(db_, query.data(), dim_, top_k, &err);
|
|
208
|
+
|
|
209
|
+
if (!result) {
|
|
210
|
+
std::string error_msg = err ? err : "Search failed";
|
|
211
|
+
if (err) free(err);
|
|
212
|
+
throw Napi::Error::New(env, error_msg);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (err) free(err);
|
|
216
|
+
|
|
217
|
+
int count = logosdb_result_count(result);
|
|
218
|
+
Napi::Array results = Napi::Array::New(env, count);
|
|
219
|
+
|
|
220
|
+
for (int i = 0; i < count; i++) {
|
|
221
|
+
results.Set(i, SearchHitToJsObject(env, result, i));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
logosdb_result_free(result);
|
|
225
|
+
|
|
226
|
+
return results;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
Napi::Value SearchTsRange(const Napi::CallbackInfo& info) {
|
|
230
|
+
Napi::Env env = info.Env();
|
|
231
|
+
|
|
232
|
+
if (info.Length() < 1 || !info[0].IsArray()) {
|
|
233
|
+
throw Napi::TypeError::New(env, "Query embedding (array) expected");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
Napi::Array queryArr = info[0].As<Napi::Array>();
|
|
237
|
+
std::vector<float> query = JsArrayToFloatVector(queryArr);
|
|
238
|
+
|
|
239
|
+
if ((int)query.size() != dim_) {
|
|
240
|
+
throw Napi::Error::New(env, "Query dimension mismatch");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
int top_k = 10;
|
|
244
|
+
std::string ts_from;
|
|
245
|
+
std::string ts_to;
|
|
246
|
+
int candidate_k = 0;
|
|
247
|
+
|
|
248
|
+
if (info.Length() > 1 && info[1].IsObject()) {
|
|
249
|
+
Napi::Object options = info[1].As<Napi::Object>();
|
|
250
|
+
|
|
251
|
+
if (options.Has("topK")) {
|
|
252
|
+
top_k = options.Get("topK").As<Napi::Number>().Int32Value();
|
|
253
|
+
}
|
|
254
|
+
if (options.Has("tsFrom")) {
|
|
255
|
+
ts_from = options.Get("tsFrom").As<Napi::String>().Utf8Value();
|
|
256
|
+
}
|
|
257
|
+
if (options.Has("tsTo")) {
|
|
258
|
+
ts_to = options.Get("tsTo").As<Napi::String>().Utf8Value();
|
|
259
|
+
}
|
|
260
|
+
if (options.Has("candidateK")) {
|
|
261
|
+
candidate_k = options.Get("candidateK").As<Napi::Number>().Int32Value();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (candidate_k < top_k) {
|
|
266
|
+
candidate_k = top_k * 10; // Default 10x
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
char* err = nullptr;
|
|
270
|
+
logosdb_search_result_t* result = logosdb_search_ts_range(
|
|
271
|
+
db_, query.data(), dim_, top_k,
|
|
272
|
+
ts_from.empty() ? nullptr : ts_from.c_str(),
|
|
273
|
+
ts_to.empty() ? nullptr : ts_to.c_str(),
|
|
274
|
+
candidate_k,
|
|
275
|
+
&err
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
if (!result) {
|
|
279
|
+
std::string error_msg = err ? err : "Search failed";
|
|
280
|
+
if (err) free(err);
|
|
281
|
+
throw Napi::Error::New(env, error_msg);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (err) free(err);
|
|
285
|
+
|
|
286
|
+
int count = logosdb_result_count(result);
|
|
287
|
+
Napi::Array results = Napi::Array::New(env, count);
|
|
288
|
+
|
|
289
|
+
for (int i = 0; i < count; i++) {
|
|
290
|
+
results.Set(i, SearchHitToJsObject(env, result, i));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
logosdb_result_free(result);
|
|
294
|
+
|
|
295
|
+
return results;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
Napi::Value Update(const Napi::CallbackInfo& info) {
|
|
299
|
+
Napi::Env env = info.Env();
|
|
300
|
+
|
|
301
|
+
if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsArray()) {
|
|
302
|
+
throw Napi::TypeError::New(env, "Expected (id, embedding, [text], [timestamp])");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
uint64_t id = static_cast<uint64_t>(info[0].As<Napi::Number>().Int64Value());
|
|
306
|
+
|
|
307
|
+
Napi::Array embeddingArr = info[1].As<Napi::Array>();
|
|
308
|
+
std::vector<float> embedding = JsArrayToFloatVector(embeddingArr);
|
|
309
|
+
|
|
310
|
+
if ((int)embedding.size() != dim_) {
|
|
311
|
+
throw Napi::Error::New(env, "Embedding dimension mismatch");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const char* text = nullptr;
|
|
315
|
+
const char* timestamp = nullptr;
|
|
316
|
+
|
|
317
|
+
if (info.Length() > 2 && info[2].IsString()) {
|
|
318
|
+
text_str_ = info[2].As<Napi::String>().Utf8Value();
|
|
319
|
+
text = text_str_.c_str();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (info.Length() > 3 && info[3].IsString()) {
|
|
323
|
+
ts_str_ = info[3].As<Napi::String>().Utf8Value();
|
|
324
|
+
timestamp = ts_str_.c_str();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
char* err = nullptr;
|
|
328
|
+
uint64_t new_id = logosdb_update(db_, id, embedding.data(), dim_, text, timestamp, &err);
|
|
329
|
+
|
|
330
|
+
if (new_id == UINT64_MAX) {
|
|
331
|
+
std::string error_msg = err ? err : "Update failed";
|
|
332
|
+
if (err) free(err);
|
|
333
|
+
throw Napi::Error::New(env, error_msg);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (err) free(err);
|
|
337
|
+
|
|
338
|
+
return Napi::Number::New(env, static_cast<double>(new_id));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
Napi::Value Delete(const Napi::CallbackInfo& info) {
|
|
342
|
+
Napi::Env env = info.Env();
|
|
343
|
+
|
|
344
|
+
if (info.Length() < 1 || !info[0].IsNumber()) {
|
|
345
|
+
throw Napi::TypeError::New(env, "ID (number) expected");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
uint64_t id = static_cast<uint64_t>(info[0].As<Napi::Number>().Int64Value());
|
|
349
|
+
|
|
350
|
+
char* err = nullptr;
|
|
351
|
+
int rc = logosdb_delete(db_, id, &err);
|
|
352
|
+
|
|
353
|
+
if (rc != 0) {
|
|
354
|
+
std::string error_msg = err ? err : "Delete failed";
|
|
355
|
+
if (err) free(err);
|
|
356
|
+
throw Napi::Error::New(env, error_msg);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (err) free(err);
|
|
360
|
+
return env.Undefined();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
Napi::Value Count(const Napi::CallbackInfo& info) {
|
|
364
|
+
Napi::Env env = info.Env();
|
|
365
|
+
size_t count = logosdb_count(db_);
|
|
366
|
+
return Napi::Number::New(env, static_cast<double>(count));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
Napi::Value CountLive(const Napi::CallbackInfo& info) {
|
|
370
|
+
Napi::Env env = info.Env();
|
|
371
|
+
size_t count = logosdb_count_live(db_);
|
|
372
|
+
return Napi::Number::New(env, static_cast<double>(count));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
Napi::Value Dim(const Napi::CallbackInfo& info) {
|
|
376
|
+
Napi::Env env = info.Env();
|
|
377
|
+
return Napi::Number::New(env, dim_);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
Napi::Value Close(const Napi::CallbackInfo& info) {
|
|
381
|
+
Napi::Env env = info.Env();
|
|
382
|
+
if (db_) {
|
|
383
|
+
logosdb_close(db_);
|
|
384
|
+
db_ = nullptr;
|
|
385
|
+
}
|
|
386
|
+
return env.Undefined();
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Constants
|
|
391
|
+
static void DefineConstants(Napi::Env env, Napi::Object exports) {
|
|
392
|
+
exports.Set("DIST_IP", Napi::Number::New(env, LOGOSDB_DIST_IP));
|
|
393
|
+
exports.Set("DIST_COSINE", Napi::Number::New(env, LOGOSDB_DIST_COSINE));
|
|
394
|
+
exports.Set("DIST_L2", Napi::Number::New(env, LOGOSDB_DIST_L2));
|
|
395
|
+
exports.Set("VERSION", Napi::String::New(env, LOGOSDB_VERSION_STRING));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Module initialization
|
|
399
|
+
static Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
|
400
|
+
DBWrapper::Init(env, exports);
|
|
401
|
+
DefineConstants(env, exports);
|
|
402
|
+
return exports;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
NODE_API_MODULE(logosdb, Init)
|
package/test/test.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogosDB Node.js bindings tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const assert = require('assert');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const { DB, DIST_IP, DIST_COSINE, DIST_L2, VERSION } = require('../lib');
|
|
12
|
+
|
|
13
|
+
// Helper to generate random unit vector
|
|
14
|
+
function randomUnitVector(dim, seed = 0) {
|
|
15
|
+
const vec = new Array(dim);
|
|
16
|
+
let sum = 0;
|
|
17
|
+
|
|
18
|
+
// Simple pseudo-random for deterministic tests
|
|
19
|
+
let state = seed;
|
|
20
|
+
for (let i = 0; i < dim; i++) {
|
|
21
|
+
state = (state * 9301 + 49297) % 233280;
|
|
22
|
+
const rnd = state / 233280;
|
|
23
|
+
vec[i] = rnd * 2 - 1; // -1 to 1
|
|
24
|
+
sum += vec[i] * vec[i];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Normalize
|
|
28
|
+
const norm = Math.sqrt(sum);
|
|
29
|
+
return vec.map(v => v / norm);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Helper to create temp directory
|
|
33
|
+
function createTempDir(prefix = 'logosdb-test-') {
|
|
34
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Helper to cleanup
|
|
38
|
+
function cleanup(dir) {
|
|
39
|
+
try {
|
|
40
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
} catch (e) {
|
|
42
|
+
// Ignore cleanup errors
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('LogosDB Node.js', function() {
|
|
47
|
+
this.timeout(10000); // 10 second timeout for tests
|
|
48
|
+
|
|
49
|
+
describe('Constants', () => {
|
|
50
|
+
it('should export distance constants', () => {
|
|
51
|
+
assert.strictEqual(typeof DIST_IP, 'number');
|
|
52
|
+
assert.strictEqual(typeof DIST_COSINE, 'number');
|
|
53
|
+
assert.strictEqual(typeof DIST_L2, 'number');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should export version', () => {
|
|
57
|
+
assert.strictEqual(typeof VERSION, 'string');
|
|
58
|
+
assert.ok(VERSION.match(/^\d+\.\d+\.\d+/));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('DB Creation', () => {
|
|
63
|
+
let dbPath;
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
if (dbPath) cleanup(dbPath);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should create a new database', () => {
|
|
70
|
+
dbPath = createTempDir();
|
|
71
|
+
const db = new DB(dbPath, { dim: 128 });
|
|
72
|
+
assert.ok(db);
|
|
73
|
+
assert.strictEqual(db.dim(), 128);
|
|
74
|
+
db.close();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should use default options', () => {
|
|
78
|
+
dbPath = createTempDir();
|
|
79
|
+
const db = new DB(dbPath);
|
|
80
|
+
assert.strictEqual(db.dim(), 128); // default
|
|
81
|
+
db.close();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should support custom options', () => {
|
|
85
|
+
dbPath = createTempDir();
|
|
86
|
+
const db = new DB(dbPath, {
|
|
87
|
+
dim: 256,
|
|
88
|
+
maxElements: 10000,
|
|
89
|
+
efConstruction: 100,
|
|
90
|
+
M: 8,
|
|
91
|
+
efSearch: 30,
|
|
92
|
+
distance: DIST_COSINE
|
|
93
|
+
});
|
|
94
|
+
assert.strictEqual(db.dim(), 256);
|
|
95
|
+
db.close();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('Basic Operations', () => {
|
|
100
|
+
let db, dbPath;
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
dbPath = createTempDir();
|
|
104
|
+
db = new DB(dbPath, { dim: 64 });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
if (db) db.close();
|
|
109
|
+
cleanup(dbPath);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should put and return an id', () => {
|
|
113
|
+
const vec = randomUnitVector(64, 1);
|
|
114
|
+
const id = db.put(vec, 'test text', '2025-01-01T00:00:00Z');
|
|
115
|
+
assert.strictEqual(typeof id, 'number');
|
|
116
|
+
assert.ok(id >= 0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should put without text and timestamp', () => {
|
|
120
|
+
const vec = randomUnitVector(64, 2);
|
|
121
|
+
const id = db.put(vec);
|
|
122
|
+
assert.strictEqual(typeof id, 'number');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should count documents', () => {
|
|
126
|
+
assert.strictEqual(db.count(), 0);
|
|
127
|
+
assert.strictEqual(db.countLive(), 0);
|
|
128
|
+
|
|
129
|
+
db.put(randomUnitVector(64, 3));
|
|
130
|
+
assert.strictEqual(db.count(), 1);
|
|
131
|
+
assert.strictEqual(db.countLive(), 1);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('Search', () => {
|
|
136
|
+
let db, dbPath;
|
|
137
|
+
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
dbPath = createTempDir();
|
|
140
|
+
db = new DB(dbPath, { dim: 64, distance: DIST_COSINE });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
afterEach(() => {
|
|
144
|
+
if (db) db.close();
|
|
145
|
+
cleanup(dbPath);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should search and return hits', () => {
|
|
149
|
+
const vec1 = randomUnitVector(64, 10);
|
|
150
|
+
const vec2 = randomUnitVector(64, 11);
|
|
151
|
+
const vec3 = randomUnitVector(64, 12);
|
|
152
|
+
|
|
153
|
+
db.put(vec1, 'first');
|
|
154
|
+
db.put(vec2, 'second');
|
|
155
|
+
db.put(vec3, 'third');
|
|
156
|
+
|
|
157
|
+
const hits = db.search(vec1, 3);
|
|
158
|
+
assert.ok(Array.isArray(hits));
|
|
159
|
+
assert.ok(hits.length <= 3);
|
|
160
|
+
|
|
161
|
+
if (hits.length > 0) {
|
|
162
|
+
assert.ok(hits[0].id >= 0);
|
|
163
|
+
assert.ok(hits[0].score > 0);
|
|
164
|
+
assert.ok(typeof hits[0].text === 'string' || hits[0].text === null);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should find similar vectors', () => {
|
|
169
|
+
// Use same seed to get identical vectors
|
|
170
|
+
const vec = randomUnitVector(64, 20);
|
|
171
|
+
|
|
172
|
+
db.put(vec, 'original');
|
|
173
|
+
|
|
174
|
+
// Search with same vector
|
|
175
|
+
const hits = db.search(vec, 1);
|
|
176
|
+
assert.strictEqual(hits.length, 1);
|
|
177
|
+
assert.strictEqual(hits[0].text, 'original');
|
|
178
|
+
assert.ok(hits[0].score > 0.99, 'Self-similarity should be ~1.0');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('Timestamp Range Search', () => {
|
|
183
|
+
let db, dbPath;
|
|
184
|
+
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
dbPath = createTempDir();
|
|
187
|
+
db = new DB(dbPath, { dim: 32, distance: DIST_COSINE });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
afterEach(() => {
|
|
191
|
+
if (db) db.close();
|
|
192
|
+
cleanup(dbPath);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should search with timestamp filter', () => {
|
|
196
|
+
const vec = randomUnitVector(32, 30);
|
|
197
|
+
|
|
198
|
+
db.put(vec, 'early', '2025-01-01T00:00:00Z');
|
|
199
|
+
db.put(vec, 'mid', '2025-01-15T00:00:00Z');
|
|
200
|
+
db.put(vec, 'late', '2025-02-01T00:00:00Z');
|
|
201
|
+
|
|
202
|
+
const hits = db.searchTsRange(vec, {
|
|
203
|
+
topK: 10,
|
|
204
|
+
tsFrom: '2025-01-01T00:00:00Z',
|
|
205
|
+
tsTo: '2025-01-31T23:59:59Z'
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
assert.ok(hits.length >= 2);
|
|
209
|
+
// Should find early and mid, but not late
|
|
210
|
+
const texts = hits.map(h => h.text);
|
|
211
|
+
assert.ok(texts.includes('early'));
|
|
212
|
+
assert.ok(texts.includes('mid'));
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('Update and Delete', () => {
|
|
217
|
+
let db, dbPath;
|
|
218
|
+
|
|
219
|
+
beforeEach(() => {
|
|
220
|
+
dbPath = createTempDir();
|
|
221
|
+
db = new DB(dbPath, { dim: 32, distance: DIST_COSINE });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
afterEach(() => {
|
|
225
|
+
if (db) db.close();
|
|
226
|
+
cleanup(dbPath);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should update a row', () => {
|
|
230
|
+
const vec = randomUnitVector(32, 40);
|
|
231
|
+
const id = db.put(vec, 'original');
|
|
232
|
+
|
|
233
|
+
const newVec = randomUnitVector(32, 41);
|
|
234
|
+
const newId = db.update(id, newVec, 'updated');
|
|
235
|
+
|
|
236
|
+
assert.strictEqual(typeof newId, 'number');
|
|
237
|
+
assert.ok(newId > id);
|
|
238
|
+
assert.strictEqual(db.countLive(), 1); // Old marked deleted, new added
|
|
239
|
+
assert.strictEqual(db.count(), 2); // Total includes both
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should delete a row', () => {
|
|
243
|
+
const vec = randomUnitVector(32, 50);
|
|
244
|
+
const id = db.put(vec, 'to delete');
|
|
245
|
+
|
|
246
|
+
assert.strictEqual(db.countLive(), 1);
|
|
247
|
+
|
|
248
|
+
db.delete(id);
|
|
249
|
+
|
|
250
|
+
assert.strictEqual(db.countLive(), 0);
|
|
251
|
+
assert.strictEqual(db.count(), 1); // Still counted but marked deleted
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('Error Handling', () => {
|
|
256
|
+
let dbPath;
|
|
257
|
+
|
|
258
|
+
afterEach(() => {
|
|
259
|
+
cleanup(dbPath);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should throw on dimension mismatch', () => {
|
|
263
|
+
dbPath = createTempDir();
|
|
264
|
+
const db = new DB(dbPath, { dim: 64 });
|
|
265
|
+
|
|
266
|
+
assert.throws(() => {
|
|
267
|
+
db.put(randomUnitVector(32)); // Wrong dimension
|
|
268
|
+
}, /dimension/);
|
|
269
|
+
|
|
270
|
+
db.close();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should throw on closed database access', () => {
|
|
274
|
+
dbPath = createTempDir();
|
|
275
|
+
const db = new DB(dbPath, { dim: 64 });
|
|
276
|
+
db.close();
|
|
277
|
+
|
|
278
|
+
assert.throws(() => {
|
|
279
|
+
db.put(randomUnitVector(64));
|
|
280
|
+
}, /closed/);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('Persistence', () => {
|
|
285
|
+
let dbPath;
|
|
286
|
+
|
|
287
|
+
afterEach(() => {
|
|
288
|
+
cleanup(dbPath);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should persist data across reopen', () => {
|
|
292
|
+
dbPath = createTempDir();
|
|
293
|
+
|
|
294
|
+
// First session: insert
|
|
295
|
+
let db1 = new DB(dbPath, { dim: 32, distance: DIST_COSINE });
|
|
296
|
+
const vec = randomUnitVector(32, 60);
|
|
297
|
+
const id = db1.put(vec, 'persistent');
|
|
298
|
+
db1.close();
|
|
299
|
+
|
|
300
|
+
// Second session: verify
|
|
301
|
+
let db2 = new DB(dbPath, { dim: 32, distance: DIST_COSINE });
|
|
302
|
+
assert.strictEqual(db2.countLive(), 1);
|
|
303
|
+
|
|
304
|
+
const hits = db2.search(vec, 1);
|
|
305
|
+
assert.strictEqual(hits.length, 1);
|
|
306
|
+
assert.strictEqual(hits[0].text, 'persistent');
|
|
307
|
+
db2.close();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Run tests if executed directly
|
|
313
|
+
if (require.main === module) {
|
|
314
|
+
const Mocha = require('mocha');
|
|
315
|
+
const mocha = new Mocha();
|
|
316
|
+
mocha.suite.emit('pre-require', global, 'test.js', mocha);
|
|
317
|
+
|
|
318
|
+
// Simple test runner for standalone execution
|
|
319
|
+
console.log('Running LogosDB Node.js tests...\n');
|
|
320
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogosDB - Fast semantic vector database (HNSW + mmap)
|
|
3
|
+
* TypeScript definitions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Search result hit
|
|
8
|
+
*/
|
|
9
|
+
export interface SearchHit {
|
|
10
|
+
/** Row ID */
|
|
11
|
+
id: number;
|
|
12
|
+
/** Similarity score (0-1) */
|
|
13
|
+
score: number;
|
|
14
|
+
/** Associated text */
|
|
15
|
+
text: string | null;
|
|
16
|
+
/** ISO 8601 timestamp */
|
|
17
|
+
timestamp: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Database options
|
|
22
|
+
*/
|
|
23
|
+
export interface DBOptions {
|
|
24
|
+
/** Vector dimension (default: 128) */
|
|
25
|
+
dim?: number;
|
|
26
|
+
/** Maximum capacity (default: 1,000,000) */
|
|
27
|
+
maxElements?: number;
|
|
28
|
+
/** HNSW construction parameter (default: 200) */
|
|
29
|
+
efConstruction?: number;
|
|
30
|
+
/** HNSW M parameter (default: 16) */
|
|
31
|
+
M?: number;
|
|
32
|
+
/** HNSW search parameter (default: 50) */
|
|
33
|
+
efSearch?: number;
|
|
34
|
+
/** Distance metric (default: DIST_IP) */
|
|
35
|
+
distance?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Timestamp range search options
|
|
40
|
+
*/
|
|
41
|
+
export interface TsRangeOptions {
|
|
42
|
+
/** Number of results (default: 10) */
|
|
43
|
+
topK?: number;
|
|
44
|
+
/** Start timestamp (inclusive) */
|
|
45
|
+
tsFrom?: string;
|
|
46
|
+
/** End timestamp (inclusive) */
|
|
47
|
+
tsTo?: string;
|
|
48
|
+
/** Internal candidate multiplier */
|
|
49
|
+
candidateK?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* LogosDB database instance
|
|
54
|
+
*/
|
|
55
|
+
export class DB {
|
|
56
|
+
/**
|
|
57
|
+
* Create a new LogosDB instance
|
|
58
|
+
* @param path - Database directory path
|
|
59
|
+
* @param options - Database options
|
|
60
|
+
*/
|
|
61
|
+
constructor(path: string, options?: DBOptions);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Insert a vector with optional text and timestamp
|
|
65
|
+
* @param embedding - Float vector
|
|
66
|
+
* @param text - Optional text metadata
|
|
67
|
+
* @param timestamp - Optional ISO 8601 timestamp
|
|
68
|
+
* @returns Assigned row ID
|
|
69
|
+
*/
|
|
70
|
+
put(embedding: number[], text?: string, timestamp?: string): number;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Search for similar vectors
|
|
74
|
+
* @param queryEmbedding - Query vector
|
|
75
|
+
* @param topK - Number of results (default: 10)
|
|
76
|
+
* @returns Search results
|
|
77
|
+
*/
|
|
78
|
+
search(queryEmbedding: number[], topK?: number): SearchHit[];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Search with timestamp range filter
|
|
82
|
+
* @param queryEmbedding - Query vector
|
|
83
|
+
* @param options - Search options
|
|
84
|
+
* @returns Search results
|
|
85
|
+
*/
|
|
86
|
+
searchTsRange(queryEmbedding: number[], options?: TsRangeOptions): SearchHit[];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Update an existing row (marks old as deleted, creates new)
|
|
90
|
+
* @param id - Row ID to update
|
|
91
|
+
* @param embedding - New embedding
|
|
92
|
+
* @param text - New text
|
|
93
|
+
* @param timestamp - New timestamp
|
|
94
|
+
* @returns New row ID
|
|
95
|
+
*/
|
|
96
|
+
update(id: number, embedding: number[], text?: string, timestamp?: string): number;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Delete a row by ID
|
|
100
|
+
* @param id - Row ID to delete
|
|
101
|
+
*/
|
|
102
|
+
delete(id: number): void;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get total row count (including deleted)
|
|
106
|
+
* @returns Total count
|
|
107
|
+
*/
|
|
108
|
+
count(): number;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get live row count (excluding deleted)
|
|
112
|
+
* @returns Live count
|
|
113
|
+
*/
|
|
114
|
+
countLive(): number;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get vector dimension
|
|
118
|
+
* @returns Dimension
|
|
119
|
+
*/
|
|
120
|
+
dim(): number;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Close the database
|
|
124
|
+
*/
|
|
125
|
+
close(): void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Inner product distance (default, requires L2-normalized vectors) */
|
|
129
|
+
export const DIST_IP: number;
|
|
130
|
+
|
|
131
|
+
/** Cosine similarity (auto-normalizes vectors) */
|
|
132
|
+
export const DIST_COSINE: number;
|
|
133
|
+
|
|
134
|
+
/** Euclidean distance (L2 space) */
|
|
135
|
+
export const DIST_L2: number;
|
|
136
|
+
|
|
137
|
+
/** Library version string */
|
|
138
|
+
export const VERSION: string;
|