rag-memory-epf-mcp 5.3.0 → 6.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 +24 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +196 -72
- package/dist/src/backup/preflight.js +39 -9
- package/dist/src/chunkText.d.ts +10 -0
- package/dist/src/chunkText.js +104 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -153,6 +153,30 @@ storeDocument(id, content, metadata)
|
|
|
153
153
|
|
|
154
154
|
## Changelog
|
|
155
155
|
|
|
156
|
+
### v6.0.0
|
|
157
|
+
|
|
158
|
+
- **Breaking — observation-derived alias links are now gated.** `autoLinkEntities` used to take every
|
|
159
|
+
`stem.ext` token appearing anywhere in an entity's observations and link that entity to every chunk
|
|
160
|
+
containing the token as a substring. Measured on a live 2,891-chunk / 604-entity corpus: 65,388 of
|
|
161
|
+
66,841 `chunk_entities` rows (97.8%) existed only because of such a hit, and one token — `agents.md`,
|
|
162
|
+
held by 88 entities — accounted for 39,248 of the 80,096 distinct (chunk, entity) pairs any alias
|
|
163
|
+
could reach (49.0%). The regex also accepted things that are not filenames at all (`v3.3`,
|
|
164
|
+
`gpt-5.6`, `1.7mb`, `github.com`, `os.path`).
|
|
165
|
+
A token now has to (a) look like a filename — extension whitelist, stem ≥ 3 chars, non-numeric
|
|
166
|
+
stem — and (b) be held by at most 3 entities, and it must match on token boundaries so `foo.py` no
|
|
167
|
+
longer matches inside `notfoo.pyc`. Effect on the same corpus: alias links 100% → 4.0%. Filtering by
|
|
168
|
+
extension alone leaves 92.3%, so the owner cap is what does the work. The cap is a judgement, not a
|
|
169
|
+
discovered boundary — the sweep is smooth (`owners<=1` 1.6% … `<=10` 19.9%) — and it is a cap, not a
|
|
170
|
+
ban: a filename named by one to three records still links, which is what the alias path was for.
|
|
171
|
+
- **What this means for existing databases.** Nothing is rewritten on upgrade: rows already in
|
|
172
|
+
`chunk_entities` stay, and new ingests simply link far less. Links have always been a function of
|
|
173
|
+
when a document was last processed (nothing re-links older documents when entities are added), and
|
|
174
|
+
`chunk_entities` has no provenance column, so old alias rows cannot be told apart from name matches
|
|
175
|
+
after the fact. If you want the old noise gone you have to clean it offline, before or after
|
|
176
|
+
upgrading. Callers that assumed dense `chunk_entities` coverage will see sparser graphs.
|
|
177
|
+
- Regression: `test/alias-link-gate.test.mjs` (registered in `verify:engine`), verified to fail
|
|
178
|
+
without the gate.
|
|
179
|
+
|
|
156
180
|
### v5.3.0
|
|
157
181
|
- (Published first as `5.3.0-rc.1` on the `next` dist-tag; promoted to `latest` after a canary run of the published artifact against a real project database: default call carries no `graph_boost` and equals explicit `useGraph:false`, the known-item probe from the 2026-08-17 measurement returns the correct gotcha at rank 1, opt-in `true` still exposes `graph_boost`, schema/MCP defaults read `false`.)
|
|
158
182
|
- **Behavior change — `hybridSearch` graph re-ranking is now opt-in** (`useGraph` default `true` → `false`; tool schema, MCP exposure and the manager signature agree). Omitting the argument now means "no graph re-ranking" — a behavior change for callers that relied on the old default, hence a release-candidate first (`next` dist-tag, fleet canary) before stable. Measured 2026-08-17 on three real corpora (self-retrieval, usable samples 120/117/120, summaries off): with the additive graph boost on, the known-item chunk got worse in 46/49/52 samples and better in 3/2/0 (sign test p < 7e-11 per corpus), 106 targets left the top-10 entirely; reproduced on the summaries-on product path (HAL, 20 paired samples: hit@1 10→7, hit@5 18→13). Mechanism: only query-matched/connected entities score, but the per-entity boost saturates the cap quickly, so heavily-linked chunks can outrank the exact chunk even at `vector_similarity` 0. This is a harm-reduced default, not a validated graph improvement: the boost path is unchanged for `useGraph: true` (legacy/experimental re-ranker for back-compat and evaluation; the graph does not generate candidates — for relationship exploration use `openNodes` → `getNeighbors`). Regression lock: `test/search-graph-default.test.mjs`.
|
package/dist/index.d.ts
CHANGED
|
@@ -279,6 +279,8 @@ export declare class RAGKnowledgeGraphManager {
|
|
|
279
279
|
errors?: string[];
|
|
280
280
|
}>;
|
|
281
281
|
private hasCJK;
|
|
282
|
+
private static readonly ALIAS_FILE_EXT;
|
|
283
|
+
private looksLikeFilename;
|
|
282
284
|
private buildEntityRangeFinder;
|
|
283
285
|
private buildEntityMatcher;
|
|
284
286
|
private autoLinkEntities;
|
|
@@ -372,6 +374,36 @@ export declare class RAGKnowledgeGraphManager {
|
|
|
372
374
|
to: number;
|
|
373
375
|
}>;
|
|
374
376
|
}>;
|
|
377
|
+
/**
|
|
378
|
+
* Diagnostic seam for the graph re-ranker (evaluation change graph-role-evaluation, R2).
|
|
379
|
+
* Returns the seed entities (query-vector matched, similarity > 0.4, top-10 per variant) and the
|
|
380
|
+
* 1-hop connected entities exactly as hybridSearch(useGraph:true) computes them, plus edge detail
|
|
381
|
+
* that hybridSearch itself does not use (edge id, type, direction, confidence). It never generates
|
|
382
|
+
* candidates and never changes ranking; hybridSearch consumes only the name sets.
|
|
383
|
+
* `opts.chunkVectorDegraded` lets a caller hand over a decision it has already made; omit it and
|
|
384
|
+
* the seam derives eligibility itself.
|
|
385
|
+
*/
|
|
386
|
+
explainGraphContext(query: string, queryVariants?: string[], opts?: {
|
|
387
|
+
chunkVectorDegraded?: boolean;
|
|
388
|
+
}): Promise<{
|
|
389
|
+
status: 'vector' | 'entity-text-fallback' | 'chunk-vector-disabled' | 'error';
|
|
390
|
+
query_variants: string[];
|
|
391
|
+
seeds: Array<{
|
|
392
|
+
entity_id: string;
|
|
393
|
+
name: string;
|
|
394
|
+
similarity: number;
|
|
395
|
+
}>;
|
|
396
|
+
connected: Array<{
|
|
397
|
+
entity_id: string;
|
|
398
|
+
name: string;
|
|
399
|
+
via_seed_id: string;
|
|
400
|
+
via_seed_name: string;
|
|
401
|
+
edge_id: string;
|
|
402
|
+
relation_type: string;
|
|
403
|
+
direction: 'out' | 'in';
|
|
404
|
+
confidence: number | null;
|
|
405
|
+
}>;
|
|
406
|
+
}>;
|
|
375
407
|
hybridSearch(query: string, limit?: number, useGraph?: boolean): Promise<{
|
|
376
408
|
results: EnhancedSearchResult[];
|
|
377
409
|
search_mode: 'hybrid' | 'hybrid-partial' | 'fts-only';
|
package/dist/index.js
CHANGED
|
@@ -2241,6 +2241,32 @@ export class RAGKnowledgeGraphManager {
|
|
|
2241
2241
|
hasCJK(text) {
|
|
2242
2242
|
return /[\u3000-\u9fff\uac00-\ud7af\uff00-\uffef]/.test(text);
|
|
2243
2243
|
}
|
|
2244
|
+
// The alias regex in autoLinkEntities matches anything shaped like "stem.ext", which
|
|
2245
|
+
// includes version strings ("v3.3", "gpt-5.6"), measurements ("1.7mb", "0.465") and
|
|
2246
|
+
// domains/module paths ("github.com", "os.path"). Those are not filenames and linking
|
|
2247
|
+
// on them is pure noise. Whitelist the extensions we actually ship and store.
|
|
2248
|
+
static ALIAS_FILE_EXT = new Set([
|
|
2249
|
+
'md', 'py', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'json',
|
|
2250
|
+
'sh', 'bash', 'zsh', 'toml', 'yaml', 'yml', 'ini', 'cfg', 'conf',
|
|
2251
|
+
'txt', 'log', 'csv', 'tsv', 'sql', 'db', 'zip', 'gz', 'tar',
|
|
2252
|
+
'css', 'scss', 'html', 'htm', 'svg', 'png', 'jpg', 'jpeg', 'gif',
|
|
2253
|
+
'pdf', 'docx', 'pptx', 'xlsx', 'hwp', 'hwpx', 'lock', 'bak',
|
|
2254
|
+
]);
|
|
2255
|
+
// Deliberately absent: any 5+ character extension (jsonl, ipynb, scss is 4 so it stays).
|
|
2256
|
+
// The extractor regex is `\w{1,4}`, so a longer extension never reaches this set — listing
|
|
2257
|
+
// one would be a dead entry that reads as support. Extend the regex first if that changes.
|
|
2258
|
+
looksLikeFilename(token) {
|
|
2259
|
+
const i = token.lastIndexOf('.');
|
|
2260
|
+
if (i < 1)
|
|
2261
|
+
return false;
|
|
2262
|
+
const stem = token.slice(0, i);
|
|
2263
|
+
const ext = token.slice(i + 1);
|
|
2264
|
+
if (stem.length < 3)
|
|
2265
|
+
return false; // "d.ts" is a fragment, not a file
|
|
2266
|
+
if (/^\d+$/.test(stem))
|
|
2267
|
+
return false; // "2026.md" style numeric stems
|
|
2268
|
+
return RAGKnowledgeGraphManager.ALIAS_FILE_EXT.has(ext);
|
|
2269
|
+
}
|
|
2244
2270
|
// spec §5.4 (r7-2·r8-1·r9): primary name 의 본문 occurrence range [sCp, eCp).
|
|
2245
2271
|
// 의미 = buildEntityMatcher 와 동일 (CJK substring / Latin word-boundary) — 여기서
|
|
2246
2272
|
// 어긋나면 'Data' 가 'Database' 에 새로 링크되는 식으로 의미가 확장된다.
|
|
@@ -2342,6 +2368,51 @@ export class RAGKnowledgeGraphManager {
|
|
|
2342
2368
|
// Minimum name length: 2 for CJK (e.g. "할랄"), 4 for Latin (avoid "API", "Bug")
|
|
2343
2369
|
const MIN_LEN_CJK = 2;
|
|
2344
2370
|
const MIN_LEN_LATIN = 4;
|
|
2371
|
+
// Observation-derived aliases are only useful when the token identifies few entities.
|
|
2372
|
+
// Measured on a 2,891-chunk / 604-entity corpus (2026-08-22). Denominators matter here,
|
|
2373
|
+
// so both are stated: 66,841 rows in chunk_entities, of which 65,388 (97.8%) exist only
|
|
2374
|
+
// because of an alias hit; counting distinct (chunk, entity) pairs reachable by any alias
|
|
2375
|
+
// gives 80,096. Against that 80,096, "agents.md" alone accounts for 39,248 (49.0%), and
|
|
2376
|
+
// 35,099 (43.8%) have no other alias reason at all. It is held by 88 entities. A filename
|
|
2377
|
+
// that dozens of entities mention is a stopword, not an identifier.
|
|
2378
|
+
//
|
|
2379
|
+
// The owner cap is what does the work: the extension whitelist alone still leaves 92.3%
|
|
2380
|
+
// of alias links. But the cap value is a judgement call, not a discovered boundary — the
|
|
2381
|
+
// sweep is smooth, with no natural knee (share of the 80,096 that survives):
|
|
2382
|
+
// owners<=1 1.6% · <=2 3.7% · <=3 5.2% · <=4 7.0% · <=5 8.3% · <=8 14.7% · <=10 19.9%
|
|
2383
|
+
// 3 was chosen to keep the intended behaviour (a file named by one or two records, plus
|
|
2384
|
+
// some slack) while cutting the stopword tail. Raising it is cheap and reversible.
|
|
2385
|
+
//
|
|
2386
|
+
// A chunk-frequency cap was measured (a further 1.4pp) and rejected on COST: it needs a
|
|
2387
|
+
// full-corpus scan on every ingest. Note the honest caveat — it was first rejected for
|
|
2388
|
+
// depending on ingest order, but this owner cap has that same property, and so does the
|
|
2389
|
+
// engine as a whole: autoLinkEntities only ever sees the entities that exist at ingest
|
|
2390
|
+
// time, and nothing re-links older documents when entities are added (see
|
|
2391
|
+
// createEntities / addObservations — neither calls this). Links are a function of when a
|
|
2392
|
+
// document was last processed. That predates this gate; it is not introduced by it.
|
|
2393
|
+
const MAX_ALIAS_OWNERS = 3;
|
|
2394
|
+
const aliasOwners = new Map();
|
|
2395
|
+
for (const e of entities) {
|
|
2396
|
+
if (!e.observations)
|
|
2397
|
+
continue;
|
|
2398
|
+
let obs;
|
|
2399
|
+
try {
|
|
2400
|
+
obs = JSON.parse(e.observations);
|
|
2401
|
+
}
|
|
2402
|
+
catch {
|
|
2403
|
+
continue;
|
|
2404
|
+
}
|
|
2405
|
+
const seen = new Set();
|
|
2406
|
+
for (const ob of obs) {
|
|
2407
|
+
const pm = String(ob).match(/[\w\-]+\.\w{1,4}\b/g);
|
|
2408
|
+
if (pm)
|
|
2409
|
+
for (const p of pm)
|
|
2410
|
+
if (p.length >= 4)
|
|
2411
|
+
seen.add(p.toLowerCase());
|
|
2412
|
+
}
|
|
2413
|
+
for (const t of seen)
|
|
2414
|
+
aliasOwners.set(t, (aliasOwners.get(t) ?? 0) + 1);
|
|
2415
|
+
}
|
|
2345
2416
|
const insertStmt = this.db.prepare(`
|
|
2346
2417
|
INSERT OR IGNORE INTO chunk_entities (chunk_rowid, entity_id) VALUES (?, ?)
|
|
2347
2418
|
`);
|
|
@@ -2351,7 +2422,9 @@ export class RAGKnowledgeGraphManager {
|
|
|
2351
2422
|
if (entity.name.length < minLen)
|
|
2352
2423
|
continue;
|
|
2353
2424
|
const nameMatcher = this.buildEntityMatcher(entity.name);
|
|
2354
|
-
// Also collect observation-derived aliases (short keywords from observations)
|
|
2425
|
+
// Also collect observation-derived aliases (short keywords from observations).
|
|
2426
|
+
// Gated: the token must look like a filename AND identify at most MAX_ALIAS_OWNERS
|
|
2427
|
+
// entities. Ungated, "agents.md" linked 88 entities to every chunk that mentioned it.
|
|
2355
2428
|
const aliases = [];
|
|
2356
2429
|
if (entity.observations) {
|
|
2357
2430
|
let obs;
|
|
@@ -2366,9 +2439,30 @@ export class RAGKnowledgeGraphManager {
|
|
|
2366
2439
|
const pathMatch = ob.match(/[\w\-]+\.\w{1,4}\b/g);
|
|
2367
2440
|
if (pathMatch) {
|
|
2368
2441
|
for (const p of pathMatch) {
|
|
2369
|
-
if (p.length
|
|
2370
|
-
|
|
2371
|
-
|
|
2442
|
+
if (p.length < 4)
|
|
2443
|
+
continue;
|
|
2444
|
+
const tok = p.toLowerCase();
|
|
2445
|
+
if (!this.looksLikeFilename(tok))
|
|
2446
|
+
continue; // "v3.3", "gpt-5.6", "0.465"
|
|
2447
|
+
if ((aliasOwners.get(tok) ?? 0) > MAX_ALIAS_OWNERS)
|
|
2448
|
+
continue; // shared = identifies nothing
|
|
2449
|
+
// Bare substring matching links "foo.py" to a chunk saying "notfoo.pyc".
|
|
2450
|
+
// Require the token to stand alone: no filename character on either side.
|
|
2451
|
+
const boundary = /[\w\-.]/;
|
|
2452
|
+
aliases.push((text) => {
|
|
2453
|
+
const hay = text.toLowerCase();
|
|
2454
|
+
let from = 0;
|
|
2455
|
+
for (;;) {
|
|
2456
|
+
const i = hay.indexOf(tok, from);
|
|
2457
|
+
if (i < 0)
|
|
2458
|
+
return false;
|
|
2459
|
+
const before = i === 0 ? '' : hay[i - 1];
|
|
2460
|
+
const after = hay[i + tok.length] ?? '';
|
|
2461
|
+
if (!boundary.test(before) && !boundary.test(after))
|
|
2462
|
+
return true;
|
|
2463
|
+
from = i + 1;
|
|
2464
|
+
}
|
|
2465
|
+
});
|
|
2372
2466
|
}
|
|
2373
2467
|
}
|
|
2374
2468
|
}
|
|
@@ -2923,6 +3017,97 @@ export class RAGKnowledgeGraphManager {
|
|
|
2923
3017
|
this.coordinator?.kick();
|
|
2924
3018
|
return { imported, skipped, observation_order_remap: remapReport };
|
|
2925
3019
|
}
|
|
3020
|
+
/**
|
|
3021
|
+
* Diagnostic seam for the graph re-ranker (evaluation change graph-role-evaluation, R2).
|
|
3022
|
+
* Returns the seed entities (query-vector matched, similarity > 0.4, top-10 per variant) and the
|
|
3023
|
+
* 1-hop connected entities exactly as hybridSearch(useGraph:true) computes them, plus edge detail
|
|
3024
|
+
* that hybridSearch itself does not use (edge id, type, direction, confidence). It never generates
|
|
3025
|
+
* candidates and never changes ranking; hybridSearch consumes only the name sets.
|
|
3026
|
+
* `opts.chunkVectorDegraded` lets a caller hand over a decision it has already made; omit it and
|
|
3027
|
+
* the seam derives eligibility itself.
|
|
3028
|
+
*/
|
|
3029
|
+
async explainGraphContext(query, queryVariants, opts) {
|
|
3030
|
+
if (!this.db)
|
|
3031
|
+
throw new Error('Database not initialized');
|
|
3032
|
+
const variants = queryVariants ?? this.buildCrossLingualVariants(query);
|
|
3033
|
+
const empty = { query_variants: variants, seeds: [], connected: [] };
|
|
3034
|
+
// The caller's latched decision wins (review finding I2). hybridSearch decides chunk-vector
|
|
3035
|
+
// degradation once, before the chunk-embedding awaits, and passes that value down; re-deriving
|
|
3036
|
+
// it here would let an eligibility flip during those awaits give the seam a different answer
|
|
3037
|
+
// than the ranking path already acted on — the pre-extraction code read it once, so this keeps
|
|
3038
|
+
// behaviour identical. A standalone caller passes nothing and gets the live derivation.
|
|
3039
|
+
const chunkVectorDegraded = opts?.chunkVectorDegraded ?? !(this.coordinator?.eligible ?? false);
|
|
3040
|
+
if (chunkVectorDegraded)
|
|
3041
|
+
return { status: 'chunk-vector-disabled', ...empty };
|
|
3042
|
+
try {
|
|
3043
|
+
const searchEntities = (embedding) => this.db.prepare(`
|
|
3044
|
+
SELECT em.entity_id, e.name, ee.distance
|
|
3045
|
+
FROM entity_embeddings ee
|
|
3046
|
+
JOIN entity_embedding_metadata em ON ee.rowid = em.rowid
|
|
3047
|
+
JOIN entities e ON e.id = em.entity_id
|
|
3048
|
+
WHERE ee.embedding MATCH ? AND k = 10
|
|
3049
|
+
ORDER BY ee.distance
|
|
3050
|
+
`).all(Buffer.from(embedding.buffer));
|
|
3051
|
+
const entityMap = new Map();
|
|
3052
|
+
for (const variant of variants) {
|
|
3053
|
+
const embedding = await this.generateEmbedding(variant, 1024, true);
|
|
3054
|
+
for (const e of searchEntities(embedding)) {
|
|
3055
|
+
const existing = entityMap.get(e.entity_id);
|
|
3056
|
+
if (!existing || e.distance < existing.distance)
|
|
3057
|
+
entityMap.set(e.entity_id, e);
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
const similar = Array.from(entityMap.values()).sort((a, b) => a.distance - b.distance || (a.entity_id < b.entity_id ? -1 : 1));
|
|
3061
|
+
const seeds = [];
|
|
3062
|
+
const connected = [];
|
|
3063
|
+
const edgeStmt = this.db.prepare(`
|
|
3064
|
+
SELECT r.id AS edge_id, r.relationType AS relation_type, r.confidence,
|
|
3065
|
+
CASE WHEN r.source_entity = ? THEN e2.id ELSE e1.id END AS entity_id,
|
|
3066
|
+
CASE WHEN r.source_entity = ? THEN e2.name ELSE e1.name END AS name,
|
|
3067
|
+
CASE WHEN r.source_entity = ? THEN 'out' ELSE 'in' END AS direction
|
|
3068
|
+
FROM relationships r
|
|
3069
|
+
JOIN entities e1 ON e1.id = r.source_entity
|
|
3070
|
+
JOIN entities e2 ON e2.id = r.target_entity
|
|
3071
|
+
WHERE r.source_entity = ? OR r.target_entity = ?
|
|
3072
|
+
ORDER BY r.id`);
|
|
3073
|
+
for (const entity of similar) {
|
|
3074
|
+
const similarity = Math.max(0, 1 - entity.distance / 2);
|
|
3075
|
+
if (similarity > 0.4) {
|
|
3076
|
+
seeds.push({ entity_id: entity.entity_id, name: entity.name, similarity });
|
|
3077
|
+
for (const row of edgeStmt.all(entity.entity_id, entity.entity_id, entity.entity_id, entity.entity_id, entity.entity_id)) {
|
|
3078
|
+
connected.push({ entity_id: row.entity_id, name: row.name, via_seed_id: entity.entity_id, via_seed_name: entity.name,
|
|
3079
|
+
edge_id: row.edge_id, relation_type: row.relation_type, direction: row.direction,
|
|
3080
|
+
confidence: row.confidence === null || row.confidence === undefined ? null : Number(row.confidence) });
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
return { status: 'vector', query_variants: variants, seeds, connected };
|
|
3085
|
+
}
|
|
3086
|
+
catch (error) {
|
|
3087
|
+
console.error('⚠️ Entity vector search for graph enhancement failed:', error);
|
|
3088
|
+
// Fallback: text-based matching (original behavior) — same SQL as before extraction.
|
|
3089
|
+
const connected = [];
|
|
3090
|
+
const queryEntities = this.extractTermsFromText(query);
|
|
3091
|
+
for (const entity of queryEntities) {
|
|
3092
|
+
const rows = this.db.prepare(`
|
|
3093
|
+
SELECT DISTINCT
|
|
3094
|
+
CASE WHEN r.source_entity = e1.id THEN e2.name ELSE e1.name END as connected_name,
|
|
3095
|
+
CASE WHEN r.source_entity = e1.id THEN e2.id ELSE e1.id END as connected_id,
|
|
3096
|
+
r.id AS edge_id, r.relationType AS relation_type, r.confidence,
|
|
3097
|
+
CASE WHEN r.source_entity = e1.id THEN 'out' ELSE 'in' END AS direction
|
|
3098
|
+
FROM entities e1
|
|
3099
|
+
JOIN relationships r ON (r.source_entity = e1.id OR r.target_entity = e1.id)
|
|
3100
|
+
JOIN entities e2 ON (e2.id = r.source_entity OR e2.id = r.target_entity)
|
|
3101
|
+
WHERE e1.name = ? AND e2.name != ?
|
|
3102
|
+
ORDER BY r.id`).all(entity, entity);
|
|
3103
|
+
for (const row of rows)
|
|
3104
|
+
connected.push({ entity_id: row.connected_id, name: row.connected_name, via_seed_id: '', via_seed_name: entity,
|
|
3105
|
+
edge_id: row.edge_id, relation_type: row.relation_type, direction: row.direction,
|
|
3106
|
+
confidence: row.confidence === null || row.confidence === undefined ? null : Number(row.confidence) });
|
|
3107
|
+
}
|
|
3108
|
+
return { status: 'entity-text-fallback', query_variants: variants, seeds: [], connected };
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
2926
3111
|
// v5.3.0: the graph re-ranker is OPT-IN (harm-reduced default, not a validated improvement).
|
|
2927
3112
|
// Measured 2026-08-17 on three real corpora (self-retrieval, usable samples 120/117/120,
|
|
2928
3113
|
// summaries off): with the additive graph boost on, the known-item chunk got WORSE in
|
|
@@ -3102,77 +3287,16 @@ export class RAGKnowledgeGraphManager {
|
|
|
3102
3287
|
...(ftsUnsearchable ? { warning: 'query has no searchable terms for FTS' } : {}),
|
|
3103
3288
|
};
|
|
3104
3289
|
}
|
|
3105
|
-
// Get entity information for graph enhancement via
|
|
3290
|
+
// Get entity information for graph enhancement — via the diagnostic seam (evaluation change
|
|
3291
|
+
// graph-role-evaluation R2). Same SQL, same threshold, same fallback; hybridSearch consumes only names.
|
|
3106
3292
|
let connectedEntities = new Set();
|
|
3107
3293
|
let queryMatchedEntities = new Set();
|
|
3108
3294
|
if (useGraph && !vectorDegraded) {
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
em.entity_id,
|
|
3115
|
-
e.name,
|
|
3116
|
-
ee.distance
|
|
3117
|
-
FROM entity_embeddings ee
|
|
3118
|
-
JOIN entity_embedding_metadata em ON ee.rowid = em.rowid
|
|
3119
|
-
JOIN entities e ON e.id = em.entity_id
|
|
3120
|
-
WHERE ee.embedding MATCH ?
|
|
3121
|
-
AND k = 10
|
|
3122
|
-
ORDER BY ee.distance
|
|
3123
|
-
`).all(Buffer.from(embedding.buffer));
|
|
3124
|
-
};
|
|
3125
|
-
// Merge all query variant entity results
|
|
3126
|
-
const entityMap = new Map();
|
|
3127
|
-
for (const variant of queryVariants) {
|
|
3128
|
-
const embedding = await this.generateEmbedding(variant, 1024, true);
|
|
3129
|
-
for (const e of searchEntities(embedding)) {
|
|
3130
|
-
const existing = entityMap.get(e.entity_id);
|
|
3131
|
-
if (!existing || e.distance < existing.distance) {
|
|
3132
|
-
entityMap.set(e.entity_id, e);
|
|
3133
|
-
}
|
|
3134
|
-
}
|
|
3135
|
-
}
|
|
3136
|
-
const similarEntities = Array.from(entityMap.values()).sort((a, b) => a.distance - b.distance);
|
|
3137
|
-
for (const entity of similarEntities) {
|
|
3138
|
-
const similarity = Math.max(0, 1 - entity.distance / 2);
|
|
3139
|
-
if (similarity > 0.4) {
|
|
3140
|
-
queryMatchedEntities.add(entity.name);
|
|
3141
|
-
// Get connected entities via relationships
|
|
3142
|
-
const connected = this.db.prepare(`
|
|
3143
|
-
SELECT DISTINCT
|
|
3144
|
-
CASE
|
|
3145
|
-
WHEN r.source_entity = ? THEN e2.name
|
|
3146
|
-
ELSE e1.name
|
|
3147
|
-
END as connected_name
|
|
3148
|
-
FROM relationships r
|
|
3149
|
-
JOIN entities e1 ON e1.id = r.source_entity
|
|
3150
|
-
JOIN entities e2 ON e2.id = r.target_entity
|
|
3151
|
-
WHERE r.source_entity = ? OR r.target_entity = ?
|
|
3152
|
-
`).all(entity.entity_id, entity.entity_id, entity.entity_id);
|
|
3153
|
-
connected.forEach((row) => connectedEntities.add(row.connected_name));
|
|
3154
|
-
}
|
|
3155
|
-
}
|
|
3156
|
-
}
|
|
3157
|
-
catch (error) {
|
|
3158
|
-
console.error('⚠️ Entity vector search for graph enhancement failed:', error);
|
|
3159
|
-
// Fallback: text-based matching (original behavior)
|
|
3160
|
-
const queryEntities = this.extractTermsFromText(query);
|
|
3161
|
-
for (const entity of queryEntities) {
|
|
3162
|
-
const connected = this.db.prepare(`
|
|
3163
|
-
SELECT DISTINCT
|
|
3164
|
-
CASE
|
|
3165
|
-
WHEN r.source_entity = e1.id THEN e2.name
|
|
3166
|
-
ELSE e1.name
|
|
3167
|
-
END as connected_name
|
|
3168
|
-
FROM entities e1
|
|
3169
|
-
JOIN relationships r ON (r.source_entity = e1.id OR r.target_entity = e1.id)
|
|
3170
|
-
JOIN entities e2 ON (e2.id = r.source_entity OR e2.id = r.target_entity)
|
|
3171
|
-
WHERE e1.name = ? AND e2.name != ?
|
|
3172
|
-
`).all(entity, entity);
|
|
3173
|
-
connected.forEach((row) => connectedEntities.add(row.connected_name));
|
|
3174
|
-
}
|
|
3175
|
-
}
|
|
3295
|
+
const ctx = await this.explainGraphContext(query, queryVariants, { chunkVectorDegraded: vectorDegraded });
|
|
3296
|
+
for (const s of ctx.seeds)
|
|
3297
|
+
queryMatchedEntities.add(s.name);
|
|
3298
|
+
for (const c of ctx.connected)
|
|
3299
|
+
connectedEntities.add(c.name);
|
|
3176
3300
|
}
|
|
3177
3301
|
// Process results with semantic summaries
|
|
3178
3302
|
const enhancedResults = [];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, openSync, readSync, closeSync,
|
|
1
|
+
import { existsSync, openSync, readSync, closeSync, copyFileSync, unlinkSync, constants } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import Database from 'better-sqlite3';
|
|
4
4
|
// 이 파일이 지켜야 하는 것은 두 줄이다:
|
|
@@ -101,21 +101,51 @@ function verifyRecoveryPoint(path) {
|
|
|
101
101
|
v.close();
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
-
// 슬롯 게시. `
|
|
105
|
-
// (rename 은 조용히 덮어쓴다)
|
|
104
|
+
// 슬롯 게시. **`COPYFILE_EXCL` 복사**로 발행한다 — 목적지가 있으면 EEXIST 로 실패하므로
|
|
105
|
+
// no-clobber 의미는 `link()` 와 같고(rename 은 조용히 덮어쓴다), 경쟁 프로세스가 있어도
|
|
106
|
+
// 복구점을 잃지 않는다.
|
|
107
|
+
//
|
|
108
|
+
// **왜 `link()` 가 아닌가** (2026-08-22, 필드 보고): Google Drive File Stream(Windows `G:\`)은
|
|
109
|
+
// 하드링크를 지원하지 않는다. 실측 = 그 FS 에서 `ln` 이 "Invalid request code" 로 실패하고
|
|
110
|
+
// node 의 `linkSync` 는 같은 조건에서 `EISDIR`(errno -4068)로 표면화한다. `EEXIST` 가 아니므로
|
|
111
|
+
// 위 루프가 그대로 throw 했고, 이 함수는 `server.connect()` **전에** 도는 fail-closed 경로라
|
|
112
|
+
// 사용자에게는 원인 없는 "MCP 연결 실패"로만 보였다. 대기 마이그레이션이 없는 동안에는
|
|
113
|
+
// 이 코드가 아예 안 돌기 때문에 **배포 시점이 아니라 스키마 범프 시점에** 터진다.
|
|
114
|
+
// 🔴 이 파일 위쪽 주석이 *"이 프로젝트군은 non-git 환경(Google Drive 폴더)에 배포된다"* 고
|
|
115
|
+
// 적어 놓고 그 FS 가 없는 syscall 로 발행하고 있었다.
|
|
116
|
+
//
|
|
117
|
+
// **FS 별 분기를 두지 않는다.** 하드링크가 되는 곳에서만 link 를 쓰는 폴백 구조는 드문 경로가
|
|
118
|
+
// 영영 안 밟혀서, 정작 필요한 날 처음 실행된다. 모든 FS 가 같은 경로를 타게 한다.
|
|
119
|
+
// 비용은 복사 한 번 추가인데 백업 자체가 이미 `db.backup()` 전체 복사다.
|
|
120
|
+
//
|
|
121
|
+
// ⚠ **복사는 원자적이지 않다** — link 는 O(1) 이라 사실상 원자적이었지만 복사는 바이트를 다시
|
|
122
|
+
// 쓴다. 중간에 죽으면 잘린 파일이 슬롯을 차지하고, `pickRecoverySlot` 은 기존 파일을 **의도적으로**
|
|
123
|
+
// 검증하지 않으므로(아래 주석) 그 파일은 영영 복구점 행세를 한다. 그래서 **목적지 기준으로
|
|
124
|
+
// 다시 검증**하고, 실패하면 슬롯을 비운다. tmp 는 이미 검증했지만 그 뒤에 바이트가 다시 쓰였다.
|
|
106
125
|
function publishNoClobber(tmp, base) {
|
|
107
126
|
for (let attempt = 0; attempt < MAX_RECOVERY_POINTS; attempt++) {
|
|
108
127
|
const slot = pickRecoverySlot(base);
|
|
109
128
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
129
|
+
copyFileSync(tmp, slot, constants.COPYFILE_EXCL);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
if (e.code === 'EEXIST')
|
|
133
|
+
continue; // 슬롯 경쟁 — 다음 빈 슬롯으로
|
|
134
|
+
throw e;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
verifyRecoveryPoint(slot);
|
|
113
138
|
}
|
|
114
139
|
catch (e) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
140
|
+
// 잘린/손상된 사본이 슬롯을 점유한 채 복구점 행세를 하지 못하게 한다.
|
|
141
|
+
try {
|
|
142
|
+
unlinkSync(slot);
|
|
143
|
+
}
|
|
144
|
+
catch { /* 정리 실패는 원인을 가리지 않는다 */ }
|
|
145
|
+
throw e;
|
|
118
146
|
}
|
|
147
|
+
unlinkSync(tmp);
|
|
148
|
+
return slot;
|
|
119
149
|
}
|
|
120
150
|
throw slotsFullError(base);
|
|
121
151
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Tiktoken } from 'tiktoken';
|
|
2
|
+
export interface ChunkSegment {
|
|
3
|
+
text: string;
|
|
4
|
+
start_pos: number | null;
|
|
5
|
+
end_pos: number | null;
|
|
6
|
+
start_token: number;
|
|
7
|
+
end_token: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function trimIncompleteUtf8(bytes: Uint8Array, trimHead: boolean, trimTail: boolean): Uint8Array;
|
|
10
|
+
export declare function chunkText(text: string, encoding: Tiktoken, maxTokens?: number, overlap?: number): ChunkSegment[];
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Tokenize and chunk text using a BPE encoder while reporting both token-space
|
|
2
|
+
// and char-space (Unicode codepoint) offsets back into the original string.
|
|
3
|
+
//
|
|
4
|
+
// BPE tokenizers (cl100k_base) split multi-byte UTF-8 sequences across tokens.
|
|
5
|
+
// Slicing token arrays at arbitrary boundaries can leave incomplete UTF-8
|
|
6
|
+
// prefix/suffix bytes, which TextDecoder replaces with U+FFFD (�). We trim the
|
|
7
|
+
// incomplete sequences at chunk boundaries; overlap covers the removed bytes.
|
|
8
|
+
//
|
|
9
|
+
// Each chunk records both token-space offsets (start_token/end_token from the
|
|
10
|
+
// BPE encoder loop) and char-space offsets (start_pos/end_pos into the original
|
|
11
|
+
// text). Char offsets are Unicode codepoint counts — language-neutral, so SQL
|
|
12
|
+
// substr, Python str slicing, and JS [...str] iteration all line up. JS's
|
|
13
|
+
// native UTF-16 indexing differs for supplementary characters (emoji, rare CJK),
|
|
14
|
+
// so the function maintains parallel UTF-16 and codepoint cursors and reports
|
|
15
|
+
// codepoint offsets. On a coincidental indexOf miss the char offsets are NULL.
|
|
16
|
+
//
|
|
17
|
+
// Extracted to a standalone module so publish-time invariant tests can exercise
|
|
18
|
+
// the algorithm directly without booting the full RAG-Memory stack.
|
|
19
|
+
// trimIncompleteUtf8: strip incomplete UTF-8 sequences from the head/tail of a
|
|
20
|
+
// byte buffer produced by decoding an arbitrary token slice. A multi-byte
|
|
21
|
+
// codepoint that begins or ends on the cut edge belongs to an adjacent chunk
|
|
22
|
+
// and must be removed so TextDecoder does not emit U+FFFD. Pass
|
|
23
|
+
// trimHead/trimTail=false to preserve head/tail bytes (first/last chunks).
|
|
24
|
+
export function trimIncompleteUtf8(bytes, trimHead, trimTail) {
|
|
25
|
+
let start = 0;
|
|
26
|
+
let end = bytes.length;
|
|
27
|
+
if (trimHead) {
|
|
28
|
+
while (start < end && (bytes[start] & 0xC0) === 0x80)
|
|
29
|
+
start++;
|
|
30
|
+
}
|
|
31
|
+
if (trimTail) {
|
|
32
|
+
let i = end - 1;
|
|
33
|
+
while (i >= start && (bytes[i] & 0xC0) === 0x80)
|
|
34
|
+
i--;
|
|
35
|
+
if (i >= start) {
|
|
36
|
+
const lead = bytes[i];
|
|
37
|
+
let needed = 1;
|
|
38
|
+
if ((lead & 0x80) === 0)
|
|
39
|
+
needed = 1;
|
|
40
|
+
else if ((lead & 0xE0) === 0xC0)
|
|
41
|
+
needed = 2;
|
|
42
|
+
else if ((lead & 0xF0) === 0xE0)
|
|
43
|
+
needed = 3;
|
|
44
|
+
else if ((lead & 0xF8) === 0xF0)
|
|
45
|
+
needed = 4;
|
|
46
|
+
if (end - i < needed)
|
|
47
|
+
end = i;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return bytes.subarray(start, end);
|
|
51
|
+
}
|
|
52
|
+
export function chunkText(text, encoding, maxTokens = 800, overlap = 160) {
|
|
53
|
+
const tokens = encoding.encode(text);
|
|
54
|
+
const segments = [];
|
|
55
|
+
let utf16Cursor = 0;
|
|
56
|
+
let cpCursor = 0;
|
|
57
|
+
for (let i = 0; i < tokens.length; i += maxTokens - overlap) {
|
|
58
|
+
const chunkTokens = tokens.slice(i, i + maxTokens);
|
|
59
|
+
const decodedBytes = encoding.decode(chunkTokens);
|
|
60
|
+
const isFirst = i === 0;
|
|
61
|
+
const isLast = i + chunkTokens.length >= tokens.length;
|
|
62
|
+
const safeBytes = trimIncompleteUtf8(decodedBytes, !isFirst, !isLast);
|
|
63
|
+
const chunkTextStr = new TextDecoder('utf-8').decode(safeBytes);
|
|
64
|
+
let startPos;
|
|
65
|
+
let endPos;
|
|
66
|
+
if (isFirst) {
|
|
67
|
+
startPos = 0;
|
|
68
|
+
endPos = [...chunkTextStr].length;
|
|
69
|
+
utf16Cursor = 0;
|
|
70
|
+
cpCursor = 0;
|
|
71
|
+
}
|
|
72
|
+
else if (chunkTextStr.length === 0) {
|
|
73
|
+
startPos = null;
|
|
74
|
+
endPos = null;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const utfIdx = text.indexOf(chunkTextStr, utf16Cursor);
|
|
78
|
+
if (utfIdx >= 0) {
|
|
79
|
+
// Advance cpCursor by codepoints between the previous cursor and the
|
|
80
|
+
// new chunk's start (handles overlap by anchoring at the previous
|
|
81
|
+
// chunk's start, not its end).
|
|
82
|
+
if (utfIdx > utf16Cursor) {
|
|
83
|
+
cpCursor += [...text.slice(utf16Cursor, utfIdx)].length;
|
|
84
|
+
utf16Cursor = utfIdx;
|
|
85
|
+
}
|
|
86
|
+
const cpLen = [...chunkTextStr].length;
|
|
87
|
+
startPos = cpCursor;
|
|
88
|
+
endPos = cpCursor + cpLen;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
startPos = null;
|
|
92
|
+
endPos = null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
segments.push({
|
|
96
|
+
text: chunkTextStr,
|
|
97
|
+
start_pos: startPos,
|
|
98
|
+
end_pos: endPos,
|
|
99
|
+
start_token: i,
|
|
100
|
+
end_token: i + chunkTokens.length
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return segments;
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rag-memory-epf-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.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/search-graph-default.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 && node test/observation-date-prefix.test.mjs && node test/delete-entities-cascade.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/search-graph-default.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/alias-link-gate.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 && node test/observation-date-prefix.test.mjs && node test/delete-entities-cascade.test.mjs && node test/backup-publish-portable.test.mjs && node test/graph-context-explain.test.mjs && node test/eval-graph-role-libs.test.mjs && node test/eval-graph-role-t5b.test.mjs && node --test test/eval-graph-role-t8-fix.test.mjs && node --test test/eval-graph-role-t7-upstream.test.mjs && node --test test/eval-graph-role-t11-decision.test.mjs && node --test test/eval-graph-role-prereq-fix.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
|
},
|