rag-memory-epf-mcp 3.3.1 → 3.3.3

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/dist/index.js CHANGED
@@ -39,6 +39,40 @@ const DB_FILE_PATH = process.env.DB_FILE_PATH
39
39
  : defaultDbPath;
40
40
  const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || 'Xenova/bge-m3';
41
41
  // Safe rowid for vec0 virtual tables (require literal integer, not parameterized)
42
+ // Trim incomplete UTF-8 multi-byte sequences at chunk boundaries.
43
+ // Continuation bytes match 10xxxxxx (0x80-0xBF); lead bytes indicate how many
44
+ // bytes the sequence needs (0xxxxxxx=1, 110xxxxx=2, 1110xxxx=3, 11110xxx=4).
45
+ // When a chunk is not at the document head/tail, any partial sequence at that
46
+ // edge belongs to an adjacent chunk and must be removed so TextDecoder does
47
+ // not emit U+FFFD. Pass trimHead/trimTail=false to preserve head/tail bytes.
48
+ function trimIncompleteUtf8(bytes, trimHead, trimTail) {
49
+ let start = 0;
50
+ let end = bytes.length;
51
+ if (trimHead) {
52
+ while (start < end && (bytes[start] & 0xC0) === 0x80)
53
+ start++;
54
+ }
55
+ if (trimTail) {
56
+ let i = end - 1;
57
+ while (i >= start && (bytes[i] & 0xC0) === 0x80)
58
+ i--;
59
+ if (i >= start) {
60
+ const lead = bytes[i];
61
+ let needed = 1;
62
+ if ((lead & 0x80) === 0)
63
+ needed = 1;
64
+ else if ((lead & 0xE0) === 0xC0)
65
+ needed = 2;
66
+ else if ((lead & 0xF0) === 0xE0)
67
+ needed = 3;
68
+ else if ((lead & 0xF8) === 0xF0)
69
+ needed = 4;
70
+ if (end - i < needed)
71
+ end = i;
72
+ }
73
+ }
74
+ return bytes.subarray(start, end);
75
+ }
42
76
  function safeRowid(value) {
43
77
  const n = Number(value);
44
78
  if (!Number.isInteger(n) || n < 0) {
@@ -1098,22 +1132,63 @@ class RAGKnowledgeGraphManager {
1098
1132
  return Array.from(terms);
1099
1133
  }
1100
1134
  // Tokenize and chunk text
1135
+ // BPE tokenizers (cl100k_base) split multi-byte UTF-8 sequences across tokens.
1136
+ // Slicing token arrays at arbitrary boundaries can leave incomplete UTF-8
1137
+ // prefix/suffix bytes, which TextDecoder replaces with U+FFFD (�). Trim the
1138
+ // incomplete sequences at chunk boundaries; overlap covers the removed bytes.
1139
+ //
1140
+ // Each chunk records both token-space offsets (start_token/end_token from the
1141
+ // BPE encoder loop) and char-space offsets (start_pos/end_pos into the original
1142
+ // text). Char offsets are recovered with indexOf using a running cursor at the
1143
+ // previous chunk's start, so substr(text, start_pos, end_pos - start_pos)
1144
+ // equals chunk.text. On a coincidental indexOf miss the char offsets are NULL.
1101
1145
  chunkText(text, maxTokens = 800, overlap = 160) {
1102
1146
  if (!this.encoding)
1103
1147
  throw new Error('Tokenizer not initialized');
1104
1148
  const tokens = this.encoding.encode(text);
1105
1149
  const chunks = [];
1150
+ let charCursor = 0;
1106
1151
  for (let i = 0; i < tokens.length; i += maxTokens - overlap) {
1107
1152
  const chunkTokens = tokens.slice(i, i + maxTokens);
1108
1153
  const decodedBytes = this.encoding.decode(chunkTokens);
1109
- const chunkText = new TextDecoder().decode(decodedBytes);
1154
+ const isFirst = i === 0;
1155
+ const isLast = i + chunkTokens.length >= tokens.length;
1156
+ const safeBytes = trimIncompleteUtf8(decodedBytes, !isFirst, !isLast);
1157
+ const chunkText = new TextDecoder('utf-8').decode(safeBytes);
1158
+ // Recover char-space offsets. First chunk is anchored at 0 because
1159
+ // trimIncompleteUtf8 leaves the leading bytes intact when isFirst is true.
1160
+ let startPos;
1161
+ let endPos;
1162
+ if (isFirst) {
1163
+ startPos = 0;
1164
+ endPos = chunkText.length;
1165
+ charCursor = 0;
1166
+ }
1167
+ else if (chunkText.length === 0) {
1168
+ startPos = null;
1169
+ endPos = null;
1170
+ }
1171
+ else {
1172
+ const idx = text.indexOf(chunkText, charCursor);
1173
+ if (idx >= 0) {
1174
+ startPos = idx;
1175
+ endPos = idx + chunkText.length;
1176
+ charCursor = idx;
1177
+ }
1178
+ else {
1179
+ startPos = null;
1180
+ endPos = null;
1181
+ }
1182
+ }
1110
1183
  chunks.push({
1111
1184
  id: '',
1112
1185
  document_id: '',
1113
1186
  chunk_index: chunks.length,
1114
1187
  text: chunkText,
1115
- start_pos: i,
1116
- end_pos: i + chunkTokens.length
1188
+ start_pos: startPos,
1189
+ end_pos: endPos,
1190
+ start_token: i,
1191
+ end_token: i + chunkTokens.length
1117
1192
  });
1118
1193
  }
1119
1194
  return chunks;
@@ -1189,14 +1264,16 @@ class RAGKnowledgeGraphManager {
1189
1264
  // Store chunk metadata (no embedding yet)
1190
1265
  this.db.prepare(`
1191
1266
  INSERT INTO chunk_metadata (
1192
- chunk_id, document_id, chunk_index, text, start_pos, end_pos
1193
- ) VALUES (?, ?, ?, ?, ?, ?)
1194
- `).run(chunkId, documentId, chunk.chunk_index, chunk.text, chunk.start_pos, chunk.end_pos);
1267
+ chunk_id, document_id, chunk_index, text, start_pos, end_pos, start_token, end_token
1268
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1269
+ `).run(chunkId, documentId, chunk.chunk_index, chunk.text, chunk.start_pos, chunk.end_pos, chunk.start_token, chunk.end_token);
1195
1270
  resultChunks.push({
1196
1271
  id: chunkId,
1197
1272
  text: chunk.text,
1198
1273
  startPos: chunk.start_pos,
1199
- endPos: chunk.end_pos
1274
+ endPos: chunk.end_pos,
1275
+ startToken: chunk.start_token,
1276
+ endToken: chunk.end_token
1200
1277
  });
1201
1278
  }
1202
1279
  console.error(`✅ Document chunked: ${chunks.length} chunks created`);
@@ -1672,6 +1749,8 @@ class RAGKnowledgeGraphManager {
1672
1749
  m.text,
1673
1750
  m.start_pos,
1674
1751
  m.end_pos,
1752
+ m.start_token,
1753
+ m.end_token,
1675
1754
  COALESCE(m.metadata, '{}') as chunk_metadata,
1676
1755
  c.distance,
1677
1756
  COALESCE(d.metadata, '{}') as doc_metadata
@@ -1752,6 +1831,8 @@ class RAGKnowledgeGraphManager {
1752
1831
  cm.text,
1753
1832
  cm.start_pos,
1754
1833
  cm.end_pos,
1834
+ cm.start_token,
1835
+ cm.end_token,
1755
1836
  COALESCE(cm.metadata, '{}') as chunk_metadata,
1756
1837
  COALESCE(d.metadata, '{}') as doc_metadata
1757
1838
  FROM chunk_metadata cm
@@ -427,5 +427,83 @@ export const migrations = [
427
427
  db.exec(`DROP TABLE IF EXISTS entities_fts`);
428
428
  db.exec(`DROP TABLE IF EXISTS chunks_fts`);
429
429
  }
430
+ },
431
+ // Migration 9: Separate token-space vs char-space chunk offsets.
432
+ // Before this migration, chunk_metadata.start_pos/end_pos held *token* indices
433
+ // for document chunks (a leftover from the BPE tokenizer-based chunkText loop)
434
+ // but already held character lengths (0..text.length) for entity/relationship
435
+ // chunks. Same column, two meanings — and a column name (`*_pos`) that implies
436
+ // char offsets in `documents.content`. This migration adds explicit
437
+ // start_token/end_token columns and reinterprets start_pos/end_pos as character
438
+ // offsets going forward. Existing document chunks: token data is moved to the
439
+ // new columns and char offsets are recomputed from documents.content via
440
+ // indexOf with a running cursor (NULL on miss — caller can re-chunk to fill).
441
+ // Existing entity/relationship chunks: leave start_pos/end_pos as-is
442
+ // (already a valid 0..text.length char range against the chunk text itself);
443
+ // token columns stay NULL since these chunks have no token-space concept.
444
+ {
445
+ version: 9,
446
+ description: 'Add start_token/end_token; reinterpret start_pos/end_pos as char offsets',
447
+ up: (db) => {
448
+ // 1) Add columns
449
+ db.exec(`ALTER TABLE chunk_metadata ADD COLUMN start_token INTEGER`);
450
+ db.exec(`ALTER TABLE chunk_metadata ADD COLUMN end_token INTEGER`);
451
+ // 2) Move token data into new columns for document chunks
452
+ db.exec(`
453
+ UPDATE chunk_metadata
454
+ SET start_token = start_pos,
455
+ end_token = end_pos,
456
+ start_pos = NULL,
457
+ end_pos = NULL
458
+ WHERE chunk_type = 'document'
459
+ `);
460
+ // 3) Recompute char offsets via indexOf with a running cursor per document
461
+ const docRows = db.prepare(`
462
+ SELECT DISTINCT document_id FROM chunk_metadata
463
+ WHERE chunk_type = 'document' AND document_id IS NOT NULL
464
+ `).all();
465
+ const docContentStmt = db.prepare(`SELECT content FROM documents WHERE id = ?`);
466
+ const chunksStmt = db.prepare(`
467
+ SELECT rowid, text FROM chunk_metadata
468
+ WHERE document_id = ? AND chunk_type = 'document'
469
+ ORDER BY chunk_index ASC
470
+ `);
471
+ const updateStmt = db.prepare(`
472
+ UPDATE chunk_metadata SET start_pos = ?, end_pos = ? WHERE rowid = ?
473
+ `);
474
+ for (const { document_id } of docRows) {
475
+ const doc = docContentStmt.get(document_id);
476
+ if (!doc)
477
+ continue;
478
+ const content = doc.content;
479
+ const chunks = chunksStmt.all(document_id);
480
+ // Cursor advances by the previous chunk's *start*, not its end, so we can
481
+ // still locate overlapping chunks. Token-space stride guarantees each
482
+ // chunk's start is strictly forward of the previous chunk's start.
483
+ let cursor = 0;
484
+ for (const c of chunks) {
485
+ if (!c.text)
486
+ continue;
487
+ const idx = content.indexOf(c.text, cursor);
488
+ if (idx >= 0) {
489
+ updateStmt.run(idx, idx + c.text.length, c.rowid);
490
+ cursor = idx;
491
+ }
492
+ // miss: leave NULL — caller can re-chunk to repair
493
+ }
494
+ }
495
+ },
496
+ down: (db) => {
497
+ // SQLite cannot DROP COLUMN cleanly. Best-effort: copy token data back into
498
+ // start_pos/end_pos for document chunks so a downgrade leaves the legacy
499
+ // token-space semantics in place.
500
+ db.exec(`
501
+ UPDATE chunk_metadata
502
+ SET start_pos = start_token, end_pos = end_token
503
+ WHERE chunk_type = 'document' AND start_token IS NOT NULL
504
+ `);
505
+ // start_token/end_token columns remain (no DROP COLUMN); they will be
506
+ // ignored by older code.
507
+ }
430
508
  }
431
509
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rag-memory-epf-mcp",
3
- "version": "3.3.1",
3
+ "version": "3.3.3",
4
4
  "description": "MCP server for project-local RAG memory with knowledge graph and multilingual vector search",
5
5
  "license": "MIT",
6
6
  "author": "bripin123",