rag-memory-epf-mcp 4.0.0 → 5.1.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 +4 -4
- package/dist/index.d.ts +30 -1
- package/dist/index.js +363 -122
- package/dist/src/chunkerC.d.ts +18 -0
- package/dist/src/chunkerC.js +210 -0
- package/dist/src/migrations/migrations.js +29 -0
- package/dist/src/tools/migration-tools.js +1 -1
- package/dist/src/tools/rag-tools.js +11 -10
- package/docs/UPDATING.md +78 -0
- package/package.json +2 -2
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
|
-
- **
|
|
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
|
|
|
@@ -64,7 +64,7 @@ Place this `.mcp.json` in each project folder with its own `DB_FILE_PATH`. Each
|
|
|
64
64
|
### Document Pipeline (9)
|
|
65
65
|
| Tool | Description | Annotation |
|
|
66
66
|
|------|------------|------------|
|
|
67
|
-
| `storeDocument` | Store documents with metadata | idempotent |
|
|
67
|
+
| `storeDocument` | Store documents with metadata. Replacing an existing document reports what it destroyed: `{ replaced, deletedChunks }` | idempotent |
|
|
68
68
|
| `chunkDocument` | Create text chunks with configurable parameters | — |
|
|
69
69
|
| `embedChunks` | Generate 1024-dim embeddings + auto-link entities | idempotent |
|
|
70
70
|
| `embedAllEntities` | Batch embed all entities (32 parallel) | idempotent |
|
|
@@ -72,7 +72,7 @@ Place this `.mcp.json` in each project folder with its own `DB_FILE_PATH`. Each
|
|
|
72
72
|
| `linkEntitiesToDocument` | Link entities to chunks where they actually appear (text-matched) | idempotent |
|
|
73
73
|
| `deleteDocuments` | Remove documents and associated data | destructive |
|
|
74
74
|
| `listDocuments` | View all stored documents | readOnly |
|
|
75
|
-
| `syncDocumentFromFile` | One-call server-side sync: reads file + delete/store/chunk/embed/link, content stays off model context. Atomic (embed-first transaction swap) + `content_hash` dedup (skips unchanged files) | idempotent |
|
|
75
|
+
| `syncDocumentFromFile` | One-call server-side sync: reads file + delete/store/chunk/embed/link, content stays off model context. Atomic (embed-first transaction swap) + `content_hash` dedup (skips unchanged files). `excludePattern` strips regions before indexing, and the hash follows the stripped text so changing the pattern re-indexes | idempotent |
|
|
76
76
|
|
|
77
77
|
### Search & Retrieval (9)
|
|
78
78
|
| Tool | Description | Annotation |
|
|
@@ -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
|
|
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,11 +221,13 @@ export declare class RAGKnowledgeGraphManager {
|
|
|
205
221
|
private translateQueryWithMap;
|
|
206
222
|
private buildCrossLingualVariants;
|
|
207
223
|
private extractTermsFromText;
|
|
208
|
-
private
|
|
224
|
+
private chunkStructured;
|
|
225
|
+
private validateChunkParams;
|
|
209
226
|
private generateEmbedding;
|
|
210
227
|
syncDocumentFromFile(filePath: string, documentId: string, options?: {
|
|
211
228
|
metadata?: Record<string, any>;
|
|
212
229
|
content?: string;
|
|
230
|
+
excludePattern?: string | string[];
|
|
213
231
|
entityNames?: string[];
|
|
214
232
|
chunkParams?: {
|
|
215
233
|
maxTokens?: number;
|
|
@@ -225,10 +243,18 @@ export declare class RAGKnowledgeGraphManager {
|
|
|
225
243
|
warning?: string;
|
|
226
244
|
skipped?: boolean;
|
|
227
245
|
reason?: string;
|
|
246
|
+
embedding_status?: string;
|
|
247
|
+
reusedChunks: number;
|
|
248
|
+
newlyEmbeddedChunks: number;
|
|
249
|
+
queuedChunks: number;
|
|
250
|
+
deletedChunks: number;
|
|
251
|
+
chunkerTransitioned: boolean;
|
|
228
252
|
}>;
|
|
229
253
|
storeDocument(id: string, content: string, metadata?: Record<string, any>): Promise<{
|
|
230
254
|
id: string;
|
|
231
255
|
stored: boolean;
|
|
256
|
+
replaced: boolean;
|
|
257
|
+
deletedChunks: number;
|
|
232
258
|
}>;
|
|
233
259
|
chunkDocument(documentId: string, options?: {
|
|
234
260
|
maxTokens?: number;
|
|
@@ -252,6 +278,7 @@ export declare class RAGKnowledgeGraphManager {
|
|
|
252
278
|
errors?: string[];
|
|
253
279
|
}>;
|
|
254
280
|
private hasCJK;
|
|
281
|
+
private buildEntityRangeFinder;
|
|
255
282
|
private buildEntityMatcher;
|
|
256
283
|
private autoLinkEntities;
|
|
257
284
|
extractTerms(documentId: string, options?: {
|
|
@@ -378,6 +405,8 @@ export declare class RAGKnowledgeGraphManager {
|
|
|
378
405
|
version: number;
|
|
379
406
|
description: string;
|
|
380
407
|
}>;
|
|
408
|
+
semanticRollback?: boolean;
|
|
409
|
+
warning?: string;
|
|
381
410
|
}>;
|
|
382
411
|
}
|
|
383
412
|
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 {
|
|
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) {
|
|
@@ -116,6 +146,28 @@ function safeRowid(value) {
|
|
|
116
146
|
}
|
|
117
147
|
return n;
|
|
118
148
|
}
|
|
149
|
+
// Remove regions the caller does not want indexed. Compiled with `s` because the intended use is
|
|
150
|
+
// spanning a marked block (`<!-- SECRET -->…<!-- /SECRET -->`) and JS has no inline (?s) flag —
|
|
151
|
+
// without it every such pattern would silently match nothing.
|
|
152
|
+
// A malformed pattern throws rather than degrading to "no exclusion": indexing is a disclosure
|
|
153
|
+
// path, so believing you excluded something you did not is worse than a failed sync.
|
|
154
|
+
function applyExcludePatterns(text, pattern) {
|
|
155
|
+
if (pattern === undefined)
|
|
156
|
+
return text;
|
|
157
|
+
const patterns = Array.isArray(pattern) ? pattern : [pattern];
|
|
158
|
+
let out = text;
|
|
159
|
+
for (const p of patterns) {
|
|
160
|
+
let re;
|
|
161
|
+
try {
|
|
162
|
+
re = new RegExp(p, 'gs');
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
throw new Error(`excludePattern is not a valid regular expression: ${JSON.stringify(p)} (${e.message})`);
|
|
166
|
+
}
|
|
167
|
+
out = out.replace(re, '');
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
119
171
|
// Enhanced RAG-enabled Knowledge Graph Manager
|
|
120
172
|
export class RAGKnowledgeGraphManager {
|
|
121
173
|
db = null;
|
|
@@ -170,6 +222,11 @@ export class RAGKnowledgeGraphManager {
|
|
|
170
222
|
this.encoding = get_encoding("cl100k_base");
|
|
171
223
|
await this.runMigrations();
|
|
172
224
|
this.currentProfileId = this.ensureCurrentProfile();
|
|
225
|
+
// v14 (spec §7.2): 런타임이 기본 chunker 의 SSOT — 마이그레이션의 리터럴은 동결된
|
|
226
|
+
// 역사이고, 기본값이 진화하면(c2 등) 이 upsert 가 부팅마다 현재값을 기록한다.
|
|
227
|
+
this.db.prepare(`INSERT INTO server_meta (key, value) VALUES ('current_default_chunker', ?)
|
|
228
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
|
|
229
|
+
.run(effectiveSignature(DEFAULT_MAX_TOKENS));
|
|
173
230
|
this.embeddingsMode = opts.skipModel
|
|
174
231
|
? 'off'
|
|
175
232
|
: (process.env.RAG_MEMORY_EMBEDDINGS || 'lazy');
|
|
@@ -1843,21 +1900,32 @@ export class RAGKnowledgeGraphManager {
|
|
|
1843
1900
|
// CJK), so the function maintains parallel UTF-16 and codepoint cursors and
|
|
1844
1901
|
// reports codepoint offsets. On a coincidental indexOf miss the char offsets
|
|
1845
1902
|
// are NULL.
|
|
1846
|
-
|
|
1903
|
+
chunkStructured(text, maxTokens = DEFAULT_MAX_TOKENS) {
|
|
1847
1904
|
if (!this.encoding)
|
|
1848
1905
|
throw new Error('Tokenizer not initialized');
|
|
1849
|
-
|
|
1850
|
-
return segments.map((seg, idx) => ({
|
|
1906
|
+
return chunkStructuredText(text, this.encoding, maxTokens).map((seg, idx) => ({
|
|
1851
1907
|
id: '',
|
|
1852
1908
|
document_id: '',
|
|
1853
1909
|
chunk_index: idx,
|
|
1854
1910
|
text: seg.text,
|
|
1855
1911
|
start_pos: seg.start_pos,
|
|
1856
1912
|
end_pos: seg.end_pos,
|
|
1857
|
-
|
|
1858
|
-
|
|
1913
|
+
// c1 has no token-space offsets (spec §4.3, r4 D4). Legacy rows keep theirs.
|
|
1914
|
+
start_token: null,
|
|
1915
|
+
end_token: null
|
|
1859
1916
|
}));
|
|
1860
1917
|
}
|
|
1918
|
+
// spec §7.1 (r4·r5-8): overlap is rejected on BOTH public paths, BEFORE any
|
|
1919
|
+
// content/dedup judgment — silently accepting it on unchanged content would
|
|
1920
|
+
// void the contract. maxTokens must be a positive integer.
|
|
1921
|
+
validateChunkParams(params) {
|
|
1922
|
+
const { maxTokens = DEFAULT_MAX_TOKENS, overlap = 0 } = params || {};
|
|
1923
|
+
if (!Number.isInteger(maxTokens) || maxTokens <= 0)
|
|
1924
|
+
throw new Error(`chunkParams.maxTokens must be a positive integer (got ${maxTokens})`);
|
|
1925
|
+
if (overlap !== 0)
|
|
1926
|
+
throw new Error(`chunkParams.overlap is no longer supported (chunker c1 has no overlap); omit it or pass 0 (got ${overlap})`);
|
|
1927
|
+
return { maxTokens };
|
|
1928
|
+
}
|
|
1861
1929
|
// Generate embeddings using sentence transformers
|
|
1862
1930
|
// isQuery: true for search queries (adds instruction prefix), false for documents/entities
|
|
1863
1931
|
async generateEmbedding(text, dimensions = 1024, isQuery = false, priority = 'interactive') {
|
|
@@ -1882,31 +1950,36 @@ export class RAGKnowledgeGraphManager {
|
|
|
1882
1950
|
async syncDocumentFromFile(filePath, documentId, options = {}) {
|
|
1883
1951
|
if (!this.db)
|
|
1884
1952
|
throw new Error('Database not initialized');
|
|
1885
|
-
// 1
|
|
1886
|
-
|
|
1887
|
-
const
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1953
|
+
// spec §7.1 + r5-8: 검증은 content 해석·dedup 판정보다 앞 (첫 실행문).
|
|
1954
|
+
const { maxTokens } = this.validateChunkParams(options.chunkParams);
|
|
1955
|
+
const signature = effectiveSignature(maxTokens);
|
|
1956
|
+
const shaHex = (t) => createHash('sha256').update(t).digest('hex');
|
|
1957
|
+
const zero = { reusedChunks: 0, newlyEmbeddedChunks: 0, queuedChunks: 0, deletedChunks: 0, chunkerTransitioned: false };
|
|
1958
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
1959
|
+
// r6-3: CAS 재시작 = 처음부터 — 파일 읽기·hash·metadata 도 attempt 안에서 재계산한다.
|
|
1960
|
+
// Strip excluded regions before anything else looks at the text. Everything downstream —
|
|
1961
|
+
// content_hash, bytes, chunking — then describes what was actually indexed, so changing the
|
|
1962
|
+
// pattern alone still invalidates the dedup gate below. Hashing the raw file instead would
|
|
1963
|
+
// report `unchanged` for a different exclusion, which is the silent-wrong case.
|
|
1964
|
+
const content = applyExcludePatterns(options.content !== undefined ? options.content : fsSync.readFileSync(filePath, 'utf-8'), options.excludePattern);
|
|
1965
|
+
const bytes = Buffer.byteLength(content, 'utf-8');
|
|
1966
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1967
|
+
const contentHash = shaHex(content);
|
|
1968
|
+
// spec §5.1: content_hash 는 system-owned — user metadata 뒤에 쓴다 (r1: spread 가 덮어쓸 수 있었다).
|
|
1969
|
+
const metadata = { source: filePath, updated: today, ...(options.metadata || {}), content_hash: contentHash };
|
|
1970
|
+
const snap = this.db.prepare(`SELECT content, metadata, chunking_signature FROM documents WHERE id = ?`)
|
|
1971
|
+
.get(documentId);
|
|
1900
1972
|
let existingHash;
|
|
1901
|
-
|
|
1902
|
-
|
|
1973
|
+
if (snap) {
|
|
1974
|
+
try {
|
|
1975
|
+
existingHash = JSON.parse(snap.metadata)?.content_hash;
|
|
1976
|
+
}
|
|
1977
|
+
catch { /* hash 없으면 full 경로 */ }
|
|
1903
1978
|
}
|
|
1904
|
-
|
|
1905
|
-
|
|
1979
|
+
// dedup gate — spec §5.1 그대로: "content_hash 동일" 만 (r6-8: content=== 확장 금지.
|
|
1980
|
+
// hash 가 없거나 낡은 문서는 full 경로로 가서 hash 가 복구된다). signature 무관.
|
|
1981
|
+
if (snap && existingHash === contentHash) {
|
|
1906
1982
|
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
1983
|
const embCount = this.db.prepare(`
|
|
1911
1984
|
SELECT count(*) AS n FROM chunks c JOIN chunk_metadata m ON c.rowid = m.rowid
|
|
1912
1985
|
WHERE m.document_id = ? AND (m.provenance_state IS NULL OR m.profile_id = ?)
|
|
@@ -1917,114 +1990,158 @@ export class RAGKnowledgeGraphManager {
|
|
|
1917
1990
|
`).get(documentId).n;
|
|
1918
1991
|
if (cmCount > 0 && cmCount === embCount) {
|
|
1919
1992
|
console.error(`⏭️ syncDocumentFromFile: ${documentId} unchanged (hash match, ${cmCount} chunks embedded) — skipped`);
|
|
1920
|
-
return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked,
|
|
1993
|
+
return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked,
|
|
1994
|
+
skipped: true, reason: 'unchanged', ...zero };
|
|
1921
1995
|
}
|
|
1922
1996
|
if (cmCount > 0 && embCount < cmCount) {
|
|
1923
|
-
// v3.6 (spec §5b M12): identical content with incomplete/stale vectors
|
|
1924
|
-
//
|
|
1925
|
-
// missing vectors are re-queued via the coordinator. Full re-chunking
|
|
1926
|
-
// here would churn rowids and links for no content change.
|
|
1997
|
+
// v3.6 (spec §5b M12): identical content with incomplete/stale vectors keeps the
|
|
1998
|
+
// document, chunks, rowids and entity links — only missing vectors are re-queued.
|
|
1927
1999
|
console.error(`♻️ syncDocumentFromFile: ${documentId} unchanged but ${cmCount - embCount} vectors missing — re-queued (chunks preserved)`);
|
|
1928
2000
|
this.coordinator?.kick();
|
|
1929
|
-
return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked,
|
|
2001
|
+
return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked,
|
|
2002
|
+
skipped: true, reason: 'unchanged-revectorizing',
|
|
2003
|
+
embedding_status: this.gate.isDisabled ? 'disabled' : 'queued',
|
|
2004
|
+
...zero, queuedChunks: cmCount - embCount };
|
|
1930
2005
|
}
|
|
2006
|
+
// cmCount === 0 이면 아래 full 경로로 계속 (최초 생성).
|
|
2007
|
+
}
|
|
2008
|
+
console.error(`🔄 syncDocumentFromFile: ${documentId} <- ${filePath} (${bytes} bytes)`);
|
|
2009
|
+
const segments = this.chunkStructured(content, maxTokens);
|
|
2010
|
+
// spec §5.2-2: 옛 행을 트랜잭션 밖에서 읽는다 (벡터 재사용 후보).
|
|
2011
|
+
const oldRows = this.db.prepare(`
|
|
2012
|
+
SELECT m.rowid, m.text, m.input_hash, m.profile_id, m.provenance_state, c.embedding
|
|
2013
|
+
FROM chunk_metadata m LEFT JOIN chunks c ON c.rowid = m.rowid
|
|
2014
|
+
WHERE m.document_id = ?`).all(documentId);
|
|
2015
|
+
const oldRowids = oldRows.map(r => r.rowid);
|
|
2016
|
+
const byHash = new Map();
|
|
2017
|
+
for (const r of oldRows) {
|
|
2018
|
+
if (!r.input_hash)
|
|
2019
|
+
continue;
|
|
2020
|
+
const arr = byHash.get(r.input_hash);
|
|
2021
|
+
if (arr)
|
|
2022
|
+
arr.push(r);
|
|
2023
|
+
else
|
|
2024
|
+
byHash.set(r.input_hash, [r]);
|
|
2025
|
+
}
|
|
2026
|
+
// 임베딩/재사용 — 트랜잭션 밖, ready 경로 한정 (N2: not-ready 계약 불변).
|
|
2027
|
+
const lazySync = !this.gate.isReady;
|
|
2028
|
+
const slots = [];
|
|
2029
|
+
let reusedChunks = 0, newlyEmbeddedChunks = 0;
|
|
2030
|
+
if (lazySync) {
|
|
2031
|
+
for (const seg of segments)
|
|
2032
|
+
slots.push({ seg, vec: null, provenance: null });
|
|
1931
2033
|
}
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
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);
|
|
2034
|
+
else {
|
|
2035
|
+
for (const seg of segments) {
|
|
2036
|
+
const hit = selectReusableVector(byHash.get(shaHex(seg.text)) ?? [], seg.text, this.currentProfileId, shaHex);
|
|
2037
|
+
if (hit) {
|
|
2038
|
+
slots.push({ seg, vec: hit.vec, provenance: hit.provenance });
|
|
2039
|
+
reusedChunks++;
|
|
2040
|
+
}
|
|
2041
|
+
else {
|
|
2042
|
+
const embedding = await this.generateEmbedding(seg.text, 1024, false, 'bulk');
|
|
2043
|
+
slots.push({ seg, vec: Buffer.from(embedding.buffer), provenance: 'verified' });
|
|
2044
|
+
newlyEmbeddedChunks++;
|
|
2045
|
+
}
|
|
1983
2046
|
}
|
|
1984
2047
|
}
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2048
|
+
__syncFaultHook?.('pre-transaction');
|
|
2049
|
+
// 한 트랜잭션: CAS 첫 문장 -> full delete/insert -> failure 정리 (spec §5.2-4·5).
|
|
2050
|
+
const applyTx = this.db.transaction(() => {
|
|
2051
|
+
const db = this.db;
|
|
2052
|
+
const now = db.prepare(`SELECT content, metadata, chunking_signature FROM documents WHERE id = ?`)
|
|
2053
|
+
.get(documentId);
|
|
2054
|
+
const same = (snap === undefined && now === undefined) ||
|
|
2055
|
+
(snap !== undefined && now !== undefined && now.content === snap.content &&
|
|
2056
|
+
now.metadata === snap.metadata && now.chunking_signature === snap.chunking_signature);
|
|
2057
|
+
if (!same)
|
|
2058
|
+
throw new SyncCasConflictError(documentId);
|
|
2059
|
+
const existing = db.prepare(`SELECT rowid FROM chunk_metadata WHERE document_id = ?`).all(documentId);
|
|
2060
|
+
for (const ch of existing) {
|
|
2061
|
+
db.prepare(`DELETE FROM chunk_entities WHERE chunk_rowid = ?`).run(ch.rowid);
|
|
2062
|
+
db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(ch.rowid)}`);
|
|
2063
|
+
}
|
|
2064
|
+
db.prepare(`DELETE FROM chunk_metadata WHERE document_id = ?`).run(documentId);
|
|
2065
|
+
db.prepare(`DELETE FROM documents WHERE id = ?`).run(documentId);
|
|
2066
|
+
db.prepare(`INSERT INTO documents (id, content, metadata, chunking_signature) VALUES (?, ?, ?, ?)`)
|
|
2067
|
+
.run(documentId, content, JSON.stringify(metadata), signature);
|
|
2068
|
+
for (const { seg, vec, provenance } of slots) {
|
|
2069
|
+
const chunkId = `${documentId}_chunk_${seg.chunk_index}`;
|
|
2070
|
+
const info = db.prepare(`
|
|
2071
|
+
INSERT INTO chunk_metadata (chunk_id, document_id, chunk_index, text, start_pos, end_pos, start_token, end_token)
|
|
2072
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
2073
|
+
`).run(chunkId, documentId, seg.chunk_index, seg.text, seg.start_pos, seg.end_pos, seg.start_token, seg.end_token);
|
|
2074
|
+
const rowid = Number(info.lastInsertRowid);
|
|
2075
|
+
if (vec) {
|
|
2076
|
+
db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)`).run(vec);
|
|
2077
|
+
db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = ? WHERE rowid = ?`)
|
|
2078
|
+
.run(shaHex(seg.text), this.currentProfileId, provenance, rowid);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
if (oldRowids.length > 0) {
|
|
2082
|
+
// r5-9: 키는 (kind, target_id) — kind 조건 없이 지우면 같은 숫자 ID 의 entity failure 까지 지운다.
|
|
2083
|
+
const ph = oldRowids.map(() => '?').join(',');
|
|
2084
|
+
db.prepare(`DELETE FROM embedding_backfill_failures WHERE kind = 'chunk' AND target_id IN (${ph})`)
|
|
2085
|
+
.run(...oldRowids.map(String));
|
|
2086
|
+
}
|
|
2087
|
+
});
|
|
2088
|
+
try {
|
|
2089
|
+
applyTx();
|
|
2090
|
+
}
|
|
2091
|
+
catch (e) {
|
|
2092
|
+
if (e instanceof SyncCasConflictError) {
|
|
2093
|
+
console.error(`↻ sync CAS conflict on ${documentId} (attempt ${attempt}/3) — restarting from file read`);
|
|
2094
|
+
if (attempt === 3)
|
|
2095
|
+
throw e;
|
|
2096
|
+
continue;
|
|
2097
|
+
}
|
|
2098
|
+
throw e;
|
|
2099
|
+
}
|
|
2100
|
+
this.coordinator?.invalidateCoverage();
|
|
2101
|
+
if (lazySync)
|
|
2102
|
+
this.coordinator?.kick();
|
|
2103
|
+
// Entity linking AFTER commit. Non-destructive + idempotent (INSERT OR IGNORE).
|
|
2104
|
+
const linkedEntities = await this.autoLinkEntities(documentId);
|
|
2105
|
+
let explicitlyLinked;
|
|
2106
|
+
if (options.entityNames && options.entityNames.length > 0) {
|
|
2107
|
+
const linkResult = await this.linkEntitiesToDocument(documentId, options.entityNames);
|
|
2108
|
+
explicitlyLinked = linkResult.linkedEntities;
|
|
2109
|
+
}
|
|
2110
|
+
const result = {
|
|
2111
|
+
documentId, bytes, chunks: segments.length,
|
|
2112
|
+
embeddedChunks: reusedChunks + newlyEmbeddedChunks, // spec §5.3
|
|
2113
|
+
linkedEntities,
|
|
2114
|
+
embedding_status: lazySync ? (this.gate.isDisabled ? 'disabled' : 'queued') : 'embedded',
|
|
2115
|
+
reusedChunks, newlyEmbeddedChunks,
|
|
2116
|
+
queuedChunks: lazySync ? segments.length : 0,
|
|
2117
|
+
deletedChunks: oldRowids.length,
|
|
2118
|
+
chunkerTransitioned: snap !== undefined && snap.chunking_signature !== signature,
|
|
2119
|
+
...(explicitlyLinked !== undefined ? { explicitlyLinked } : {}),
|
|
2120
|
+
};
|
|
2121
|
+
if (linkedEntities === 0 && explicitlyLinked === undefined) {
|
|
2122
|
+
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.';
|
|
2123
|
+
}
|
|
2124
|
+
console.error(`✅ syncDocumentFromFile done: ${documentId} (${result.chunks} chunks, reused ${reusedChunks}, embedded ${newlyEmbeddedChunks})`);
|
|
2125
|
+
return result;
|
|
2011
2126
|
}
|
|
2012
|
-
|
|
2013
|
-
return result;
|
|
2127
|
+
throw new Error('unreachable');
|
|
2014
2128
|
}
|
|
2015
2129
|
async storeDocument(id, content, metadata = {}) {
|
|
2016
2130
|
if (!this.db)
|
|
2017
2131
|
throw new Error('Database not initialized');
|
|
2018
2132
|
console.error(`📄 Storing document: ${id}`);
|
|
2133
|
+
// Decide `replaced` from the document row, not from the chunk count: a document stored but
|
|
2134
|
+
// never chunked still gets overwritten here, and reporting that as a fresh write would be a lie.
|
|
2135
|
+
const existed = this.db.prepare(`SELECT 1 FROM documents WHERE id = ?`).get(id) !== undefined;
|
|
2019
2136
|
// Clean up existing document
|
|
2020
|
-
await this.cleanupDocument(id);
|
|
2137
|
+
const cleaned = await this.cleanupDocument(id);
|
|
2021
2138
|
// Store document
|
|
2022
2139
|
this.db.prepare(`
|
|
2023
2140
|
INSERT OR REPLACE INTO documents (id, content, metadata)
|
|
2024
2141
|
VALUES (?, ?, ?)
|
|
2025
2142
|
`).run(id, content, JSON.stringify(metadata));
|
|
2026
2143
|
console.error(`✅ Document stored: ${id}`);
|
|
2027
|
-
return { id, stored: true };
|
|
2144
|
+
return { id, stored: true, replaced: existed, deletedChunks: cleaned.deletedChunks };
|
|
2028
2145
|
}
|
|
2029
2146
|
async chunkDocument(documentId, options = {}) {
|
|
2030
2147
|
if (!this.db)
|
|
@@ -2036,12 +2153,12 @@ export class RAGKnowledgeGraphManager {
|
|
|
2036
2153
|
if (!document) {
|
|
2037
2154
|
throw new Error(`Document with ID ${documentId} not found`);
|
|
2038
2155
|
}
|
|
2039
|
-
const { maxTokens
|
|
2040
|
-
console.error(`🔪 Chunking document: ${documentId} (maxTokens: ${maxTokens},
|
|
2156
|
+
const { maxTokens } = this.validateChunkParams(options);
|
|
2157
|
+
console.error(`🔪 Chunking document: ${documentId} (maxTokens: ${maxTokens}, chunker: c1)`);
|
|
2041
2158
|
// Clean up existing chunks
|
|
2042
2159
|
await this.cleanupDocument(documentId);
|
|
2043
2160
|
// Create chunks
|
|
2044
|
-
const chunks = this.
|
|
2161
|
+
const chunks = this.chunkStructured(document.content, maxTokens);
|
|
2045
2162
|
const resultChunks = [];
|
|
2046
2163
|
for (const chunk of chunks) {
|
|
2047
2164
|
const chunkId = `${documentId}_chunk_${chunk.chunk_index}`;
|
|
@@ -2061,6 +2178,9 @@ export class RAGKnowledgeGraphManager {
|
|
|
2061
2178
|
});
|
|
2062
2179
|
}
|
|
2063
2180
|
console.error(`✅ Document chunked: ${chunks.length} chunks created`);
|
|
2181
|
+
// spec §7.1: 두 번째 chunk 생성 경로 — 스탬프를 안 박으면 §5.1 관측이 조용히 샌다.
|
|
2182
|
+
this.db.prepare(`UPDATE documents SET chunking_signature = ? WHERE id = ?`)
|
|
2183
|
+
.run(effectiveSignature(maxTokens), documentId);
|
|
2064
2184
|
// Indirect missing-row producer (spec §5): freshly chunked rows have no
|
|
2065
2185
|
// vectors yet — let the coordinator recover them without a restart.
|
|
2066
2186
|
this.coordinator?.invalidateCoverage();
|
|
@@ -2113,6 +2233,73 @@ export class RAGKnowledgeGraphManager {
|
|
|
2113
2233
|
hasCJK(text) {
|
|
2114
2234
|
return /[\u3000-\u9fff\uac00-\ud7af\uff00-\uffef]/.test(text);
|
|
2115
2235
|
}
|
|
2236
|
+
// spec §5.4 (r7-2·r8-1·r9): primary name 의 본문 occurrence range [sCp, eCp).
|
|
2237
|
+
// 의미 = buildEntityMatcher 와 동일 (CJK substring / Latin word-boundary) — 여기서
|
|
2238
|
+
// 어긋나면 'Data' 가 'Database' 에 새로 링크되는 식으로 의미가 확장된다.
|
|
2239
|
+
buildEntityRangeFinder(content) {
|
|
2240
|
+
// 원문 UTF-16 -> codepoint 표. Latin 경로는 folded 가 아니라 **원문**에 regex 를 건다
|
|
2241
|
+
// (r9-1: folded 에 걸면 fooİ -> fooi̇ 로 접힌 뒤 매치돼 현행 matcher 의미가 확장된다).
|
|
2242
|
+
const origU16ToCp = [];
|
|
2243
|
+
let origTotalCp = 0;
|
|
2244
|
+
for (let u = 0; u < content.length;) {
|
|
2245
|
+
const c = content.codePointAt(u);
|
|
2246
|
+
origU16ToCp.push(origTotalCp);
|
|
2247
|
+
if (c > 0xffff) {
|
|
2248
|
+
origU16ToCp.push(origTotalCp);
|
|
2249
|
+
u += 2;
|
|
2250
|
+
}
|
|
2251
|
+
else
|
|
2252
|
+
u += 1;
|
|
2253
|
+
origTotalCp++;
|
|
2254
|
+
}
|
|
2255
|
+
const origCpAt = (u16) => (u16 < origU16ToCp.length ? origU16ToCp[u16] : origTotalCp);
|
|
2256
|
+
// folded 표 (CJK substring / fallback 경로 전용). unit -> 유래한 원문 cp.
|
|
2257
|
+
let folded = '';
|
|
2258
|
+
const u16ToCp = [];
|
|
2259
|
+
let cp = 0;
|
|
2260
|
+
for (const ch of content) { // for..of = codepoint 순회
|
|
2261
|
+
const f = ch.toLowerCase(); // 다단위 fold 가능 (İ -> 'i̇')
|
|
2262
|
+
for (let i = 0; i < f.length; i++)
|
|
2263
|
+
u16ToCp.push(cp);
|
|
2264
|
+
folded += f;
|
|
2265
|
+
cp++;
|
|
2266
|
+
}
|
|
2267
|
+
// r9-1: exclusive end = "마지막으로 소비한 unit 의 원문 cp + 1".
|
|
2268
|
+
// 경계 unit 을 읽으면 매치가 fold 전개 중간에서 끝날 때 1 모자란다 (漢İ/漢i 실측 [0,1)).
|
|
2269
|
+
const endCp = (u16) => (u16 === 0 ? 0 : u16ToCp[Math.min(u16, u16ToCp.length) - 1] + 1);
|
|
2270
|
+
return (name, isCjk) => {
|
|
2271
|
+
const out = [];
|
|
2272
|
+
const lower = name.toLowerCase();
|
|
2273
|
+
const pushAllSubstr = () => {
|
|
2274
|
+
let from = 0;
|
|
2275
|
+
while (true) {
|
|
2276
|
+
const u = folded.indexOf(lower, from);
|
|
2277
|
+
if (u < 0)
|
|
2278
|
+
break;
|
|
2279
|
+
out.push({ s: u16ToCp[u], e: endCp(u + lower.length) });
|
|
2280
|
+
from = u + 1; // r9-2: 중첩 occurrence 보존
|
|
2281
|
+
}
|
|
2282
|
+
};
|
|
2283
|
+
if (isCjk) {
|
|
2284
|
+
pushAllSubstr();
|
|
2285
|
+
return out;
|
|
2286
|
+
}
|
|
2287
|
+
try {
|
|
2288
|
+
const escaped = lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2289
|
+
const re = new RegExp(`\\b${escaped}\\b`, 'gi'); // buildEntityMatcher 와 동일 규칙,
|
|
2290
|
+
let m; // 단 원문에 실행 (의미 확장 방지)
|
|
2291
|
+
while ((m = re.exec(content)) !== null) {
|
|
2292
|
+
out.push({ s: origCpAt(m.index), e: origCpAt(m.index + m[0].length) });
|
|
2293
|
+
if (re.lastIndex === m.index)
|
|
2294
|
+
re.lastIndex++;
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
catch {
|
|
2298
|
+
pushAllSubstr();
|
|
2299
|
+
} // matcher 의 fallback 과 동일
|
|
2300
|
+
return out;
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2116
2303
|
// Build a match pattern for an entity name — word-boundary for Latin, substring for CJK
|
|
2117
2304
|
buildEntityMatcher(name) {
|
|
2118
2305
|
const lower = name.toLowerCase();
|
|
@@ -2136,9 +2323,12 @@ export class RAGKnowledgeGraphManager {
|
|
|
2136
2323
|
return 0;
|
|
2137
2324
|
try {
|
|
2138
2325
|
// Get all chunk text for this document
|
|
2139
|
-
const chunks = this.db.prepare(`SELECT rowid, text FROM chunk_metadata WHERE document_id = ?`).all(documentId);
|
|
2326
|
+
const chunks = this.db.prepare(`SELECT rowid, text, start_pos, end_pos FROM chunk_metadata WHERE document_id = ?`).all(documentId);
|
|
2140
2327
|
if (chunks.length === 0)
|
|
2141
2328
|
return 0;
|
|
2329
|
+
// spec §5.4: range 링킹용 — 문서 본문과 finder 를 1회 준비
|
|
2330
|
+
const docRow = this.db.prepare(`SELECT content FROM documents WHERE id = ?`).get(documentId);
|
|
2331
|
+
const findRanges = docRow ? this.buildEntityRangeFinder(docRow.content) : null;
|
|
2142
2332
|
// Get all entities with observations for richer matching
|
|
2143
2333
|
const entities = this.db.prepare(`SELECT id, name, entityType, observations FROM entities`).all();
|
|
2144
2334
|
// Minimum name length: 2 for CJK (e.g. "할랄"), 4 for Latin (avoid "API", "Bug")
|
|
@@ -2184,6 +2374,19 @@ export class RAGKnowledgeGraphManager {
|
|
|
2184
2374
|
entityLinked = true;
|
|
2185
2375
|
}
|
|
2186
2376
|
}
|
|
2377
|
+
// spec §5.4 (r7-2): chunk 단위 매칭은 경계에 잘린 이름을 영원히 놓친다 — c1 은
|
|
2378
|
+
// overlap 이 없어 흡수도 안 된다. primary name 의 본문 occurrence range 와
|
|
2379
|
+
// 교차하는 chunk 에 링크한다 (aliases 는 predicate 라 chunk 단위 유지).
|
|
2380
|
+
if (findRanges) {
|
|
2381
|
+
for (const { s, e } of findRanges(entity.name, this.hasCJK(entity.name))) {
|
|
2382
|
+
for (const chunk of chunks) {
|
|
2383
|
+
if (chunk.start_pos !== null && chunk.end_pos !== null && chunk.start_pos < e && chunk.end_pos > s) {
|
|
2384
|
+
insertStmt.run(chunk.rowid, entity.id); // INSERT OR IGNORE — 중복 무해
|
|
2385
|
+
entityLinked = true;
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2187
2390
|
if (entityLinked)
|
|
2188
2391
|
linkedCount++;
|
|
2189
2392
|
}
|
|
@@ -2256,9 +2459,13 @@ export class RAGKnowledgeGraphManager {
|
|
|
2256
2459
|
console.error(`✅ Entities linked: ${linkedCount} entities linked to document`);
|
|
2257
2460
|
return { documentId, linkedEntities: linkedCount };
|
|
2258
2461
|
}
|
|
2462
|
+
// Report what was destroyed. The counts were already computed here and thrown away, so a caller
|
|
2463
|
+
// that replaces a document could not tell from the return value that anything was deleted
|
|
2464
|
+
// (2026-08-05 field report from a deployed project: "{stored:true} came back and I did not know
|
|
2465
|
+
// what I had just wiped"). Silent destruction is the defect; the numbers are free.
|
|
2259
2466
|
async cleanupDocument(documentId) {
|
|
2260
2467
|
if (!this.db)
|
|
2261
|
-
return;
|
|
2468
|
+
return { deletedChunks: 0, deletedAssociations: 0, deletedVectors: 0 };
|
|
2262
2469
|
console.error(`🧹 Cleaning up document: ${documentId}`);
|
|
2263
2470
|
// Get existing chunks
|
|
2264
2471
|
const existingChunks = this.db.prepare(`
|
|
@@ -2286,6 +2493,7 @@ export class RAGKnowledgeGraphManager {
|
|
|
2286
2493
|
console.error(` ├─ Deleted ${deletedVectors} vector embeddings`);
|
|
2287
2494
|
console.error(` └─ Deleted ${metadata.changes} chunk metadata records`);
|
|
2288
2495
|
}
|
|
2496
|
+
return { deletedChunks: existingChunks.length, deletedAssociations, deletedVectors };
|
|
2289
2497
|
}
|
|
2290
2498
|
async deleteDocument(documentId) {
|
|
2291
2499
|
if (!this.db)
|
|
@@ -3031,8 +3239,12 @@ export class RAGKnowledgeGraphManager {
|
|
|
3031
3239
|
graphBoost += Math.min(entityBoost, 0.4);
|
|
3032
3240
|
}
|
|
3033
3241
|
// Generate semantic summary (skip when degraded — no embeddings available).
|
|
3242
|
+
// RAG_MEMORY_SEARCH_SUMMARIES=off: diagnostic escape hatch (v5) — the summary
|
|
3243
|
+
// path embeds EVERY sentence of EVERY candidate (~100+ inferences per search,
|
|
3244
|
+
// measured 90-120s cold). Off = preview slices + relevanceScore 0; ranking
|
|
3245
|
+
// then rests on vectorSimilarity + boosts. Default unchanged.
|
|
3034
3246
|
let summary, keyHighlight, relevanceScore;
|
|
3035
|
-
if (vectorDegraded || !primaryQueryEmbedding) {
|
|
3247
|
+
if (vectorDegraded || !primaryQueryEmbedding || process.env.RAG_MEMORY_SEARCH_SUMMARIES === 'off') {
|
|
3036
3248
|
keyHighlight = result.text.slice(0, 150);
|
|
3037
3249
|
summary = result.text.slice(0, 300);
|
|
3038
3250
|
relevanceScore = 0;
|
|
@@ -3210,6 +3422,20 @@ export class RAGKnowledgeGraphManager {
|
|
|
3210
3422
|
// reads version, model/reconciliation state, and provenance coverage here.
|
|
3211
3423
|
const gs = this.gate.status;
|
|
3212
3424
|
const cov = this.coordinator?.coverage();
|
|
3425
|
+
// v14 (spec §7.2): document 기준 chunking 전환 상태 — 상호배타, 합 = documents.
|
|
3426
|
+
// regex 분류는 SQL 밖(JS)에서: current = 런타임이 인식하는 c1 형식(강한 파서),
|
|
3427
|
+
// legacy = 'legacy-unknown', unknown = 그 외 전부.
|
|
3428
|
+
const sigRows = this.db.prepare(`SELECT chunking_signature AS s, count(*) AS n FROM documents GROUP BY chunking_signature`)
|
|
3429
|
+
.all();
|
|
3430
|
+
let sigCur = 0, sigLeg = 0, sigUnk = 0;
|
|
3431
|
+
for (const r of sigRows) {
|
|
3432
|
+
if (r.s === LEGACY_SIGNATURE)
|
|
3433
|
+
sigLeg += r.n;
|
|
3434
|
+
else if (isCurrentFormatSignature(r.s))
|
|
3435
|
+
sigCur += r.n;
|
|
3436
|
+
else
|
|
3437
|
+
sigUnk += r.n;
|
|
3438
|
+
}
|
|
3213
3439
|
return {
|
|
3214
3440
|
entities: {
|
|
3215
3441
|
total: entityStats.reduce((sum, stat) => sum + stat.count, 0),
|
|
@@ -3221,6 +3447,8 @@ export class RAGKnowledgeGraphManager {
|
|
|
3221
3447
|
},
|
|
3222
3448
|
documents: documentCount.count,
|
|
3223
3449
|
chunks: chunkCount.count,
|
|
3450
|
+
chunking: { current: sigCur, legacy: sigLeg, unknown: sigUnk,
|
|
3451
|
+
default_signature: effectiveSignature(DEFAULT_MAX_TOKENS) },
|
|
3224
3452
|
server: {
|
|
3225
3453
|
version: PKG_VERSION,
|
|
3226
3454
|
node: process.versions.node,
|
|
@@ -3517,7 +3745,7 @@ export class RAGKnowledgeGraphManager {
|
|
|
3517
3745
|
.filter(m => m.version > targetVersion && m.version <= currentVersion)
|
|
3518
3746
|
.sort((a, b) => b.version - a.version);
|
|
3519
3747
|
migrationManager.rollback(targetVersion);
|
|
3520
|
-
|
|
3748
|
+
const result = {
|
|
3521
3749
|
rolledBack: migrationsToRollback.length,
|
|
3522
3750
|
currentVersion: migrationManager.getCurrentVersion(),
|
|
3523
3751
|
rolledBackMigrations: migrationsToRollback.map(m => ({
|
|
@@ -3525,6 +3753,18 @@ export class RAGKnowledgeGraphManager {
|
|
|
3525
3753
|
description: m.description
|
|
3526
3754
|
}))
|
|
3527
3755
|
};
|
|
3756
|
+
// v14 rollback is a compatibility rollback ONLY (spec §6.3): dropping the
|
|
3757
|
+
// chunking_signature column does not restore old chunk boundaries — c1 rows
|
|
3758
|
+
// read fine on v13 code. Say so in the RESPONSE, not just the tool
|
|
3759
|
+
// description, so a caller who rolled back sees the limit (advisor r5-10).
|
|
3760
|
+
if (result.rolledBackMigrations.some(m => m.version === 14)) {
|
|
3761
|
+
return {
|
|
3762
|
+
...result,
|
|
3763
|
+
semanticRollback: false,
|
|
3764
|
+
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.'
|
|
3765
|
+
};
|
|
3766
|
+
}
|
|
3767
|
+
return result;
|
|
3528
3768
|
}
|
|
3529
3769
|
}
|
|
3530
3770
|
// Initialize the manager
|
|
@@ -3633,6 +3873,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3633
3873
|
return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.syncDocumentFromFile(validatedArgs.path, validatedArgs.documentId, {
|
|
3634
3874
|
metadata: validatedArgs.metadata,
|
|
3635
3875
|
content: validatedArgs.content,
|
|
3876
|
+
excludePattern: validatedArgs.excludePattern,
|
|
3636
3877
|
entityNames: validatedArgs.entityNames,
|
|
3637
3878
|
chunkParams: validatedArgs.chunkParams,
|
|
3638
3879
|
}), null, 2) }] };
|
|
@@ -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:
|
|
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: '
|
|
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
|
|
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
|
-
-
|
|
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
|
|
118
|
-
- Large context: {"documentId": "legal_doc", "maxTokens": 400
|
|
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().
|
|
123
|
-
overlap: z.number().
|
|
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
|
|
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
|
}
|
|
@@ -594,8 +594,9 @@ const syncDocumentFromFileSchema = {
|
|
|
594
594
|
documentId: z.string().describe('RAG document ID to (re)create from the file'),
|
|
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
|
+
excludePattern: z.union([z.string(), z.array(z.string())]).optional().describe('Regular expression(s) whose matches are stripped before indexing. Applied to the file (or to `content`) first, so hash, byte count and chunking all describe what was actually indexed. Compiled with the dotAll flag, so a pattern may span lines to drop a marked block. An invalid expression fails the call rather than indexing the whole file.'),
|
|
597
598
|
entityNames: z.array(z.string()).optional().describe('Optional entities to explicitly link'),
|
|
598
|
-
chunkParams: z.record(z.any()).optional().describe('Optional chunking parameters { maxTokens
|
|
599
|
+
chunkParams: z.record(z.any()).optional().describe('Optional chunking parameters { maxTokens }. overlap: omit or 0 only (rejected otherwise since v5.0.0)'),
|
|
599
600
|
};
|
|
600
601
|
export const syncDocumentFromFileTool = {
|
|
601
602
|
capability: syncDocumentFromFileCapability,
|
package/docs/UPDATING.md
CHANGED
|
@@ -108,6 +108,84 @@ 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.1.0 (schema v14, unchanged): destructive-replace reporting + `excludePattern`
|
|
112
|
+
|
|
113
|
+
**What changes on upgrade**: nothing you have to do. No migration, no schema
|
|
114
|
+
change, no re-embedding. Both changes are additive — existing calls keep their
|
|
115
|
+
arguments and keep working, and the new response fields are extra keys.
|
|
116
|
+
|
|
117
|
+
**`storeDocument` now says what it destroyed.** It has always deleted the
|
|
118
|
+
previous document's chunks, vectors and entity links before writing, but the
|
|
119
|
+
response was `{ id, stored: true }`, so a caller replacing a document could not
|
|
120
|
+
tell from the return value that anything was removed. It now returns
|
|
121
|
+
`{ id, stored, replaced, deletedChunks }`, matching what `syncDocumentFromFile`
|
|
122
|
+
already reported. `replaced` is decided by the document row, not the chunk
|
|
123
|
+
count — a document that was stored but never chunked still gets overwritten,
|
|
124
|
+
and reporting that as a fresh write would be wrong.
|
|
125
|
+
|
|
126
|
+
**`syncDocumentFromFile` accepts `excludePattern`** (string or array of
|
|
127
|
+
strings): regions matching these regular expressions are stripped before
|
|
128
|
+
indexing. Previously the only way to leave part of a file out was to read it
|
|
129
|
+
yourself and pass the whole edited text through `content`, which defeats the
|
|
130
|
+
point of a tool that reads server-side to keep content off the model context.
|
|
131
|
+
|
|
132
|
+
Three properties worth knowing:
|
|
133
|
+
|
|
134
|
+
1. **The exclusion happens first**, before hashing and chunking, so
|
|
135
|
+
`content_hash`, the reported `bytes` and the chunk boundaries all describe
|
|
136
|
+
what was actually indexed. Changing only the pattern therefore invalidates
|
|
137
|
+
the dedup gate and re-indexes; it does not silently return `unchanged`.
|
|
138
|
+
2. **Patterns are compiled with the dotAll flag**, so one pattern can span
|
|
139
|
+
lines to drop a marked block (`<!-- SECRET -->[\s\S]*?<!-- /SECRET -->`).
|
|
140
|
+
JavaScript has no inline `(?s)`, so without this every such pattern would
|
|
141
|
+
quietly match nothing.
|
|
142
|
+
3. **An invalid expression fails the call.** Degrading to "no exclusion" would
|
|
143
|
+
index the whole file while the caller believes it was filtered, and an index
|
|
144
|
+
is a disclosure path — a failed sync is the safer error.
|
|
145
|
+
|
|
146
|
+
## v5.0.0 (schema v14): chunker c1 + vector reuse
|
|
147
|
+
|
|
148
|
+
**Breaking**: `chunkParams.overlap` is rejected on BOTH public paths
|
|
149
|
+
(`syncDocumentFromFile.chunkParams` and `chunkDocument`) unless omitted or
|
|
150
|
+
exactly 0 — chunker c1 has no overlap. `maxTokens` must be a positive integer.
|
|
151
|
+
Validation runs before the dedup gate, so invalid params fail even on
|
|
152
|
+
unchanged content.
|
|
153
|
+
|
|
154
|
+
**What changes on upgrade**: nothing, immediately. v14 is schema-only —
|
|
155
|
+
`documents.chunking_signature` is added with DEFAULT `legacy-unknown` and no
|
|
156
|
+
data row changes, so there is no coverage cliff and no re-embedding storm.
|
|
157
|
+
A document transitions to c1 only when its CONTENT changes at sync time
|
|
158
|
+
(signature mismatch alone is an observed state, not a trigger). The first
|
|
159
|
+
sync of an edited document pays a cold transition (old BPE chunk texts rarely
|
|
160
|
+
match c1 boundaries); every later sync reuses vectors for unchanged text.
|
|
161
|
+
|
|
162
|
+
**Observability**: `getKnowledgeGraphStats().chunking = { current, legacy,
|
|
163
|
+
unknown, default_signature }` — mutually exclusive, sums to `documents`. The
|
|
164
|
+
framework's /start Step 5a reads this response.
|
|
165
|
+
|
|
166
|
+
**Rollback caveat (v14)**: `rollbackMigration` drops the column and returns
|
|
167
|
+
`semanticRollback: false` plus a warning — chunk boundaries produced by c1
|
|
168
|
+
are NOT restored (they read fine on v13 code). Data restore path = the
|
|
169
|
+
pre-migration backup snapshot.
|
|
170
|
+
|
|
171
|
+
**Manual links**: full replacement re-derives `chunk_entities`; a link made
|
|
172
|
+
via `linkEntitiesToDocument` that is not reproducible from body literals or
|
|
173
|
+
`entityNames` is not preserved (true before v5 too). New in v5: primary
|
|
174
|
+
entity names are also linked by document-level occurrence ranges, so a name
|
|
175
|
+
cut across a chunk boundary still links to the intersecting chunks
|
|
176
|
+
(overlap used to absorb this; c1 has none).
|
|
177
|
+
|
|
178
|
+
**Fleet prerequisite before releasing/upgrading**: audit every deployment's
|
|
179
|
+
`schema_migrations` for occupied slots `>= 14` — pending migrations are
|
|
180
|
+
selected by MAX(version) arithmetic, so an experimental slot silently skips
|
|
181
|
+
the real v14 (this is exactly how code-v8 never ran in production).
|
|
182
|
+
|
|
183
|
+
**Diagnostic env (v5)**: `RAG_MEMORY_SEARCH_SUMMARIES=off` disables the per-result
|
|
184
|
+
sentence-similarity summaries in `hybridSearch` (which embed every sentence of every
|
|
185
|
+
candidate — 100+ inferences, 90-120s cold per search, measured). Off = preview-slice
|
|
186
|
+
summaries, `relevance_score` 0, ranking rests on vector similarity + boosts. Default
|
|
187
|
+
unchanged. The 3-arm release harness sets this uniformly across all arms.
|
|
188
|
+
|
|
111
189
|
## v3.6 breaking response changes
|
|
112
190
|
|
|
113
191
|
1. `hybridSearch` returns an envelope: `{results, search_mode, model_state,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rag-memory-epf-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=24"
|
|
6
6
|
},
|
|
@@ -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 && node test/document-return-contracts.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
|
},
|