rag-memory-epf-mcp 4.0.0 → 5.0.0

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 CHANGED
@@ -15,7 +15,7 @@ A **project-local RAG memory** MCP server — knowledge graph + multilingual vec
15
15
  - **Graph-aware scoring** — per-entity geometric decay (0.5^i) with hard cap prevents any single document from dominating results
16
16
  - **38 MCP tools** — knowledge graph CRUD, observation lifecycle (correct / retract / history), document pipeline, hybrid search, multi-hop traversal, graph analytics (centrality / community detection / structure), export/import, temporal queries
17
17
  - **Observations that hold their history** — corrections supersede instead of overwrite, search returns only current facts, and every revision keeps its provenance
18
- - **Codepoint-safe chunking** — chunk offsets are Unicode codepoints, language-neutral across SQL `substr`, Python slicing, and JS `[...str]` iteration. Korean/CJK/emoji documents stay aligned. Verified by a publish-time invariant test.
18
+ - **Structure-anchored chunking (c1, v5)** — boundaries anchor to markdown structure (fence-aware, H1–H4 first, block-greedy, exact-token fallback), so editing the top of a file no longer re-embeds the whole document: unchanged text reuses its stored vectors at sync time. Chunk offsets are Unicode codepoints, language-neutral across SQL `substr`, Python slicing, and JS `[...str]` iteration; a publish-time invariant gate locks the gap-free partition. `overlap` is retired (omit or 0).
19
19
  - **SQLite optimized** — WAL mode, 32MB cache, 256MB mmap, FTS5 triggers, 7 indexes
20
20
  - **MCP SDK 1.27.1** — Tool Annotations (readOnly/destructive/idempotent), latest protocol 2025-11-25
21
21
 
@@ -105,7 +105,7 @@ Place this `.mcp.json` in each project folder with its own `DB_FILE_PATH`. Each
105
105
 
106
106
  ```
107
107
  storeDocument(id, content, metadata)
108
- → chunkDocument(documentId, maxTokens, overlap)
108
+ → chunkDocument(documentId, maxTokens) # overlap retired in v5 (omit or 0)
109
109
  → embedChunks(documentId)
110
110
  ├── generates vector embeddings for each chunk
111
111
  ├── auto-links entities to chunks (word boundary + CJK aware)
package/dist/index.d.ts CHANGED
@@ -46,6 +46,22 @@ interface DetailedContext {
46
46
  entities: string[];
47
47
  metadata: Record<string, any>;
48
48
  }
49
+ export declare class SyncCasConflictError extends Error {
50
+ constructor(documentId: string);
51
+ }
52
+ export declare function setSyncFaultPoint(point: string | null, fn: (() => void) | null): void;
53
+ export interface ReuseCandidate {
54
+ rowid: number;
55
+ text: string;
56
+ input_hash: string | null;
57
+ profile_id: number | null;
58
+ provenance_state: string | null;
59
+ embedding: Buffer | null;
60
+ }
61
+ export declare function selectReusableVector(candidates: ReuseCandidate[], text: string, currentProfileId: number, sha256hex: (s: string) => string): {
62
+ vec: Buffer;
63
+ provenance: 'verified' | 'legacy_assumed';
64
+ } | null;
49
65
  export declare class RAGKnowledgeGraphManager {
50
66
  private db;
51
67
  private encoding;
@@ -205,7 +221,8 @@ export declare class RAGKnowledgeGraphManager {
205
221
  private translateQueryWithMap;
206
222
  private buildCrossLingualVariants;
207
223
  private extractTermsFromText;
208
- private chunkText;
224
+ private chunkStructured;
225
+ private validateChunkParams;
209
226
  private generateEmbedding;
210
227
  syncDocumentFromFile(filePath: string, documentId: string, options?: {
211
228
  metadata?: Record<string, any>;
@@ -225,6 +242,12 @@ export declare class RAGKnowledgeGraphManager {
225
242
  warning?: string;
226
243
  skipped?: boolean;
227
244
  reason?: string;
245
+ embedding_status?: string;
246
+ reusedChunks: number;
247
+ newlyEmbeddedChunks: number;
248
+ queuedChunks: number;
249
+ deletedChunks: number;
250
+ chunkerTransitioned: boolean;
228
251
  }>;
229
252
  storeDocument(id: string, content: string, metadata?: Record<string, any>): Promise<{
230
253
  id: string;
@@ -252,6 +275,7 @@ export declare class RAGKnowledgeGraphManager {
252
275
  errors?: string[];
253
276
  }>;
254
277
  private hasCJK;
278
+ private buildEntityRangeFinder;
255
279
  private buildEntityMatcher;
256
280
  private autoLinkEntities;
257
281
  extractTerms(documentId: string, options?: {
@@ -378,6 +402,8 @@ export declare class RAGKnowledgeGraphManager {
378
402
  version: number;
379
403
  description: string;
380
404
  }>;
405
+ semanticRollback?: boolean;
406
+ warning?: string;
381
407
  }>;
382
408
  }
383
409
  export {};
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ import { rebuildProjection, deleteStaleKgChunks } from './src/observations/proje
26
26
  import { addRevision, correctRevision, transitionStatus, linkSources, nextProjectionOrder } from './src/observations/lifecycle.js';
27
27
  import { getObservationHistory } from './src/observations/history.js';
28
28
  // Import chunk text algorithm (extracted for publish-time invariant testing)
29
- import { chunkText as splitTextIntoChunks } from './src/chunkText.js';
29
+ import { chunkStructured as chunkStructuredText, effectiveSignature, isCurrentFormatSignature, LEGACY_SIGNATURE, DEFAULT_MAX_TOKENS } from './src/chunkerC.js';
30
30
  import { migrations } from './src/migrations/migrations.js';
31
31
  // v3.6 lite install: model lifecycle + version-independent cache (A′ boundary)
32
32
  import { EmbeddingGate, GateNotReadyError, GateDisabledError, TerminalConfigError } from './src/embeddingGate.js';
@@ -109,6 +109,36 @@ const TEXT_BUILDER_VERSION = 'tb1';
109
109
  // edge belongs to an adjacent chunk and must be removed so TextDecoder does
110
110
  // not emit U+FFFD. Pass trimHead/trimTail=false to preserve head/tail bytes.
111
111
  // (Implementation moved to src/chunkText.ts for testability.)
112
+ export class SyncCasConflictError extends Error {
113
+ constructor(documentId) { super(`sync CAS conflict on ${documentId}`); this.name = 'SyncCasConflictError'; }
114
+ }
115
+ // Test-only fault hook (v13 setMigrationFaultPoint 선례 — 환경변수 금지: 상시 스위치는
116
+ // 오설정 한 줄로 sync 를 깬다).
117
+ let __syncFaultHook = null;
118
+ export function setSyncFaultPoint(point, fn) {
119
+ __syncFaultHook = point && fn ? (p) => { if (p === point)
120
+ fn(); } : null;
121
+ }
122
+ // Pure vector-reuse decision (spec §5.2 조건 1~5). Returns an OWNED Buffer copy:
123
+ // a Buffer read back from SQLite has no byteOffset-0 guarantee, and inserting
124
+ // `.buffer` of a subarray would write the wrong 4,096 bytes (advisor r5-9).
125
+ export function selectReusableVector(candidates, text, currentProfileId, sha256hex) {
126
+ const h = sha256hex(text);
127
+ for (const r of candidates) {
128
+ if (!r.embedding)
129
+ continue; // 조건 1: 벡터 실존
130
+ if (r.input_hash !== h)
131
+ continue; // 조건 2: input_hash 일치
132
+ if (r.text !== text)
133
+ continue; // 조건 3: exact text 최종판정
134
+ if (r.profile_id !== currentProfileId)
135
+ continue; // 조건 4: 현행 프로필
136
+ if (r.provenance_state !== 'verified' && r.provenance_state !== 'legacy_assumed')
137
+ continue; // 조건 5 (NULL 제외)
138
+ return { vec: Buffer.from(r.embedding), provenance: r.provenance_state }; // owned copy
139
+ }
140
+ return null;
141
+ }
112
142
  function safeRowid(value) {
113
143
  const n = Number(value);
114
144
  if (!Number.isInteger(n) || n < 0) {
@@ -170,6 +200,11 @@ export class RAGKnowledgeGraphManager {
170
200
  this.encoding = get_encoding("cl100k_base");
171
201
  await this.runMigrations();
172
202
  this.currentProfileId = this.ensureCurrentProfile();
203
+ // v14 (spec §7.2): 런타임이 기본 chunker 의 SSOT — 마이그레이션의 리터럴은 동결된
204
+ // 역사이고, 기본값이 진화하면(c2 등) 이 upsert 가 부팅마다 현재값을 기록한다.
205
+ this.db.prepare(`INSERT INTO server_meta (key, value) VALUES ('current_default_chunker', ?)
206
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
207
+ .run(effectiveSignature(DEFAULT_MAX_TOKENS));
173
208
  this.embeddingsMode = opts.skipModel
174
209
  ? 'off'
175
210
  : (process.env.RAG_MEMORY_EMBEDDINGS || 'lazy');
@@ -1843,21 +1878,32 @@ export class RAGKnowledgeGraphManager {
1843
1878
  // CJK), so the function maintains parallel UTF-16 and codepoint cursors and
1844
1879
  // reports codepoint offsets. On a coincidental indexOf miss the char offsets
1845
1880
  // are NULL.
1846
- chunkText(text, maxTokens = 800, overlap = 160) {
1881
+ chunkStructured(text, maxTokens = DEFAULT_MAX_TOKENS) {
1847
1882
  if (!this.encoding)
1848
1883
  throw new Error('Tokenizer not initialized');
1849
- const segments = splitTextIntoChunks(text, this.encoding, maxTokens, overlap);
1850
- return segments.map((seg, idx) => ({
1884
+ return chunkStructuredText(text, this.encoding, maxTokens).map((seg, idx) => ({
1851
1885
  id: '',
1852
1886
  document_id: '',
1853
1887
  chunk_index: idx,
1854
1888
  text: seg.text,
1855
1889
  start_pos: seg.start_pos,
1856
1890
  end_pos: seg.end_pos,
1857
- start_token: seg.start_token,
1858
- end_token: seg.end_token
1891
+ // c1 has no token-space offsets (spec §4.3, r4 D4). Legacy rows keep theirs.
1892
+ start_token: null,
1893
+ end_token: null
1859
1894
  }));
1860
1895
  }
1896
+ // spec §7.1 (r4·r5-8): overlap is rejected on BOTH public paths, BEFORE any
1897
+ // content/dedup judgment — silently accepting it on unchanged content would
1898
+ // void the contract. maxTokens must be a positive integer.
1899
+ validateChunkParams(params) {
1900
+ const { maxTokens = DEFAULT_MAX_TOKENS, overlap = 0 } = params || {};
1901
+ if (!Number.isInteger(maxTokens) || maxTokens <= 0)
1902
+ throw new Error(`chunkParams.maxTokens must be a positive integer (got ${maxTokens})`);
1903
+ if (overlap !== 0)
1904
+ throw new Error(`chunkParams.overlap is no longer supported (chunker c1 has no overlap); omit it or pass 0 (got ${overlap})`);
1905
+ return { maxTokens };
1906
+ }
1861
1907
  // Generate embeddings using sentence transformers
1862
1908
  // isQuery: true for search queries (adds instruction prefix), false for documents/entities
1863
1909
  async generateEmbedding(text, dimensions = 1024, isQuery = false, priority = 'interactive') {
@@ -1882,31 +1928,32 @@ export class RAGKnowledgeGraphManager {
1882
1928
  async syncDocumentFromFile(filePath, documentId, options = {}) {
1883
1929
  if (!this.db)
1884
1930
  throw new Error('Database not initialized');
1885
- // 1. Resolve content: raw file verbatim (default) or explicit override.
1886
- // Content is read on the server and never routed through the model context.
1887
- const content = options.content !== undefined
1888
- ? options.content
1889
- : fsSync.readFileSync(filePath, 'utf-8');
1890
- const bytes = Buffer.byteLength(content, 'utf-8');
1891
- // 2. Metadata: default source=path, updated=today, content_hash; caller can override.
1892
- const today = new Date().toISOString().slice(0, 10);
1893
- const contentHash = createHash('sha256').update(content).digest('hex');
1894
- const metadata = { source: filePath, updated: today, content_hash: contentHash, ...(options.metadata || {}) };
1895
- // 2b. Dedup gate: skip the full delete/store/chunk/embed pipeline when the
1896
- // file is unchanged AND the existing document is fully embedded. The
1897
- // completeness check avoids wrongly skipping a partial/failed prior sync.
1898
- const existingDoc = this.db.prepare(`SELECT metadata FROM documents WHERE id = ?`).get(documentId);
1899
- if (existingDoc) {
1931
+ // spec §7.1 + r5-8: 검증은 content 해석·dedup 판정보다 (첫 실행문).
1932
+ const { maxTokens } = this.validateChunkParams(options.chunkParams);
1933
+ const signature = effectiveSignature(maxTokens);
1934
+ const shaHex = (t) => createHash('sha256').update(t).digest('hex');
1935
+ const zero = { reusedChunks: 0, newlyEmbeddedChunks: 0, queuedChunks: 0, deletedChunks: 0, chunkerTransitioned: false };
1936
+ for (let attempt = 1; attempt <= 3; attempt++) {
1937
+ // r6-3: CAS 재시작 = 처음부터 파일 읽기·hash·metadata 도 attempt 안에서 재계산한다.
1938
+ const content = options.content !== undefined ? options.content : fsSync.readFileSync(filePath, 'utf-8');
1939
+ const bytes = Buffer.byteLength(content, 'utf-8');
1940
+ const today = new Date().toISOString().slice(0, 10);
1941
+ const contentHash = shaHex(content);
1942
+ // spec §5.1: content_hash system-owned user metadata 뒤에 쓴다 (r1: spread 가 덮어쓸 수 있었다).
1943
+ const metadata = { source: filePath, updated: today, ...(options.metadata || {}), content_hash: contentHash };
1944
+ const snap = this.db.prepare(`SELECT content, metadata, chunking_signature FROM documents WHERE id = ?`)
1945
+ .get(documentId);
1900
1946
  let existingHash;
1901
- try {
1902
- existingHash = JSON.parse(existingDoc.metadata)?.content_hash;
1947
+ if (snap) {
1948
+ try {
1949
+ existingHash = JSON.parse(snap.metadata)?.content_hash;
1950
+ }
1951
+ catch { /* hash 없으면 full 경로 */ }
1903
1952
  }
1904
- catch { /* ignore */ }
1905
- if (existingHash === contentHash) {
1953
+ // dedup gate spec §5.1 그대로: "content_hash 동일" 만 (r6-8: content=== 확장 금지.
1954
+ // hash 없거나 낡은 문서는 full 경로로 가서 hash 가 복구된다). signature 무관.
1955
+ if (snap && existingHash === contentHash) {
1906
1956
  const cmCount = this.db.prepare(`SELECT count(*) AS n FROM chunk_metadata WHERE document_id = ?`).get(documentId).n;
1907
- // "Embedded" for dedup completeness = vector exists AND its profile is
1908
- // current (or legacy-NULL awaiting grandfather). Raw vector counts
1909
- // would misjudge old-profile rows as complete (beta 1R supplement).
1910
1957
  const embCount = this.db.prepare(`
1911
1958
  SELECT count(*) AS n FROM chunks c JOIN chunk_metadata m ON c.rowid = m.rowid
1912
1959
  WHERE m.document_id = ? AND (m.provenance_state IS NULL OR m.profile_id = ?)
@@ -1917,100 +1964,141 @@ export class RAGKnowledgeGraphManager {
1917
1964
  `).get(documentId).n;
1918
1965
  if (cmCount > 0 && cmCount === embCount) {
1919
1966
  console.error(`⏭️ syncDocumentFromFile: ${documentId} unchanged (hash match, ${cmCount} chunks embedded) — skipped`);
1920
- return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked, skipped: true, reason: 'unchanged' };
1967
+ return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked,
1968
+ skipped: true, reason: 'unchanged', ...zero };
1921
1969
  }
1922
1970
  if (cmCount > 0 && embCount < cmCount) {
1923
- // v3.6 (spec §5b M12): identical content with incomplete/stale vectors
1924
- // keeps the document, chunks, rowids and entity links — only the
1925
- // missing vectors are re-queued via the coordinator. Full re-chunking
1926
- // here would churn rowids and links for no content change.
1971
+ // v3.6 (spec §5b M12): identical content with incomplete/stale vectors keeps the
1972
+ // document, chunks, rowids and entity links — only missing vectors are re-queued.
1927
1973
  console.error(`♻️ syncDocumentFromFile: ${documentId} unchanged but ${cmCount - embCount} vectors missing — re-queued (chunks preserved)`);
1928
1974
  this.coordinator?.kick();
1929
- return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked, skipped: true, reason: 'unchanged-revectorizing', embedding_status: this.gate.isDisabled ? 'disabled' : 'queued' };
1975
+ return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked,
1976
+ skipped: true, reason: 'unchanged-revectorizing',
1977
+ embedding_status: this.gate.isDisabled ? 'disabled' : 'queued',
1978
+ ...zero, queuedChunks: cmCount - embCount };
1930
1979
  }
1980
+ // cmCount === 0 이면 아래 full 경로로 계속 (최초 생성).
1981
+ }
1982
+ console.error(`🔄 syncDocumentFromFile: ${documentId} <- ${filePath} (${bytes} bytes)`);
1983
+ const segments = this.chunkStructured(content, maxTokens);
1984
+ // spec §5.2-2: 옛 행을 트랜잭션 밖에서 읽는다 (벡터 재사용 후보).
1985
+ const oldRows = this.db.prepare(`
1986
+ SELECT m.rowid, m.text, m.input_hash, m.profile_id, m.provenance_state, c.embedding
1987
+ FROM chunk_metadata m LEFT JOIN chunks c ON c.rowid = m.rowid
1988
+ WHERE m.document_id = ?`).all(documentId);
1989
+ const oldRowids = oldRows.map(r => r.rowid);
1990
+ const byHash = new Map();
1991
+ for (const r of oldRows) {
1992
+ if (!r.input_hash)
1993
+ continue;
1994
+ const arr = byHash.get(r.input_hash);
1995
+ if (arr)
1996
+ arr.push(r);
1997
+ else
1998
+ byHash.set(r.input_hash, [r]);
1999
+ }
2000
+ // 임베딩/재사용 — 트랜잭션 밖, ready 경로 한정 (N2: not-ready 계약 불변).
2001
+ const lazySync = !this.gate.isReady;
2002
+ const slots = [];
2003
+ let reusedChunks = 0, newlyEmbeddedChunks = 0;
2004
+ if (lazySync) {
2005
+ for (const seg of segments)
2006
+ slots.push({ seg, vec: null, provenance: null });
1931
2007
  }
1932
- }
1933
- console.error(`🔄 syncDocumentFromFile: ${documentId} <- ${filePath} (${bytes} bytes)`);
1934
- // 3. Two contracts (spec §5b):
1935
- // ready — pre-compute ALL embeddings BEFORE any DB mutation; if
1936
- // inference throws mid-way the old document stays intact
1937
- // (v3.5.0 atomicity, unchanged).
1938
- // not-ready — intentional lazy sync: store document + chunks + FTS in
1939
- // one transaction with NO vectors (embedding_status:
1940
- // queued); the backfill coordinator recovers them.
1941
- const { maxTokens = 800, overlap = 160 } = options.chunkParams || {};
1942
- const segments = this.chunkText(content, maxTokens, overlap);
1943
- const lazySync = !this.gate.isReady;
1944
- const embedded = [];
1945
- if (lazySync) {
1946
- for (const seg of segments)
1947
- embedded.push({ seg, embedding: null });
1948
- }
1949
- else {
1950
- for (const seg of segments) {
1951
- const embedding = await this.generateEmbedding(seg.text, 1024, false, 'bulk');
1952
- embedded.push({ seg, embedding });
1953
- }
1954
- }
1955
- // 4. Atomic swap: delete old -> insert doc -> insert chunks (+ embeddings
1956
- // with verified provenance when ready), one synchronous transaction.
1957
- const applyTx = this.db.transaction(() => {
1958
- const db = this.db;
1959
- // 4a. cleanup old doc (inlined sync version of cleanupDocument).
1960
- const existing = db.prepare(`SELECT rowid FROM chunk_metadata WHERE document_id = ?`).all(documentId);
1961
- for (const ch of existing) {
1962
- db.prepare(`DELETE FROM chunk_entities WHERE chunk_rowid = ?`).run(ch.rowid);
1963
- db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(ch.rowid)}`);
1964
- }
1965
- db.prepare(`DELETE FROM chunk_metadata WHERE document_id = ?`).run(documentId);
1966
- db.prepare(`DELETE FROM documents WHERE id = ?`).run(documentId);
1967
- // 4b. insert document.
1968
- db.prepare(`INSERT INTO documents (id, content, metadata) VALUES (?, ?, ?)`)
1969
- .run(documentId, content, JSON.stringify(metadata));
1970
- // 4c. insert chunk_metadata (FTS5 chunks_fts auto-filled by trigger);
1971
- // vectors + provenance only on the ready path (§6a-2).
1972
- for (const { seg, embedding } of embedded) {
1973
- const chunkId = `${documentId}_chunk_${seg.chunk_index}`;
1974
- const info = db.prepare(`
1975
- INSERT INTO chunk_metadata (chunk_id, document_id, chunk_index, text, start_pos, end_pos, start_token, end_token)
1976
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1977
- `).run(chunkId, documentId, seg.chunk_index, seg.text, seg.start_pos, seg.end_pos, seg.start_token, seg.end_token);
1978
- const rowid = Number(info.lastInsertRowid);
1979
- if (embedding) {
1980
- db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)`).run(Buffer.from(embedding.buffer));
1981
- db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
1982
- .run(createHash('sha256').update(seg.text).digest('hex'), this.currentProfileId, rowid);
2008
+ else {
2009
+ for (const seg of segments) {
2010
+ const hit = selectReusableVector(byHash.get(shaHex(seg.text)) ?? [], seg.text, this.currentProfileId, shaHex);
2011
+ if (hit) {
2012
+ slots.push({ seg, vec: hit.vec, provenance: hit.provenance });
2013
+ reusedChunks++;
2014
+ }
2015
+ else {
2016
+ const embedding = await this.generateEmbedding(seg.text, 1024, false, 'bulk');
2017
+ slots.push({ seg, vec: Buffer.from(embedding.buffer), provenance: 'verified' });
2018
+ newlyEmbeddedChunks++;
2019
+ }
1983
2020
  }
1984
2021
  }
1985
- });
1986
- applyTx();
1987
- this.coordinator?.invalidateCoverage();
1988
- if (lazySync)
1989
- this.coordinator?.kick();
1990
- const embeddedChunks = lazySync ? 0 : embedded.length;
1991
- // 5. Entity linking AFTER commit. Non-destructive + idempotent (INSERT OR
1992
- // IGNORE), so a linking failure cannot corrupt the doc/embeddings.
1993
- const linkedEntities = await this.autoLinkEntities(documentId);
1994
- let explicitlyLinked;
1995
- if (options.entityNames && options.entityNames.length > 0) {
1996
- const linkResult = await this.linkEntitiesToDocument(documentId, options.entityNames);
1997
- explicitlyLinked = linkResult.linkedEntities;
1998
- }
1999
- // 6. Terse summary only (no chunk text / content echo) to keep caller context flat.
2000
- const result = {
2001
- documentId,
2002
- bytes,
2003
- chunks: segments.length,
2004
- embeddedChunks,
2005
- linkedEntities,
2006
- embedding_status: lazySync ? (this.gate.isDisabled ? 'disabled' : 'queued') : 'embedded',
2007
- ...(explicitlyLinked !== undefined ? { explicitlyLinked } : {}),
2008
- };
2009
- if (linkedEntities === 0 && explicitlyLinked === undefined) {
2010
- result.warning = 'linkedEntities=0: ensure the file content contains entity-name literals (e.g. a wiki anchor line "RAG entity: ...") so term-matching can link entities.';
2022
+ __syncFaultHook?.('pre-transaction');
2023
+ // 한 트랜잭션: CAS 첫 문장 -> full delete/insert -> failure 정리 (spec §5.2-4·5).
2024
+ const applyTx = this.db.transaction(() => {
2025
+ const db = this.db;
2026
+ const now = db.prepare(`SELECT content, metadata, chunking_signature FROM documents WHERE id = ?`)
2027
+ .get(documentId);
2028
+ const same = (snap === undefined && now === undefined) ||
2029
+ (snap !== undefined && now !== undefined && now.content === snap.content &&
2030
+ now.metadata === snap.metadata && now.chunking_signature === snap.chunking_signature);
2031
+ if (!same)
2032
+ throw new SyncCasConflictError(documentId);
2033
+ const existing = db.prepare(`SELECT rowid FROM chunk_metadata WHERE document_id = ?`).all(documentId);
2034
+ for (const ch of existing) {
2035
+ db.prepare(`DELETE FROM chunk_entities WHERE chunk_rowid = ?`).run(ch.rowid);
2036
+ db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(ch.rowid)}`);
2037
+ }
2038
+ db.prepare(`DELETE FROM chunk_metadata WHERE document_id = ?`).run(documentId);
2039
+ db.prepare(`DELETE FROM documents WHERE id = ?`).run(documentId);
2040
+ db.prepare(`INSERT INTO documents (id, content, metadata, chunking_signature) VALUES (?, ?, ?, ?)`)
2041
+ .run(documentId, content, JSON.stringify(metadata), signature);
2042
+ for (const { seg, vec, provenance } of slots) {
2043
+ const chunkId = `${documentId}_chunk_${seg.chunk_index}`;
2044
+ const info = db.prepare(`
2045
+ INSERT INTO chunk_metadata (chunk_id, document_id, chunk_index, text, start_pos, end_pos, start_token, end_token)
2046
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2047
+ `).run(chunkId, documentId, seg.chunk_index, seg.text, seg.start_pos, seg.end_pos, seg.start_token, seg.end_token);
2048
+ const rowid = Number(info.lastInsertRowid);
2049
+ if (vec) {
2050
+ db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)`).run(vec);
2051
+ db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = ? WHERE rowid = ?`)
2052
+ .run(shaHex(seg.text), this.currentProfileId, provenance, rowid);
2053
+ }
2054
+ }
2055
+ if (oldRowids.length > 0) {
2056
+ // r5-9: 키는 (kind, target_id) — kind 조건 없이 지우면 같은 숫자 ID 의 entity failure 까지 지운다.
2057
+ const ph = oldRowids.map(() => '?').join(',');
2058
+ db.prepare(`DELETE FROM embedding_backfill_failures WHERE kind = 'chunk' AND target_id IN (${ph})`)
2059
+ .run(...oldRowids.map(String));
2060
+ }
2061
+ });
2062
+ try {
2063
+ applyTx();
2064
+ }
2065
+ catch (e) {
2066
+ if (e instanceof SyncCasConflictError) {
2067
+ console.error(`↻ sync CAS conflict on ${documentId} (attempt ${attempt}/3) — restarting from file read`);
2068
+ if (attempt === 3)
2069
+ throw e;
2070
+ continue;
2071
+ }
2072
+ throw e;
2073
+ }
2074
+ this.coordinator?.invalidateCoverage();
2075
+ if (lazySync)
2076
+ this.coordinator?.kick();
2077
+ // Entity linking AFTER commit. Non-destructive + idempotent (INSERT OR IGNORE).
2078
+ const linkedEntities = await this.autoLinkEntities(documentId);
2079
+ let explicitlyLinked;
2080
+ if (options.entityNames && options.entityNames.length > 0) {
2081
+ const linkResult = await this.linkEntitiesToDocument(documentId, options.entityNames);
2082
+ explicitlyLinked = linkResult.linkedEntities;
2083
+ }
2084
+ const result = {
2085
+ documentId, bytes, chunks: segments.length,
2086
+ embeddedChunks: reusedChunks + newlyEmbeddedChunks, // spec §5.3
2087
+ linkedEntities,
2088
+ embedding_status: lazySync ? (this.gate.isDisabled ? 'disabled' : 'queued') : 'embedded',
2089
+ reusedChunks, newlyEmbeddedChunks,
2090
+ queuedChunks: lazySync ? segments.length : 0,
2091
+ deletedChunks: oldRowids.length,
2092
+ chunkerTransitioned: snap !== undefined && snap.chunking_signature !== signature,
2093
+ ...(explicitlyLinked !== undefined ? { explicitlyLinked } : {}),
2094
+ };
2095
+ if (linkedEntities === 0 && explicitlyLinked === undefined) {
2096
+ result.warning = 'linkedEntities=0: ensure the file content contains entity-name literals (e.g. a wiki anchor line "RAG entity: ...") so term-matching can link entities.';
2097
+ }
2098
+ console.error(`✅ syncDocumentFromFile done: ${documentId} (${result.chunks} chunks, reused ${reusedChunks}, embedded ${newlyEmbeddedChunks})`);
2099
+ return result;
2011
2100
  }
2012
- console.error(`✅ syncDocumentFromFile done: ${documentId} (${result.chunks} chunks, ${result.embeddedChunks} embedded, ${linkedEntities} linked)`);
2013
- return result;
2101
+ throw new Error('unreachable');
2014
2102
  }
2015
2103
  async storeDocument(id, content, metadata = {}) {
2016
2104
  if (!this.db)
@@ -2036,12 +2124,12 @@ export class RAGKnowledgeGraphManager {
2036
2124
  if (!document) {
2037
2125
  throw new Error(`Document with ID ${documentId} not found`);
2038
2126
  }
2039
- const { maxTokens = 800, overlap = 160 } = options;
2040
- console.error(`🔪 Chunking document: ${documentId} (maxTokens: ${maxTokens}, overlap: ${overlap})`);
2127
+ const { maxTokens } = this.validateChunkParams(options);
2128
+ console.error(`🔪 Chunking document: ${documentId} (maxTokens: ${maxTokens}, chunker: c1)`);
2041
2129
  // Clean up existing chunks
2042
2130
  await this.cleanupDocument(documentId);
2043
2131
  // Create chunks
2044
- const chunks = this.chunkText(document.content, maxTokens, overlap);
2132
+ const chunks = this.chunkStructured(document.content, maxTokens);
2045
2133
  const resultChunks = [];
2046
2134
  for (const chunk of chunks) {
2047
2135
  const chunkId = `${documentId}_chunk_${chunk.chunk_index}`;
@@ -2061,6 +2149,9 @@ export class RAGKnowledgeGraphManager {
2061
2149
  });
2062
2150
  }
2063
2151
  console.error(`✅ Document chunked: ${chunks.length} chunks created`);
2152
+ // spec §7.1: 두 번째 chunk 생성 경로 — 스탬프를 안 박으면 §5.1 관측이 조용히 샌다.
2153
+ this.db.prepare(`UPDATE documents SET chunking_signature = ? WHERE id = ?`)
2154
+ .run(effectiveSignature(maxTokens), documentId);
2064
2155
  // Indirect missing-row producer (spec §5): freshly chunked rows have no
2065
2156
  // vectors yet — let the coordinator recover them without a restart.
2066
2157
  this.coordinator?.invalidateCoverage();
@@ -2113,6 +2204,73 @@ export class RAGKnowledgeGraphManager {
2113
2204
  hasCJK(text) {
2114
2205
  return /[\u3000-\u9fff\uac00-\ud7af\uff00-\uffef]/.test(text);
2115
2206
  }
2207
+ // spec §5.4 (r7-2·r8-1·r9): primary name 의 본문 occurrence range [sCp, eCp).
2208
+ // 의미 = buildEntityMatcher 와 동일 (CJK substring / Latin word-boundary) — 여기서
2209
+ // 어긋나면 'Data' 가 'Database' 에 새로 링크되는 식으로 의미가 확장된다.
2210
+ buildEntityRangeFinder(content) {
2211
+ // 원문 UTF-16 -> codepoint 표. Latin 경로는 folded 가 아니라 **원문**에 regex 를 건다
2212
+ // (r9-1: folded 에 걸면 fooİ -> fooi̇ 로 접힌 뒤 매치돼 현행 matcher 의미가 확장된다).
2213
+ const origU16ToCp = [];
2214
+ let origTotalCp = 0;
2215
+ for (let u = 0; u < content.length;) {
2216
+ const c = content.codePointAt(u);
2217
+ origU16ToCp.push(origTotalCp);
2218
+ if (c > 0xffff) {
2219
+ origU16ToCp.push(origTotalCp);
2220
+ u += 2;
2221
+ }
2222
+ else
2223
+ u += 1;
2224
+ origTotalCp++;
2225
+ }
2226
+ const origCpAt = (u16) => (u16 < origU16ToCp.length ? origU16ToCp[u16] : origTotalCp);
2227
+ // folded 표 (CJK substring / fallback 경로 전용). unit -> 유래한 원문 cp.
2228
+ let folded = '';
2229
+ const u16ToCp = [];
2230
+ let cp = 0;
2231
+ for (const ch of content) { // for..of = codepoint 순회
2232
+ const f = ch.toLowerCase(); // 다단위 fold 가능 (İ -> 'i̇')
2233
+ for (let i = 0; i < f.length; i++)
2234
+ u16ToCp.push(cp);
2235
+ folded += f;
2236
+ cp++;
2237
+ }
2238
+ // r9-1: exclusive end = "마지막으로 소비한 unit 의 원문 cp + 1".
2239
+ // 경계 unit 을 읽으면 매치가 fold 전개 중간에서 끝날 때 1 모자란다 (漢İ/漢i 실측 [0,1)).
2240
+ const endCp = (u16) => (u16 === 0 ? 0 : u16ToCp[Math.min(u16, u16ToCp.length) - 1] + 1);
2241
+ return (name, isCjk) => {
2242
+ const out = [];
2243
+ const lower = name.toLowerCase();
2244
+ const pushAllSubstr = () => {
2245
+ let from = 0;
2246
+ while (true) {
2247
+ const u = folded.indexOf(lower, from);
2248
+ if (u < 0)
2249
+ break;
2250
+ out.push({ s: u16ToCp[u], e: endCp(u + lower.length) });
2251
+ from = u + 1; // r9-2: 중첩 occurrence 보존
2252
+ }
2253
+ };
2254
+ if (isCjk) {
2255
+ pushAllSubstr();
2256
+ return out;
2257
+ }
2258
+ try {
2259
+ const escaped = lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2260
+ const re = new RegExp(`\\b${escaped}\\b`, 'gi'); // buildEntityMatcher 와 동일 규칙,
2261
+ let m; // 단 원문에 실행 (의미 확장 방지)
2262
+ while ((m = re.exec(content)) !== null) {
2263
+ out.push({ s: origCpAt(m.index), e: origCpAt(m.index + m[0].length) });
2264
+ if (re.lastIndex === m.index)
2265
+ re.lastIndex++;
2266
+ }
2267
+ }
2268
+ catch {
2269
+ pushAllSubstr();
2270
+ } // matcher 의 fallback 과 동일
2271
+ return out;
2272
+ };
2273
+ }
2116
2274
  // Build a match pattern for an entity name — word-boundary for Latin, substring for CJK
2117
2275
  buildEntityMatcher(name) {
2118
2276
  const lower = name.toLowerCase();
@@ -2136,9 +2294,12 @@ export class RAGKnowledgeGraphManager {
2136
2294
  return 0;
2137
2295
  try {
2138
2296
  // Get all chunk text for this document
2139
- const chunks = this.db.prepare(`SELECT rowid, text FROM chunk_metadata WHERE document_id = ?`).all(documentId);
2297
+ const chunks = this.db.prepare(`SELECT rowid, text, start_pos, end_pos FROM chunk_metadata WHERE document_id = ?`).all(documentId);
2140
2298
  if (chunks.length === 0)
2141
2299
  return 0;
2300
+ // spec §5.4: range 링킹용 — 문서 본문과 finder 를 1회 준비
2301
+ const docRow = this.db.prepare(`SELECT content FROM documents WHERE id = ?`).get(documentId);
2302
+ const findRanges = docRow ? this.buildEntityRangeFinder(docRow.content) : null;
2142
2303
  // Get all entities with observations for richer matching
2143
2304
  const entities = this.db.prepare(`SELECT id, name, entityType, observations FROM entities`).all();
2144
2305
  // Minimum name length: 2 for CJK (e.g. "할랄"), 4 for Latin (avoid "API", "Bug")
@@ -2184,6 +2345,19 @@ export class RAGKnowledgeGraphManager {
2184
2345
  entityLinked = true;
2185
2346
  }
2186
2347
  }
2348
+ // spec §5.4 (r7-2): chunk 단위 매칭은 경계에 잘린 이름을 영원히 놓친다 — c1 은
2349
+ // overlap 이 없어 흡수도 안 된다. primary name 의 본문 occurrence range 와
2350
+ // 교차하는 chunk 에 링크한다 (aliases 는 predicate 라 chunk 단위 유지).
2351
+ if (findRanges) {
2352
+ for (const { s, e } of findRanges(entity.name, this.hasCJK(entity.name))) {
2353
+ for (const chunk of chunks) {
2354
+ if (chunk.start_pos !== null && chunk.end_pos !== null && chunk.start_pos < e && chunk.end_pos > s) {
2355
+ insertStmt.run(chunk.rowid, entity.id); // INSERT OR IGNORE — 중복 무해
2356
+ entityLinked = true;
2357
+ }
2358
+ }
2359
+ }
2360
+ }
2187
2361
  if (entityLinked)
2188
2362
  linkedCount++;
2189
2363
  }
@@ -3031,8 +3205,12 @@ export class RAGKnowledgeGraphManager {
3031
3205
  graphBoost += Math.min(entityBoost, 0.4);
3032
3206
  }
3033
3207
  // Generate semantic summary (skip when degraded — no embeddings available).
3208
+ // RAG_MEMORY_SEARCH_SUMMARIES=off: diagnostic escape hatch (v5) — the summary
3209
+ // path embeds EVERY sentence of EVERY candidate (~100+ inferences per search,
3210
+ // measured 90-120s cold). Off = preview slices + relevanceScore 0; ranking
3211
+ // then rests on vectorSimilarity + boosts. Default unchanged.
3034
3212
  let summary, keyHighlight, relevanceScore;
3035
- if (vectorDegraded || !primaryQueryEmbedding) {
3213
+ if (vectorDegraded || !primaryQueryEmbedding || process.env.RAG_MEMORY_SEARCH_SUMMARIES === 'off') {
3036
3214
  keyHighlight = result.text.slice(0, 150);
3037
3215
  summary = result.text.slice(0, 300);
3038
3216
  relevanceScore = 0;
@@ -3210,6 +3388,20 @@ export class RAGKnowledgeGraphManager {
3210
3388
  // reads version, model/reconciliation state, and provenance coverage here.
3211
3389
  const gs = this.gate.status;
3212
3390
  const cov = this.coordinator?.coverage();
3391
+ // v14 (spec §7.2): document 기준 chunking 전환 상태 — 상호배타, 합 = documents.
3392
+ // regex 분류는 SQL 밖(JS)에서: current = 런타임이 인식하는 c1 형식(강한 파서),
3393
+ // legacy = 'legacy-unknown', unknown = 그 외 전부.
3394
+ const sigRows = this.db.prepare(`SELECT chunking_signature AS s, count(*) AS n FROM documents GROUP BY chunking_signature`)
3395
+ .all();
3396
+ let sigCur = 0, sigLeg = 0, sigUnk = 0;
3397
+ for (const r of sigRows) {
3398
+ if (r.s === LEGACY_SIGNATURE)
3399
+ sigLeg += r.n;
3400
+ else if (isCurrentFormatSignature(r.s))
3401
+ sigCur += r.n;
3402
+ else
3403
+ sigUnk += r.n;
3404
+ }
3213
3405
  return {
3214
3406
  entities: {
3215
3407
  total: entityStats.reduce((sum, stat) => sum + stat.count, 0),
@@ -3221,6 +3413,8 @@ export class RAGKnowledgeGraphManager {
3221
3413
  },
3222
3414
  documents: documentCount.count,
3223
3415
  chunks: chunkCount.count,
3416
+ chunking: { current: sigCur, legacy: sigLeg, unknown: sigUnk,
3417
+ default_signature: effectiveSignature(DEFAULT_MAX_TOKENS) },
3224
3418
  server: {
3225
3419
  version: PKG_VERSION,
3226
3420
  node: process.versions.node,
@@ -3517,7 +3711,7 @@ export class RAGKnowledgeGraphManager {
3517
3711
  .filter(m => m.version > targetVersion && m.version <= currentVersion)
3518
3712
  .sort((a, b) => b.version - a.version);
3519
3713
  migrationManager.rollback(targetVersion);
3520
- return {
3714
+ const result = {
3521
3715
  rolledBack: migrationsToRollback.length,
3522
3716
  currentVersion: migrationManager.getCurrentVersion(),
3523
3717
  rolledBackMigrations: migrationsToRollback.map(m => ({
@@ -3525,6 +3719,18 @@ export class RAGKnowledgeGraphManager {
3525
3719
  description: m.description
3526
3720
  }))
3527
3721
  };
3722
+ // v14 rollback is a compatibility rollback ONLY (spec §6.3): dropping the
3723
+ // chunking_signature column does not restore old chunk boundaries — c1 rows
3724
+ // read fine on v13 code. Say so in the RESPONSE, not just the tool
3725
+ // description, so a caller who rolled back sees the limit (advisor r5-10).
3726
+ if (result.rolledBackMigrations.some(m => m.version === 14)) {
3727
+ return {
3728
+ ...result,
3729
+ semanticRollback: false,
3730
+ warning: 'v14 rollback removes the chunking_signature column only; chunk boundaries produced by chunker c1 are NOT restored (compatibility rollback). Data restore path = pre-migration backup snapshot.'
3731
+ };
3732
+ }
3733
+ return result;
3528
3734
  }
3529
3735
  }
3530
3736
  // Initialize the manager
@@ -0,0 +1,18 @@
1
+ import type { Tiktoken } from 'tiktoken';
2
+ export interface CSegment {
3
+ text: string;
4
+ start_pos: number;
5
+ end_pos: number;
6
+ }
7
+ export declare const DEFAULT_MAX_TOKENS = 800;
8
+ export declare const LEGACY_SIGNATURE = "legacy-unknown";
9
+ export declare function mergeMinTokens(maxTokens: number): number;
10
+ export declare function effectiveSignature(maxTokens: number): string;
11
+ export declare function isCurrentFormatSignature(sig: string): boolean;
12
+ type Block = {
13
+ lines: string[];
14
+ heading: boolean;
15
+ };
16
+ export declare function buildBlocks(lines: string[]): Block[];
17
+ export declare function chunkStructured(text: string, enc: Tiktoken, maxTokens?: number): CSegment[];
18
+ export {};
@@ -0,0 +1,210 @@
1
+ export const DEFAULT_MAX_TOKENS = 800;
2
+ export const LEGACY_SIGNATURE = 'legacy-unknown';
3
+ // r11: heading is a preferred cut, not a mandatory one. A cut happens only when
4
+ // the accumulated section group AND the next section are both >= this floor —
5
+ // short adjacent sections merge, so cross-section queries still land in one
6
+ // chunk (gate regression k05) and short log sections stop forming over-sharp
7
+ // competitor chunks (k09). Derived, not independent: one knob (maxTokens).
8
+ export function mergeMinTokens(maxTokens) {
9
+ return Math.floor(maxTokens / 2); // NOT >>1 — bitshift wraps at 2^31 (r12)
10
+ }
11
+ export function effectiveSignature(maxTokens) {
12
+ return `c1:enc=cl100k_base:max=${maxTokens}:overlap=0:fence=on:merge=${mergeMinTokens(maxTokens)}:fallback=cp-exact-${maxTokens}`;
13
+ }
14
+ // Strict parse (r5-15): the regex alone classified impossible signatures
15
+ // (max=0, max/fallback mismatch) as current. Cross-check the components.
16
+ export function isCurrentFormatSignature(sig) {
17
+ const m = /^c1:enc=cl100k_base:max=(\d+):overlap=0:fence=on:merge=(\d+):fallback=cp-exact-(\d+)$/.exec(sig);
18
+ if (!m)
19
+ return false;
20
+ const max = Number(m[1]);
21
+ return Number.isInteger(max) && max > 0 && m[1] === m[3] && Number(m[2]) === mergeMinTokens(max);
22
+ }
23
+ function splitLines(text) {
24
+ const lines = [];
25
+ let start = 0;
26
+ for (let i = 0; i < text.length; i++) {
27
+ if (text[i] === '\n') {
28
+ lines.push(text.slice(start, i + 1));
29
+ start = i + 1;
30
+ }
31
+ }
32
+ if (start < text.length)
33
+ lines.push(text.slice(start));
34
+ return lines;
35
+ }
36
+ const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
37
+ const HEADING_RE = /^#{1,4}[ \t]/;
38
+ const BULLET_RE = /^(\s*)([-*+]|\d+[.)])[ \t]/;
39
+ const BLANK_RE = /^\s*$/;
40
+ export function buildBlocks(lines) {
41
+ const blocks = [];
42
+ let i = 0;
43
+ const attachTrailingBlanks = (blk) => {
44
+ while (i < lines.length && BLANK_RE.test(lines[i]))
45
+ blk.lines.push(lines[i++]);
46
+ };
47
+ while (i < lines.length) {
48
+ const line = lines[i];
49
+ const fm = line.match(FENCE_RE);
50
+ if (fm) {
51
+ const marker = fm[1][0];
52
+ const openLen = fm[1].length;
53
+ const blk = { lines: [lines[i++]], heading: false };
54
+ const closeRe = new RegExp(`^\\s{0,3}\\${marker}{${openLen},}\\s*$`);
55
+ while (i < lines.length) {
56
+ const l = lines[i];
57
+ blk.lines.push(l);
58
+ i++;
59
+ if (closeRe.test(l.replace(/\r?\n$/, '')))
60
+ break; // unclosed -> EOF
61
+ }
62
+ attachTrailingBlanks(blk);
63
+ blocks.push(blk);
64
+ }
65
+ else if (HEADING_RE.test(line)) {
66
+ const blk = { lines: [lines[i++]], heading: true };
67
+ attachTrailingBlanks(blk);
68
+ blocks.push(blk);
69
+ }
70
+ else if (BULLET_RE.test(line)) {
71
+ const indent = line.match(BULLET_RE)[1].length;
72
+ const blk = { lines: [lines[i++]], heading: false };
73
+ while (i < lines.length) {
74
+ const l = lines[i];
75
+ if (BLANK_RE.test(l) || HEADING_RE.test(l) || FENCE_RE.test(l))
76
+ break;
77
+ const bm = l.match(BULLET_RE);
78
+ if (bm && bm[1].length <= indent)
79
+ break; // sibling/outer bullet
80
+ const li = l.match(/^(\s*)/)[1].length;
81
+ if (!bm && li <= indent)
82
+ break; // dedented prose
83
+ blk.lines.push(l);
84
+ i++;
85
+ }
86
+ attachTrailingBlanks(blk);
87
+ blocks.push(blk);
88
+ }
89
+ else if (BLANK_RE.test(line)) {
90
+ const blk = { lines: [], heading: false };
91
+ while (i < lines.length && BLANK_RE.test(lines[i]))
92
+ blk.lines.push(lines[i++]);
93
+ blocks.push(blk);
94
+ }
95
+ else {
96
+ const blk = { lines: [lines[i++]], heading: false };
97
+ while (i < lines.length && !BLANK_RE.test(lines[i]) && !HEADING_RE.test(lines[i])
98
+ && !FENCE_RE.test(lines[i]) && !BULLET_RE.test(lines[i])) {
99
+ blk.lines.push(lines[i++]);
100
+ }
101
+ attachTrailingBlanks(blk);
102
+ blocks.push(blk);
103
+ }
104
+ }
105
+ return blocks;
106
+ }
107
+ // cp-exact oversize split, non-monotonicity-safe (spec §4.3-6, r5-5).
108
+ // BPE prefix token counts are NOT monotonic ('/sdkX' -> 1,1,2,1,2), so binary
109
+ // search is invalid. Scan prefixes linearly, remember the last fitting one, and
110
+ // keep probing LOOKAHEAD candidates past a miss to recover dips. If not even
111
+ // one codepoint fits, throw — never emit an over-budget chunk.
112
+ const LOOKAHEAD = 16;
113
+ function splitOversize(block, enc, maxTokens) {
114
+ const cps = [...block];
115
+ const out = [];
116
+ let start = 0;
117
+ while (start < cps.length) {
118
+ let lastFit = 0;
119
+ let missesSinceFit = 0;
120
+ for (let probe = start + 1; probe <= cps.length && missesSinceFit < LOOKAHEAD; probe++) {
121
+ const t = enc.encode(cps.slice(start, probe).join('')).length;
122
+ if (t <= maxTokens) {
123
+ lastFit = probe - start;
124
+ missesSinceFit = 0;
125
+ }
126
+ else
127
+ missesSinceFit++;
128
+ }
129
+ if (lastFit === 0) {
130
+ throw new Error(`chunker c1: codepoint at offset ${start} exceeds maxTokens=${maxTokens} on its own — cannot honor the token budget`);
131
+ }
132
+ out.push(cps.slice(start, start + lastFit).join(''));
133
+ start += lastFit;
134
+ }
135
+ return out;
136
+ }
137
+ export function chunkStructured(text, enc, maxTokens = DEFAULT_MAX_TOKENS) {
138
+ if (text.length === 0)
139
+ return [];
140
+ const blocks = buildBlocks(splitLines(text));
141
+ // r11 section pass: a section = one heading block through the next heading
142
+ // (preamble = blocks before the first heading). Cut decisions are precomputed
143
+ // per section so they depend only on section sizes, not packing state.
144
+ const minSection = mergeMinTokens(maxTokens);
145
+ const sectionOfBlock = new Array(blocks.length);
146
+ const sectionTexts = [];
147
+ let sec = -1;
148
+ blocks.forEach((b, bi) => {
149
+ if (b.heading || sec === -1) {
150
+ sec++;
151
+ sectionTexts[sec] = '';
152
+ }
153
+ sectionOfBlock[bi] = sec;
154
+ sectionTexts[sec] += b.lines.join('');
155
+ });
156
+ const secTokens = sectionTexts.map(t => enc.encode(t).length);
157
+ // Cut at section s iff the group accumulated since the last cut and section s
158
+ // are BOTH >= minSection. Group size = sum of section token counts (token
159
+ // counts are not additive across joins; the sum is a deterministic threshold
160
+ // proxy, never used as a budget).
161
+ const cutAtSection = new Array(secTokens.length).fill(false);
162
+ {
163
+ let groupTokens = 0;
164
+ for (let s = 0; s < secTokens.length; s++) {
165
+ if (s > 0 && groupTokens >= minSection && secTokens[s] >= minSection) {
166
+ cutAtSection[s] = true;
167
+ groupTokens = 0;
168
+ }
169
+ groupTokens += secTokens[s];
170
+ }
171
+ }
172
+ const pieces = [];
173
+ let acc = '';
174
+ const flush = () => { if (acc.length > 0) {
175
+ pieces.push(acc);
176
+ acc = '';
177
+ } };
178
+ for (let bi = 0; bi < blocks.length; bi++) {
179
+ const b = blocks[bi];
180
+ const btext = b.lines.join('');
181
+ if (btext.length === 0)
182
+ continue;
183
+ if (b.heading) {
184
+ const s = sectionOfBlock[bi];
185
+ // Preferred cut: honor the precomputed section cut. Merged headings still
186
+ // flush when their whole section cannot join the open chunk — splitting
187
+ // at the heading beats orphaning the heading line at a budget flush.
188
+ if (cutAtSection[s])
189
+ flush();
190
+ else if (acc.length > 0 && enc.encode(acc + sectionTexts[s]).length > maxTokens)
191
+ flush();
192
+ }
193
+ if (acc.length > 0 && enc.encode(acc + btext).length > maxTokens)
194
+ flush();
195
+ if (acc.length === 0 && enc.encode(btext).length > maxTokens) {
196
+ pieces.push(...splitOversize(btext, enc, maxTokens)); // mega-block fallback
197
+ continue;
198
+ }
199
+ acc += btext;
200
+ }
201
+ flush();
202
+ const segments = [];
203
+ let cursor = 0;
204
+ for (const p of pieces) {
205
+ const len = [...p].length;
206
+ segments.push({ text: p, start_pos: cursor, end_pos: cursor + len });
207
+ cursor += len;
208
+ }
209
+ return segments;
210
+ }
@@ -751,5 +751,34 @@ export const migrations = [
751
751
  db.exec(`DROP TABLE IF EXISTS entity_observations`);
752
752
  db.exec(`DROP TABLE IF EXISTS observation_roots`);
753
753
  }
754
+ },
755
+ {
756
+ version: 14,
757
+ description: 'Chunking signature: documents.chunking_signature (schema-only; backfill legacy-unknown)',
758
+ up: (db) => {
759
+ // spec §6.1: schema-only. 전환은 sync 의 content 변경 시에만 (spec §5.1, r4 D3).
760
+ // 백필값은 'legacy-unknown', NOT 'bpe-800-160': custom chunkDocument 파라미터가
761
+ // 기록된 적 없어 단정하면 거짓 표기가 된다 (advisor r1).
762
+ const before = db.prepare(`SELECT count(*) AS n FROM documents`).get().n;
763
+ db.exec(`ALTER TABLE documents ADD COLUMN chunking_signature TEXT NOT NULL DEFAULT 'legacy-unknown'`);
764
+ // 리터럴 고정 — 마이그레이션은 동결된 역사다. 런타임 기본값 진화는 boot upsert 소관.
765
+ db.prepare(`INSERT INTO server_meta (key, value) VALUES ('current_default_chunker', ?)
766
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
767
+ .run('c1:enc=cl100k_base:max=800:overlap=0:fence=on:fallback=cp-exact-800');
768
+ const after = db.prepare(`SELECT count(*) AS n FROM documents`).get().n;
769
+ if (after !== before)
770
+ throw new Error(`v14 gate: documents rows changed ${before} -> ${after}`);
771
+ const cols = db.prepare(`PRAGMA table_info(documents)`).all();
772
+ if (!cols.some(c => c.name === 'chunking_signature'))
773
+ throw new Error('v14 gate: column missing after ALTER');
774
+ const fk = db.prepare(`PRAGMA foreign_key_check`).all();
775
+ if (fk.length > 0)
776
+ throw new Error(`v14 gate: foreign_key_check reported ${fk.length} violations`);
777
+ },
778
+ down: (db) => {
779
+ // 호환성 rollback 뿐 (spec §6.3): chunk 경계는 복원하지 않는다. c1 행은 v13 코드가 읽는다.
780
+ db.exec(`ALTER TABLE documents DROP COLUMN chunking_signature`);
781
+ db.prepare(`DELETE FROM server_meta WHERE key = 'current_default_chunker'`).run();
782
+ }
754
783
  }
755
784
  ];
@@ -110,7 +110,7 @@ Applies all pending database migrations to bring the schema up to the latest ver
110
110
  },
111
111
  rollbackMigration: {
112
112
  capability: {
113
- description: 'Rollback database to a specific migration version',
113
+ description: 'Rollback database to a specific migration version. v14+: rolling back the chunking_signature column does NOT restore old chunk boundaries (compatibility rollback only — the response carries semanticRollback:false and a warning).',
114
114
  parameters: {
115
115
  type: 'object',
116
116
  properties: {
@@ -75,12 +75,12 @@ const chunkDocumentCapability = {
75
75
  },
76
76
  maxTokens: {
77
77
  type: 'number',
78
- description: 'Maximum tokens per chunk (default: 200)',
78
+ description: 'Maximum tokens per chunk (default: 800, positive integer)',
79
79
  optional: true
80
80
  },
81
81
  overlap: {
82
82
  type: 'number',
83
- description: 'Number of overlapping tokens between chunks (default: 20)',
83
+ description: 'Deprecated since v5.0.0: chunker c1 has no overlap. Omit or pass exactly 0 — any other value is rejected.',
84
84
  optional: true
85
85
  }
86
86
  },
@@ -94,7 +94,7 @@ Create text chunks from a stored document with configurable chunking parameters.
94
94
 
95
95
  <importantNotes>
96
96
  - (!important!) **Document must be stored first** using storeDocument
97
- - (!important!) **Configurable chunking** - adjust maxTokens and overlap as needed
97
+ - (!important!) **Configurable chunking** - adjust maxTokens as needed. overlap is deprecated since v5.0.0 (chunker c1 has no overlap): omit it or pass exactly 0 — any other value is rejected
98
98
  - (!important!) **Replaces existing chunks** for the document if any exist
99
99
  </importantNotes>
100
100
 
@@ -108,19 +108,19 @@ Create text chunks from a stored document with configurable chunking parameters.
108
108
  <bestPractices>
109
109
  - Smaller chunks (100-150 tokens) for precise retrieval
110
110
  - Larger chunks (300-500 tokens) for context preservation
111
- - Use overlap (10-30 tokens) to maintain continuity
111
+ - Chunker c1 anchors boundaries to markdown structure and merges adjacent sections shorter than floor(maxTokens/2), so continuity needs no overlap
112
112
  - Consider document type when choosing chunk size
113
113
  </bestPractices>
114
114
 
115
115
  <examples>
116
116
  - Default chunking: {"documentId": "doc1"}
117
- - Custom size: {"documentId": "doc1", "maxTokens": 150, "overlap": 30}
118
- - Large context: {"documentId": "legal_doc", "maxTokens": 400, "overlap": 50}
117
+ - Custom size: {"documentId": "doc1", "maxTokens": 150}
118
+ - Large context: {"documentId": "legal_doc", "maxTokens": 400}
119
119
  </examples>`;
120
120
  const chunkDocumentSchema = {
121
121
  documentId: z.string().describe('ID of the stored document to chunk'),
122
- maxTokens: z.number().default(200).optional().describe('Maximum tokens per chunk'),
123
- overlap: z.number().default(20).optional().describe('Number of overlapping tokens'),
122
+ maxTokens: z.number().optional().default(800).describe('Maximum tokens per chunk (positive integer)'),
123
+ overlap: z.number().optional().default(0).describe('Deprecated: omit or 0 only (rejected otherwise since v5.0.0)'),
124
124
  };
125
125
  export const chunkDocumentTool = {
126
126
  capability: chunkDocumentCapability,
@@ -549,7 +549,7 @@ const syncDocumentFromFileCapability = {
549
549
  },
550
550
  chunkParams: {
551
551
  type: 'object',
552
- description: 'Optional chunking parameters { maxTokens, overlap }',
552
+ description: 'Optional chunking parameters { maxTokens }. overlap: omit or 0 only (rejected otherwise since v5.0.0 — chunker c1 has no overlap)',
553
553
  additionalProperties: true,
554
554
  optional: true
555
555
  }
@@ -595,7 +595,7 @@ const syncDocumentFromFileSchema = {
595
595
  metadata: z.record(z.any()).optional().describe('Metadata merged into the stored document'),
596
596
  content: z.string().optional().describe('Optional content override; stored instead of reading the file'),
597
597
  entityNames: z.array(z.string()).optional().describe('Optional entities to explicitly link'),
598
- chunkParams: z.record(z.any()).optional().describe('Optional chunking parameters { maxTokens, overlap }'),
598
+ chunkParams: z.record(z.any()).optional().describe('Optional chunking parameters { maxTokens }. overlap: omit or 0 only (rejected otherwise since v5.0.0)'),
599
599
  };
600
600
  export const syncDocumentFromFileTool = {
601
601
  capability: syncDocumentFromFileCapability,
package/docs/UPDATING.md CHANGED
@@ -108,6 +108,49 @@ path and holder pid (e.g. `.download-<key>.lock`). Verify the holder process
108
108
  is genuinely gone or hung (`ps -p <pid>`), then remove the lock file manually;
109
109
  the next start becomes a clean download owner.
110
110
 
111
+ ## v5.0.0 (schema v14): chunker c1 + vector reuse
112
+
113
+ **Breaking**: `chunkParams.overlap` is rejected on BOTH public paths
114
+ (`syncDocumentFromFile.chunkParams` and `chunkDocument`) unless omitted or
115
+ exactly 0 — chunker c1 has no overlap. `maxTokens` must be a positive integer.
116
+ Validation runs before the dedup gate, so invalid params fail even on
117
+ unchanged content.
118
+
119
+ **What changes on upgrade**: nothing, immediately. v14 is schema-only —
120
+ `documents.chunking_signature` is added with DEFAULT `legacy-unknown` and no
121
+ data row changes, so there is no coverage cliff and no re-embedding storm.
122
+ A document transitions to c1 only when its CONTENT changes at sync time
123
+ (signature mismatch alone is an observed state, not a trigger). The first
124
+ sync of an edited document pays a cold transition (old BPE chunk texts rarely
125
+ match c1 boundaries); every later sync reuses vectors for unchanged text.
126
+
127
+ **Observability**: `getKnowledgeGraphStats().chunking = { current, legacy,
128
+ unknown, default_signature }` — mutually exclusive, sums to `documents`. The
129
+ framework's /start Step 5a reads this response.
130
+
131
+ **Rollback caveat (v14)**: `rollbackMigration` drops the column and returns
132
+ `semanticRollback: false` plus a warning — chunk boundaries produced by c1
133
+ are NOT restored (they read fine on v13 code). Data restore path = the
134
+ pre-migration backup snapshot.
135
+
136
+ **Manual links**: full replacement re-derives `chunk_entities`; a link made
137
+ via `linkEntitiesToDocument` that is not reproducible from body literals or
138
+ `entityNames` is not preserved (true before v5 too). New in v5: primary
139
+ entity names are also linked by document-level occurrence ranges, so a name
140
+ cut across a chunk boundary still links to the intersecting chunks
141
+ (overlap used to absorb this; c1 has none).
142
+
143
+ **Fleet prerequisite before releasing/upgrading**: audit every deployment's
144
+ `schema_migrations` for occupied slots `>= 14` — pending migrations are
145
+ selected by MAX(version) arithmetic, so an experimental slot silently skips
146
+ the real v14 (this is exactly how code-v8 never ran in production).
147
+
148
+ **Diagnostic env (v5)**: `RAG_MEMORY_SEARCH_SUMMARIES=off` disables the per-result
149
+ sentence-similarity summaries in `hybridSearch` (which embed every sentence of every
150
+ candidate — 100+ inferences, 90-120s cold per search, measured). Off = preview-slice
151
+ summaries, `relevance_score` 0, ranking rests on vector similarity + boosts. Default
152
+ unchanged. The 3-arm release harness sets this uniformly across all arms.
153
+
111
154
  ## v3.6 breaking response changes
112
155
 
113
156
  1. `hybridSearch` returns an envelope: `{results, search_mode, model_state,
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "rag-memory-epf-mcp",
3
- "version": "4.0.0",
3
+ "version": "5.0.0",
4
4
  "engines": {
5
5
  "node": ">=24"
6
6
  },
7
- "description": "Project-local RAG memory MCP server knowledge graph + multilingual vector + FTS5 in a single SQLite file. Per-project isolation, 38 MCP tools, codepoint-safe chunking (Korean/CJK/emoji).",
7
+ "description": "Project-local RAG memory MCP server \u2014 knowledge graph + multilingual vector + FTS5 in a single SQLite file. Per-project isolation, 38 MCP tools, codepoint-safe chunking (Korean/CJK/emoji).",
8
8
  "keywords": [
9
9
  "mcp",
10
10
  "model-context-protocol",
@@ -45,7 +45,7 @@
45
45
  "prepare": "npm run build",
46
46
  "watch": "tsc --watch",
47
47
  "verify:invariants": "node test/chunk-invariants.test.mjs",
48
- "verify:engine": "node test/engine-smoke.test.mjs && node test/launch-smoke.test.mjs && node test/sync-atomicity.test.mjs && node test/dedup.test.mjs && node test/search-degradation.test.mjs && node test/entity-embed-cap.test.mjs && node test/migration12.test.mjs && node test/model-cache.test.mjs && node test/embedding-gate.test.mjs && node test/lazy-boot.test.mjs && node test/reconciliation.test.mjs && node test/backfill.test.mjs && node test/fts-query.test.mjs && node test/search-contracts.test.mjs && node test/tool-contracts.test.mjs && node test/bounded-exit.test.mjs && node test/observation-schema.test.mjs && node test/observation-migration.test.mjs && node test/observation-lifecycle.test.mjs && node test/observation-contracts.test.mjs && node test/observation-search.test.mjs && node test/observation-cascade.test.mjs && node test/observation-realdata.test.mjs",
48
+ "verify:engine": "node test/engine-smoke.test.mjs && node test/launch-smoke.test.mjs && node test/sync-atomicity.test.mjs && node test/dedup.test.mjs && node test/search-degradation.test.mjs && node test/entity-embed-cap.test.mjs && node test/migration12.test.mjs && node test/model-cache.test.mjs && node test/embedding-gate.test.mjs && node test/lazy-boot.test.mjs && node test/reconciliation.test.mjs && node test/backfill.test.mjs && node test/fts-query.test.mjs && node test/search-contracts.test.mjs && node test/tool-contracts.test.mjs && node test/bounded-exit.test.mjs && node test/observation-schema.test.mjs && node test/observation-migration.test.mjs && node test/observation-lifecycle.test.mjs && node test/observation-contracts.test.mjs && node test/observation-search.test.mjs && node test/observation-cascade.test.mjs && node test/observation-realdata.test.mjs && node test/chunker-c.test.mjs && node test/migration14.test.mjs && node test/chunk-params-validation.test.mjs && node test/vector-reuse.test.mjs && node test/entity-range-linking.test.mjs && node test/stats-chunking.test.mjs && node test/migration14-realdata.test.mjs && node test/migration14-realdata-sync.test.mjs && node test/search-summaries-off.test.mjs",
49
49
  "test": "npm run build && npm run verify:invariants && npm run verify:engine",
50
50
  "prepublishOnly": "npm run build && npm run verify:invariants && npm run verify:engine"
51
51
  },