rag-memory-epf-mcp 5.1.0 → 5.3.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +34 -10
- package/dist/src/migrations/migrations.js +10 -1
- package/dist/src/observations/date-prefix.d.ts +26 -0
- package/dist/src/observations/date-prefix.js +89 -0
- package/dist/src/tools/knowledge-graph-tools.js +20 -14
- package/docs/UPDATING.md +22 -0
- package/package.json +2 -2
- package/dist/src/chunkText.d.ts +0 -10
- package/dist/src/chunkText.js +0 -104
package/README.md
CHANGED
|
@@ -10,9 +10,9 @@ A **project-local RAG memory** MCP server — knowledge graph + multilingual vec
|
|
|
10
10
|
## Key Features
|
|
11
11
|
|
|
12
12
|
- **Project-local isolation** — each project gets its own `.memory/rag-memory.db`. Multiple projects run simultaneously without interference.
|
|
13
|
-
- **
|
|
13
|
+
- **Hybrid search** — vector similarity (bge-m3, 1024-dim) + FTS5 BM25 keyword matching (RRF-fused). Knowledge-graph re-ranking is an **opt-in legacy/experimental** signal since v5.3.0 (measured to hurt known-item retrieval)
|
|
14
14
|
- **100+ languages** — Korean, Chinese, Japanese, Arabic, and more. Cross-lingual search works out of the box.
|
|
15
|
-
- **Graph-
|
|
15
|
+
- **Graph re-ranker (opt-in)** — per-entity geometric decay (0.5^i) with a hard cap 0.4; the cap bounds the boost but was measured (2026-08-17) to let heavily-linked chunks saturate it and outrank the exact chunk — hence off by default
|
|
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).
|
|
@@ -77,7 +77,7 @@ Place this `.mcp.json` in each project folder with its own `DB_FILE_PATH`. Each
|
|
|
77
77
|
### Search & Retrieval (9)
|
|
78
78
|
| Tool | Description | Annotation |
|
|
79
79
|
|------|------------|------------|
|
|
80
|
-
| `hybridSearch` | Vector + FTS5 BM25
|
|
80
|
+
| `hybridSearch` | Vector + FTS5 BM25, plus an **opt-in** graph re-ranker (`useGraph: true`; default off since v5.3.0). Degrades to FTS5-only (`search_mode`) when the embedding model is down | readOnly |
|
|
81
81
|
| `searchNodes` | Semantic entity search with `since`/`until` temporal filtering | readOnly |
|
|
82
82
|
| `openNodes` | Retrieve specific entities by name | readOnly |
|
|
83
83
|
| `readGraph` | Get complete knowledge graph | readOnly |
|
|
@@ -153,6 +153,10 @@ storeDocument(id, content, metadata)
|
|
|
153
153
|
|
|
154
154
|
## Changelog
|
|
155
155
|
|
|
156
|
+
### v5.3.0-rc.1
|
|
157
|
+
- **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`.
|
|
158
|
+
- (v5.0.0–v5.2.0 notes live in the git tags / `docs/UPDATING.md`.)
|
|
159
|
+
|
|
156
160
|
### v4.0.0
|
|
157
161
|
|
|
158
162
|
**Observation lifecycle (schema v13).** Observations used to be a JSON array of strings on the
|
package/dist/index.d.ts
CHANGED
|
@@ -70,6 +70,7 @@ export declare class RAGKnowledgeGraphManager {
|
|
|
70
70
|
currentProfileId: number;
|
|
71
71
|
grandfatherAllowed: boolean;
|
|
72
72
|
coordinator: BackfillCoordinator | null;
|
|
73
|
+
readonly calendarTimeZone: string;
|
|
73
74
|
private embeddingCache;
|
|
74
75
|
private readonly EMBEDDING_CACHE_MAX;
|
|
75
76
|
private dictionaryCache;
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,7 @@ import { migrations } from './src/migrations/migrations.js';
|
|
|
32
32
|
import { EmbeddingGate, GateNotReadyError, GateDisabledError, TerminalConfigError } from './src/embeddingGate.js';
|
|
33
33
|
import { resolveModelCacheDir, preflightCacheDir, artifactKey, ModelDownloadLock, handleLoaderFailure } from './src/modelCache.js';
|
|
34
34
|
import { BackfillCoordinator } from './src/backfillCoordinator.js';
|
|
35
|
+
import { calendarDate, resolveCalendarTimeZone, stampDatePrefix, stripDatePrefix } from './src/observations/date-prefix.js';
|
|
35
36
|
import os from 'node:os';
|
|
36
37
|
import { createHash } from 'crypto';
|
|
37
38
|
import { createRequire } from 'module';
|
|
@@ -179,6 +180,9 @@ export class RAGKnowledgeGraphManager {
|
|
|
179
180
|
// default model config, or with the explicit trust opt-in (spec §6b guard).
|
|
180
181
|
grandfatherAllowed = IS_DEFAULT_MODEL_CONFIG || process.env.RAG_MEMORY_TRUST_LEGACY_VECTORS === '1';
|
|
181
182
|
coordinator = null;
|
|
183
|
+
// The calendar that date-only human labels are written in. Resolved once, here, so an invalid
|
|
184
|
+
// zone fails at construction instead of quietly writing wrong days for weeks.
|
|
185
|
+
calendarTimeZone = resolveCalendarTimeZone(process.env.RAG_MEMORY_CALENDAR_TZ);
|
|
182
186
|
embeddingCache = new Map();
|
|
183
187
|
EMBEDDING_CACHE_MAX = 500;
|
|
184
188
|
dictionaryCache = null;
|
|
@@ -574,11 +578,12 @@ export class RAGKnowledgeGraphManager {
|
|
|
574
578
|
}
|
|
575
579
|
// === ORIGINAL MCP FUNCTIONALITY ===
|
|
576
580
|
_timestampObservation(obs) {
|
|
577
|
-
//
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
581
|
+
// Stamp and dedup share one parser (src/observations/date-prefix.ts). They used to carry
|
|
582
|
+
// separate regexes and disagreed about "[2026-08-11 session16] ...": stamping treated it as
|
|
583
|
+
// undated and prepended a second date, while dedup could not strip it — so the same sentence
|
|
584
|
+
// written in two sessions became two observations. Measured on a live database: 82 rows with
|
|
585
|
+
// two dates, 29 with a day earlier than the day they were written.
|
|
586
|
+
return stampDatePrefix(obs, this.calendarTimeZone);
|
|
582
587
|
}
|
|
583
588
|
async createEntities(entities) {
|
|
584
589
|
if (!this.db)
|
|
@@ -590,7 +595,7 @@ export class RAGKnowledgeGraphManager {
|
|
|
590
595
|
INSERT OR IGNORE INTO entities (id, name, entityType, observations, metadata)
|
|
591
596
|
VALUES (?, ?, ?, '[]', ?)
|
|
592
597
|
`);
|
|
593
|
-
const stripDate =
|
|
598
|
+
const stripDate = stripDatePrefix; // shared with the stamp path — see date-prefix.ts
|
|
594
599
|
for (const entity of entities) {
|
|
595
600
|
const entityId = `entity_${entity.name.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
|
|
596
601
|
const ts = new Date().toISOString();
|
|
@@ -700,7 +705,7 @@ export class RAGKnowledgeGraphManager {
|
|
|
700
705
|
// dedup 기준은 v3.6 과 같다: 날짜 prefix 를 뗀 본문이 active 에 이미 있으면
|
|
701
706
|
// 새 revision 을 만들지 않는다. 다만 v13 에서는 같은 사실이 다른 출처에서 다시
|
|
702
707
|
// 온 것이므로 그 revision 에 source link 를 더한다(spec §8.3 T13).
|
|
703
|
-
const stripDate =
|
|
708
|
+
const stripDate = stripDatePrefix; // shared with the stamp path — see date-prefix.ts
|
|
704
709
|
const activeRows = this.db.prepare(`SELECT observation_id, content FROM entity_observations
|
|
705
710
|
WHERE entity_id = ? AND status = 'active'`).all(entityId);
|
|
706
711
|
const activeByBare = new Map(activeRows.map(r => [stripDate(r.content), r.observation_id]));
|
|
@@ -1963,7 +1968,10 @@ export class RAGKnowledgeGraphManager {
|
|
|
1963
1968
|
// report `unchanged` for a different exclusion, which is the silent-wrong case.
|
|
1964
1969
|
const content = applyExcludePatterns(options.content !== undefined ? options.content : fsSync.readFileSync(filePath, 'utf-8'), options.excludePattern);
|
|
1965
1970
|
const bytes = Buffer.byteLength(content, 'utf-8');
|
|
1966
|
-
|
|
1971
|
+
// Same calendar as the observation prefix. Deciding this explicitly rather than leaving it
|
|
1972
|
+
// on UTC: both are date-only labels a person reads, and "observations in Seoul days but
|
|
1973
|
+
// documents in UTC days" is not a distinction anyone could explain later.
|
|
1974
|
+
const today = calendarDate(new Date(), this.calendarTimeZone);
|
|
1967
1975
|
const contentHash = shaHex(content);
|
|
1968
1976
|
// spec §5.1: content_hash 는 system-owned — user metadata 뒤에 쓴다 (r1: spread 가 덮어쓸 수 있었다).
|
|
1969
1977
|
const metadata = { source: filePath, updated: today, ...(options.metadata || {}), content_hash: contentHash };
|
|
@@ -2915,7 +2923,19 @@ export class RAGKnowledgeGraphManager {
|
|
|
2915
2923
|
this.coordinator?.kick();
|
|
2916
2924
|
return { imported, skipped, observation_order_remap: remapReport };
|
|
2917
2925
|
}
|
|
2918
|
-
|
|
2926
|
+
// v5.3.0: the graph re-ranker is OPT-IN (harm-reduced default, not a validated improvement).
|
|
2927
|
+
// Measured 2026-08-17 on three real corpora (self-retrieval, usable samples 120/117/120,
|
|
2928
|
+
// summaries off): with the additive graph boost on, the known-item chunk got WORSE in
|
|
2929
|
+
// 46/49/52 samples and BETTER in 3/2/0 (sign test p < 7e-11 per corpus); 106 targets left the
|
|
2930
|
+
// top-10 entirely. Reproduced on the summaries-on product path (HAL, 20 paired samples:
|
|
2931
|
+
// hit@1 10 -> 7, hit@5 18 -> 13, worse 8 / better 3). Mechanism: only query-matched or
|
|
2932
|
+
// connected entities score, but the per-entity boost (0.5^i decay, cap 0.4) saturates fast, so
|
|
2933
|
+
// heavily-linked chunks get more chances to match and reach the cap — they can outrank the
|
|
2934
|
+
// exact chunk even at vector_similarity 0. Note the graph does not generate candidates: it only
|
|
2935
|
+
// re-orders the vector/FTS candidate pool, so useGraph:true is a legacy/experimental re-ranker
|
|
2936
|
+
// (backward compatibility, controlled evaluation), not a relationship-exploration path — that
|
|
2937
|
+
// contract is openNodes -> getNeighbors. The boost path itself is unchanged.
|
|
2938
|
+
async hybridSearch(query, limit = 5, useGraph = false) {
|
|
2919
2939
|
if (!this.db)
|
|
2920
2940
|
throw new Error('Database not initialized');
|
|
2921
2941
|
if (!this.encoding)
|
|
@@ -3452,6 +3472,9 @@ export class RAGKnowledgeGraphManager {
|
|
|
3452
3472
|
server: {
|
|
3453
3473
|
version: PKG_VERSION,
|
|
3454
3474
|
node: process.versions.node,
|
|
3475
|
+
// Which calendar produced the date-only labels in this database. Surfaced because a
|
|
3476
|
+
// wrong value is otherwise invisible: the labels look plausible either way.
|
|
3477
|
+
calendar_timezone: this.calendarTimeZone,
|
|
3455
3478
|
embeddings_mode: this.embeddingsMode,
|
|
3456
3479
|
model: `${EMBEDDING_MODEL}@${MODEL_REVISION}`,
|
|
3457
3480
|
model_state: gs.state,
|
|
@@ -3859,7 +3882,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3859
3882
|
return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.linkEntitiesToDocument(validatedArgs.documentId, validatedArgs.entityNames), null, 2) }] };
|
|
3860
3883
|
case "hybridSearch":
|
|
3861
3884
|
const limit = typeof validatedArgs.limit === 'number' ? validatedArgs.limit : 5;
|
|
3862
|
-
|
|
3885
|
+
// v5.3.0: graph is opt-in — only an explicit true enables the re-ranker (schema default false).
|
|
3886
|
+
const useGraph = validatedArgs.useGraph === true;
|
|
3863
3887
|
return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.hybridSearch(validatedArgs.query, limit, useGraph), null, 2) }] };
|
|
3864
3888
|
case "getDetailedContext":
|
|
3865
3889
|
return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getDetailedContext(validatedArgs.chunkId, validatedArgs.includeSurrounding !== false), null, 2) }] };
|
|
@@ -9,7 +9,16 @@ export const migrations = [
|
|
|
9
9
|
version: 1,
|
|
10
10
|
description: 'Complete RAG Knowledge Graph schema - all tables and features',
|
|
11
11
|
up: (db) => {
|
|
12
|
-
//
|
|
12
|
+
// ⚠ 이 줄은 **아무 일도 하지 않는다** (2026-08-11 실측). migration-manager 가 각
|
|
13
|
+
// 마이그레이션을 `db.transaction(...)` 으로 감싸는데, SQLite 에서 `PRAGMA foreign_keys`
|
|
14
|
+
// 는 **트랜잭션 안에서 no-op** 이기 때문이다. 실측 = 신규 DB 에 13개 마이그레이션을
|
|
15
|
+
// 전부 적용한 뒤에도 `foreign_keys = 1`.
|
|
16
|
+
//
|
|
17
|
+
// **고치지 말 것.** 이 줄이 실제로 동작하게 만들면(트랜잭션 밖으로 빼는 등) FK 는
|
|
18
|
+
// 마이그레이션 이후 **그 프로세스 수명 내내 꺼진 채로 남는다** — 부팅 게이트(index.ts)
|
|
19
|
+
// 는 `runMigrations()` **앞**에서 판정하므로 그걸 못 잡는다. 그 상태에서 entity 를
|
|
20
|
+
// 지우면 observation 계열이 CASCADE 되지 않아 고아·FK 위반이 쌓인다.
|
|
21
|
+
// 회귀 잠금 = test/observation-cascade.test.mjs T25(신규 DB 부팅 후 FK==1).
|
|
13
22
|
db.pragma('foreign_keys = OFF');
|
|
14
23
|
// Original entities table (enhanced)
|
|
15
24
|
db.exec(`
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** The calendar day of `instant` in `timeZone`, as YYYY-MM-DD. */
|
|
2
|
+
export declare function calendarDate(instant: Date, timeZone: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Resolve RAG_MEMORY_CALENDAR_TZ. Unset or blank means UTC. An unrecognised zone throws at
|
|
5
|
+
* boot rather than silently falling back — a wrong-but-quiet calendar writes wrong labels for
|
|
6
|
+
* as long as nobody looks, which is the failure this whole module exists to end.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveCalendarTimeZone(raw: string | undefined | null): string;
|
|
9
|
+
export interface DatePrefix {
|
|
10
|
+
/** YYYY-MM-DD, already validated as a real calendar day. */
|
|
11
|
+
date: string;
|
|
12
|
+
/** Text between the date and the closing bracket, e.g. a session marker. */
|
|
13
|
+
annotation: string | null;
|
|
14
|
+
/** The matched prefix including the brackets, without trailing whitespace. */
|
|
15
|
+
matched: string;
|
|
16
|
+
}
|
|
17
|
+
/** Parse a leading date prefix, or null when the text does not start with one. */
|
|
18
|
+
export declare function parseDatePrefix(content: string): DatePrefix | null;
|
|
19
|
+
/**
|
|
20
|
+
* The dedup key: the text with its date prefix removed. The annotation comes off with the date
|
|
21
|
+
* because it records *when and in which session the line was written*, not what the line claims
|
|
22
|
+
* — the same sentence written in two sessions is one fact, and dedup has to see that.
|
|
23
|
+
*/
|
|
24
|
+
export declare function stripDatePrefix(content: string): string;
|
|
25
|
+
/** Prepend today's calendar day unless the text already carries a valid date prefix. */
|
|
26
|
+
export declare function stampDatePrefix(content: string, timeZone: string, now?: Date): string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// One place that decides what a leading "[date]" on an observation means.
|
|
2
|
+
//
|
|
3
|
+
// Why this is a module and not two regexes inline: the stamp path and the dedup path each had
|
|
4
|
+
// their own copy of the pattern, and they disagreed. Stamping asked "does this already start
|
|
5
|
+
// with a date?" while dedup asked "what is this text without its date?", so widening one without
|
|
6
|
+
// the other let the same sentence be stored twice under two session markers. Both now call in
|
|
7
|
+
// here.
|
|
8
|
+
//
|
|
9
|
+
// Why an explicit timezone instead of the process one: a date-only label has no meaning until
|
|
10
|
+
// you say which calendar produced it. The same database is reached from a laptop, from CI and
|
|
11
|
+
// from another country; deriving the day from the ambient TZ makes the stored string mean
|
|
12
|
+
// something different on each. The default stays UTC so the product does not inherit whichever
|
|
13
|
+
// zone its first author happened to sit in — a deployment that wants local days says so.
|
|
14
|
+
/** The calendar day of `instant` in `timeZone`, as YYYY-MM-DD. */
|
|
15
|
+
export function calendarDate(instant, timeZone) {
|
|
16
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
17
|
+
timeZone,
|
|
18
|
+
year: 'numeric',
|
|
19
|
+
month: '2-digit',
|
|
20
|
+
day: '2-digit',
|
|
21
|
+
}).formatToParts(instant);
|
|
22
|
+
const part = (type) => {
|
|
23
|
+
const found = parts.find(p => p.type === type);
|
|
24
|
+
if (!found)
|
|
25
|
+
throw new Error(`calendarDate: missing ${type} for timeZone ${timeZone}`);
|
|
26
|
+
return found.value;
|
|
27
|
+
};
|
|
28
|
+
return `${part('year')}-${part('month')}-${part('day')}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve RAG_MEMORY_CALENDAR_TZ. Unset or blank means UTC. An unrecognised zone throws at
|
|
32
|
+
* boot rather than silently falling back — a wrong-but-quiet calendar writes wrong labels for
|
|
33
|
+
* as long as nobody looks, which is the failure this whole module exists to end.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveCalendarTimeZone(raw) {
|
|
36
|
+
const value = (raw ?? '').trim();
|
|
37
|
+
if (!value)
|
|
38
|
+
return 'UTC';
|
|
39
|
+
try {
|
|
40
|
+
new Intl.DateTimeFormat('en-US', { timeZone: value });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error(`RAG_MEMORY_CALENDAR_TZ is not a recognised IANA time zone: ${JSON.stringify(value)}. ` +
|
|
44
|
+
`Use a name like "Asia/Seoul", or leave it unset for UTC.`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
// The annotation may not contain a newline or a bracket: an unclosed or multi-line "[2026-…"
|
|
49
|
+
// is prose that happens to start with a digit, not a prefix. Requiring the closing bracket is
|
|
50
|
+
// what keeps "[2026-08-11 unclosed" and "[2026-08-111]" out.
|
|
51
|
+
const PREFIX_RE = /^\[(\d{4})-(\d{2})-(\d{2})(?:[ \t]([^\]\n]*))?\]/;
|
|
52
|
+
function isRealCalendarDay(year, month, day) {
|
|
53
|
+
const asUtc = new Date(Date.UTC(year, month - 1, day));
|
|
54
|
+
return asUtc.getUTCFullYear() === year
|
|
55
|
+
&& asUtc.getUTCMonth() === month - 1
|
|
56
|
+
&& asUtc.getUTCDate() === day;
|
|
57
|
+
}
|
|
58
|
+
/** Parse a leading date prefix, or null when the text does not start with one. */
|
|
59
|
+
export function parseDatePrefix(content) {
|
|
60
|
+
const m = PREFIX_RE.exec(content);
|
|
61
|
+
if (!m)
|
|
62
|
+
return null;
|
|
63
|
+
const [matched, year, month, day, annotation] = m;
|
|
64
|
+
if (!isRealCalendarDay(Number(year), Number(month), Number(day)))
|
|
65
|
+
return null;
|
|
66
|
+
const trimmed = annotation === undefined ? null : annotation.trim();
|
|
67
|
+
return {
|
|
68
|
+
date: `${year}-${month}-${day}`,
|
|
69
|
+
annotation: trimmed === '' ? null : trimmed,
|
|
70
|
+
matched,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The dedup key: the text with its date prefix removed. The annotation comes off with the date
|
|
75
|
+
* because it records *when and in which session the line was written*, not what the line claims
|
|
76
|
+
* — the same sentence written in two sessions is one fact, and dedup has to see that.
|
|
77
|
+
*/
|
|
78
|
+
export function stripDatePrefix(content) {
|
|
79
|
+
const prefix = parseDatePrefix(content);
|
|
80
|
+
if (!prefix)
|
|
81
|
+
return content;
|
|
82
|
+
return content.slice(prefix.matched.length).replace(/^\s+/, '');
|
|
83
|
+
}
|
|
84
|
+
/** Prepend today's calendar day unless the text already carries a valid date prefix. */
|
|
85
|
+
export function stampDatePrefix(content, timeZone, now = new Date()) {
|
|
86
|
+
if (parseDatePrefix(content))
|
|
87
|
+
return content;
|
|
88
|
+
return `[${calendarDate(now, timeZone)}] ${content}`;
|
|
89
|
+
}
|
|
@@ -200,6 +200,11 @@ Observations provide the factual foundation that supports entity existence and p
|
|
|
200
200
|
- (!important!) **Entity must exist** - this tool only adds to existing entities
|
|
201
201
|
- (!important!) Duplicates are filtered - repeating existing text with a **new** sources entry
|
|
202
202
|
adds evidence to the existing revision and returns null for that position
|
|
203
|
+
- (!important!) A \`[YYYY-MM-DD]\` prefix is prepended unless the text already starts with a valid
|
|
204
|
+
one. The day comes from \`RAG_MEMORY_CALENDAR_TZ\` (default UTC) - see \`calendar_timezone\` in
|
|
205
|
+
getKnowledgeGraphStats. Text may open with \`[YYYY-MM-DD note]\`; the note is treated as recording
|
|
206
|
+
metadata and is ignored when deduplicating, so the same sentence filed under two session markers
|
|
207
|
+
stays one observation
|
|
203
208
|
- (!important!) Observations are cumulative. **This tool never replaces anything** - use
|
|
204
209
|
correctObservation to supersede and retractObservation to withdraw
|
|
205
210
|
- (!important!) **Be specific and factual** - observations should be verifiable statements
|
|
@@ -287,22 +292,24 @@ const hybridSearchCapability = {
|
|
|
287
292
|
},
|
|
288
293
|
useGraph: {
|
|
289
294
|
type: 'boolean',
|
|
290
|
-
description: '
|
|
291
|
-
default:
|
|
295
|
+
description: 'Opt-in legacy/experimental graph re-ranker (default false since v5.3.0 — measured to hurt known-item retrieval). It only re-orders the vector/FTS candidate pool; for relationship exploration use openNodes -> getNeighbors instead',
|
|
296
|
+
default: false
|
|
292
297
|
}
|
|
293
298
|
},
|
|
294
299
|
required: ['query'],
|
|
295
300
|
},
|
|
296
301
|
};
|
|
297
302
|
const hybridSearchDescription = () => `<description>
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
303
|
+
Hybrid document search: vector similarity + FTS5 BM25 over document chunks, with an **opt-in** knowledge-graph re-ranker.
|
|
304
|
+
Default (useGraph false) is the harm-reduced default for finding a fact you know exists (known-item retrieval).
|
|
305
|
+
useGraph: true is a legacy/experimental re-ranker kept for backward compatibility and controlled evaluation — it does not
|
|
306
|
+
add candidates, it only re-orders the vector/FTS pool, and measured on three real corpora (2026-08-17) it pushed the exact
|
|
307
|
+
chunk out of the top-5 in ~40% of known-item queries. For relationship exploration use openNodes -> getNeighbors.
|
|
301
308
|
</description>
|
|
302
309
|
|
|
303
310
|
<importantNotes>
|
|
304
|
-
- (!important!) **
|
|
305
|
-
- (!important!) Graph
|
|
311
|
+
- (!important!) **Graph re-ranking is opt-in (default false since v5.3.0)** — it re-orders candidates by matched/connected entity links, which is measured to hurt known-item retrieval; enable it only for backward compatibility or controlled evaluation
|
|
312
|
+
- (!important!) Graph re-ranking does not generate candidates — for "what is connected to X" use openNodes -> getNeighbors
|
|
306
313
|
- (!important!) Results include similarity scores, graph boost, and hybrid rankings
|
|
307
314
|
- (!important!) **Best results when knowledge graph is well-populated** with entities and relationships
|
|
308
315
|
</importantNotes>
|
|
@@ -327,7 +334,7 @@ Perfect for complex queries that benefit from both content matching and conceptu
|
|
|
327
334
|
|
|
328
335
|
<bestPractices>
|
|
329
336
|
- Use natural language queries rather than keywords
|
|
330
|
-
-
|
|
337
|
+
- Keep graph re-ranking off for "find the fact I know exists"; for "what is connected to this" use openNodes -> getNeighbors, not useGraph
|
|
331
338
|
- Start with broader queries, then narrow down based on results
|
|
332
339
|
- Review entity associations to understand why results were selected
|
|
333
340
|
- Use appropriate limits based on your analysis needs
|
|
@@ -337,19 +344,18 @@ Perfect for complex queries that benefit from both content matching and conceptu
|
|
|
337
344
|
<parameters>
|
|
338
345
|
- query: Natural language search query (string, required)
|
|
339
346
|
- limit: Maximum results to return, default 5 (number, optional)
|
|
340
|
-
- useGraph: Enable knowledge graph
|
|
347
|
+
- useGraph: Enable knowledge graph re-ranking, default false / opt-in (boolean, optional)
|
|
341
348
|
</parameters>
|
|
342
349
|
|
|
343
350
|
<examples>
|
|
344
|
-
-
|
|
345
|
-
-
|
|
346
|
-
- Discovery
|
|
347
|
-
- Quick lookup: {"query": "quantum computing advantages", "limit": 3, "useGraph": false}
|
|
351
|
+
- Known-item lookup (default, graph off): {"query": "machine learning applications in healthcare", "limit": 10}
|
|
352
|
+
- Legacy re-ranker (opt-in, evaluation/back-compat only): {"query": "React performance optimization techniques", "useGraph": true}
|
|
353
|
+
- Discovery / relationship exploration: use openNodes then getNeighbors on the entity, not this tool's useGraph
|
|
348
354
|
</examples>`;
|
|
349
355
|
const hybridSearchSchema = {
|
|
350
356
|
query: z.string().describe('The search query to find relevant information'),
|
|
351
357
|
limit: z.number().optional().default(5).describe('Maximum number of results to return'),
|
|
352
|
-
useGraph: z.boolean().optional().default(
|
|
358
|
+
useGraph: z.boolean().optional().default(false).describe('Opt-in legacy/experimental graph re-ranker (default false since v5.3.0; measured to hurt known-item retrieval; for exploration use openNodes -> getNeighbors)'),
|
|
353
359
|
};
|
|
354
360
|
export const hybridSearchTool = {
|
|
355
361
|
capability: hybridSearchCapability,
|
package/docs/UPDATING.md
CHANGED
|
@@ -108,6 +108,28 @@ 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.3.0-rc.1 (schema v14, unchanged): `hybridSearch` graph re-ranking is opt-in
|
|
112
|
+
|
|
113
|
+
**What changed.** `useGraph` defaults to `false` (was `true`) — in the manager signature, the tool JSON
|
|
114
|
+
schema, the zod schema (`validateToolArgs` fills `false`) and the MCP dispatch (`=== true`). Nothing else
|
|
115
|
+
in the scoring path moved: `useGraph: true` runs exactly the pre-5.3 graph boost.
|
|
116
|
+
|
|
117
|
+
**Why.** Measured 2026-08-17 on three real corpora (self-retrieval, usable samples 120/117/120, summaries
|
|
118
|
+
off): with the additive graph boost on, the known-item chunk got WORSE in 46/49/52 samples and BETTER in
|
|
119
|
+
3/2/0 (paired sign test p < 7e-11 per corpus); 106 targets fell out of the top-10 entirely. Reproduced on
|
|
120
|
+
the summaries-on product path (HAL, 20 paired samples: hit@1 10→7, hit@5 18→13). Only query-matched or
|
|
121
|
+
connected entities score, but the per-entity boost (0.5^i decay, cap 0.4) saturates fast, so heavily-linked
|
|
122
|
+
chunks can outrank the exact chunk even at `vector_similarity` 0. This is a reversible harm mitigation, not
|
|
123
|
+
a validated graph improvement — the graph's role (candidate generation vs. re-ranking, explicit mode) is
|
|
124
|
+
still open and will be decided on a graph-required query suite. Note that `useGraph: true` does not add
|
|
125
|
+
candidates (it re-orders the vector/FTS pool): for relationship exploration use `openNodes` → `getNeighbors`.
|
|
126
|
+
|
|
127
|
+
**Fleet rollout.** No migration, no schema change. Published first as a release candidate on the `next`
|
|
128
|
+
dist-tag (omitting an argument changes behavior, so it gets a canary before `latest`): pin one project to
|
|
129
|
+
`rag-memory-epf-mcp@next`, run its normal /start and /sync searches, then promote to stable. A caller that
|
|
130
|
+
relied on graph re-ranking by default must now pass `useGraph: true`. Callers that already choose per query
|
|
131
|
+
see no change. Regression lock: `test/search-graph-default.test.mjs`.
|
|
132
|
+
|
|
111
133
|
## v5.1.0 (schema v14, unchanged): destructive-replace reporting + `excludePattern`
|
|
112
134
|
|
|
113
135
|
**What changes on upgrade**: nothing you have to do. No migration, no schema
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rag-memory-epf-mcp",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.0-rc.1",
|
|
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 && 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",
|
|
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",
|
|
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
|
},
|
package/dist/src/chunkText.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
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[];
|
package/dist/src/chunkText.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
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
|
-
}
|