knowledge-rag 4.9.0__tar.gz → 4.9.2__tar.gz
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.
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/PKG-INFO +3 -1
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/README.md +2 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/__init__.py +1 -1
- knowledge_rag-4.9.2/mcp_server/preflight.py +171 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/server.py +9 -3
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/pyproject.toml +1 -1
- knowledge_rag-4.9.0/mcp_server/preflight.py +0 -74
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/.gitignore +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/LICENSE +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/bench/dashboard/README.md +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/config.example.yaml +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/config.py +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/fts5_index.py +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/guarded.py +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/health.py +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/ingestion.py +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/instance_lock.py +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/logging_config.py +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/metrics.py +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/query_router.py +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/ratelimit.py +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/mcp_server/security.py +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/npm/README.md +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/presets/cybersecurity.yaml +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/presets/developer.yaml +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/presets/general.yaml +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/presets/multilingual.yaml +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/presets/research.yaml +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/requirements.txt +0 -0
- {knowledge_rag-4.9.0 → knowledge_rag-4.9.2}/skills/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: knowledge-rag
|
|
3
|
-
Version: 4.9.
|
|
3
|
+
Version: 4.9.2
|
|
4
4
|
Summary: Local RAG System for Claude Code — Hybrid search + Cross-encoder Reranking + 13 MCP Tools + 20 Format Parsers. Zero external servers.
|
|
5
5
|
Project-URL: Homepage, https://github.com/lyonzin/knowledge-rag
|
|
6
6
|
Project-URL: Repository, https://github.com/lyonzin/knowledge-rag
|
|
@@ -636,6 +636,8 @@ Native MCP
|
|
|
636
636
|
|
|
637
637
|
**Complete client configuration guide with JSON schemas per client:** [docs/INSTALLATION.md#use-with-other-mcp-clients →](docs/INSTALLATION.md#use-with-other-mcp-clients)
|
|
638
638
|
|
|
639
|
+
> ⚠️ **Operating model — read once.** Your MCP client (Claude Desktop, Code, Cursor, etc.) **spawns and owns the knowledge-rag server process**. Do **NOT** also run `knowledge-rag` in a terminal while your MCP client is open — two writer processes against the same `data_dir` corrupt the ChromaDB HNSW segment on Windows (see [issue #216](https://github.com/lyonzin/knowledge-rag/issues/216)). Recommended baseline: add `"KNOWLEDGE_RAG_SINGLE_INSTANCE": "1"` to the `env` block of your MCP config as a safety net. Full explanation + how to run CLI commands safely: [docs/single-instance.md](docs/single-instance.md).
|
|
640
|
+
|
|
639
641
|
---
|
|
640
642
|
|
|
641
643
|
## ⚙️ Configuration in 30 seconds
|
|
@@ -588,6 +588,8 @@ Native MCP
|
|
|
588
588
|
|
|
589
589
|
**Complete client configuration guide with JSON schemas per client:** [docs/INSTALLATION.md#use-with-other-mcp-clients →](docs/INSTALLATION.md#use-with-other-mcp-clients)
|
|
590
590
|
|
|
591
|
+
> ⚠️ **Operating model — read once.** Your MCP client (Claude Desktop, Code, Cursor, etc.) **spawns and owns the knowledge-rag server process**. Do **NOT** also run `knowledge-rag` in a terminal while your MCP client is open — two writer processes against the same `data_dir` corrupt the ChromaDB HNSW segment on Windows (see [issue #216](https://github.com/lyonzin/knowledge-rag/issues/216)). Recommended baseline: add `"KNOWLEDGE_RAG_SINGLE_INSTANCE": "1"` to the `env` block of your MCP config as a safety net. Full explanation + how to run CLI commands safely: [docs/single-instance.md](docs/single-instance.md).
|
|
592
|
+
|
|
591
593
|
---
|
|
592
594
|
|
|
593
595
|
## ⚙️ Configuration in 30 seconds
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Startup preflight checks for persistent ChromaDB state.
|
|
2
|
+
|
|
3
|
+
GH #216 (v4.9.1): probe retries + inconclusive-on-timeout + atomic rename.
|
|
4
|
+
The previous single-attempt quarantine turned any transient failure
|
|
5
|
+
(file locks after client restart, slow disk, cold GPU init) into a
|
|
6
|
+
full-corpus reindex loop on Windows stdio MCP clients. The reporter's
|
|
7
|
+
forensic analysis on issue #216 documented the failure mode and the
|
|
8
|
+
fixes below were validated on their environment before merging.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .config import BASE_DIR, config
|
|
21
|
+
|
|
22
|
+
PROBE_ATTEMPTS = 3
|
|
23
|
+
PROBE_RETRY_DELAY_SECONDS = 5
|
|
24
|
+
PROBE_TIMEOUT_SECONDS = 30
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _log(msg: str) -> None:
|
|
28
|
+
"""Emit preflight message to stderr and append to data/preflight.log.
|
|
29
|
+
|
|
30
|
+
Persisting to a file matters because stdio MCP clients hide the server's
|
|
31
|
+
stderr in their own logs — without the file, users could only ever see
|
|
32
|
+
the truncated exit code, not the actual Chroma stack trace.
|
|
33
|
+
"""
|
|
34
|
+
line = f"[{datetime.now().isoformat(timespec='seconds')}] {msg}"
|
|
35
|
+
print(line, file=sys.stderr)
|
|
36
|
+
try:
|
|
37
|
+
log_file = config.data_dir / "preflight.log"
|
|
38
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
with log_file.open("a", encoding="utf-8") as f:
|
|
40
|
+
f.write(line + "\n")
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _backup_active_index(reason: str) -> Path:
|
|
46
|
+
"""Atomically move active ChromaDB state aside so the server can rebuild.
|
|
47
|
+
|
|
48
|
+
Uses os.rename (atomic on same volume) instead of shutil.move — the
|
|
49
|
+
fallback copy+rmtree path in shutil.move leaks a partial copy in
|
|
50
|
+
backups/ AND a partially deleted live chroma_db/ when another process
|
|
51
|
+
holds a segment file open (WinError 32).
|
|
52
|
+
|
|
53
|
+
The two moves (metadata JSON + chroma_db dir) must be all-or-nothing.
|
|
54
|
+
If we moved chroma_db first and metadata failed (antivirus scanning
|
|
55
|
+
the JSON, brief file lock), restart would find `chroma_db` gone but
|
|
56
|
+
`index_metadata.json` still live — incremental reindex would then
|
|
57
|
+
read the orphan metadata, mark every doc as "already indexed", and
|
|
58
|
+
silently produce an empty collection. To prevent that, metadata is
|
|
59
|
+
moved first (small file, low lock probability) and chroma_db is
|
|
60
|
+
wrapped in try/except that rolls the metadata rename back before
|
|
61
|
+
re-raising. Restart then sees the pristine pre-quarantine state
|
|
62
|
+
and re-runs preflight cleanly.
|
|
63
|
+
"""
|
|
64
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
65
|
+
backup_dir = config.data_dir / "backups" / f"auto-repair-{stamp}"
|
|
66
|
+
backup_dir.mkdir(parents=True, exist_ok=False)
|
|
67
|
+
|
|
68
|
+
metadata_file = config.data_dir / "index_metadata.json"
|
|
69
|
+
metadata_backup = backup_dir / f"index_metadata.{reason}.json"
|
|
70
|
+
metadata_moved = False
|
|
71
|
+
if metadata_file.exists():
|
|
72
|
+
os.rename(str(metadata_file), str(metadata_backup))
|
|
73
|
+
metadata_moved = True
|
|
74
|
+
|
|
75
|
+
if config.chroma_dir.exists():
|
|
76
|
+
try:
|
|
77
|
+
os.rename(str(config.chroma_dir), str(backup_dir / f"chroma_db.{reason}"))
|
|
78
|
+
except OSError:
|
|
79
|
+
if metadata_moved:
|
|
80
|
+
# Rollback the metadata move so the two files stay consistent.
|
|
81
|
+
# If the rollback itself fails (unlikely — dst dir is fresh)
|
|
82
|
+
# we still re-raise the original OSError so the caller can
|
|
83
|
+
# exit cleanly instead of leaving the system in a wedged state.
|
|
84
|
+
try:
|
|
85
|
+
os.rename(str(metadata_backup), str(metadata_file))
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
return backup_dir
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _probe_chroma(timeout_seconds: int = PROBE_TIMEOUT_SECONDS) -> subprocess.CompletedProcess[str]:
|
|
94
|
+
"""Check Chroma in a child process so native crashes do not kill MCP startup."""
|
|
95
|
+
code = r"""
|
|
96
|
+
import chromadb
|
|
97
|
+
|
|
98
|
+
from mcp_server.config import config
|
|
99
|
+
|
|
100
|
+
if not config.chroma_dir.exists():
|
|
101
|
+
print("missing")
|
|
102
|
+
raise SystemExit(0)
|
|
103
|
+
|
|
104
|
+
client = chromadb.PersistentClient(path=str(config.chroma_dir))
|
|
105
|
+
collection = client.get_or_create_collection(name=config.collection_name)
|
|
106
|
+
print(collection.count())
|
|
107
|
+
"""
|
|
108
|
+
env = os.environ.copy()
|
|
109
|
+
env.setdefault("KNOWLEDGE_RAG_DIR", str(BASE_DIR))
|
|
110
|
+
return subprocess.run(
|
|
111
|
+
[sys.executable, "-c", code],
|
|
112
|
+
cwd=str(BASE_DIR),
|
|
113
|
+
env=env,
|
|
114
|
+
text=True,
|
|
115
|
+
stdout=subprocess.PIPE,
|
|
116
|
+
stderr=subprocess.PIPE,
|
|
117
|
+
timeout=timeout_seconds,
|
|
118
|
+
check=False,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def run_preflight(timeout_seconds: int = PROBE_TIMEOUT_SECONDS) -> bool:
|
|
123
|
+
"""Return True when active Chroma state was moved aside for repair.
|
|
124
|
+
|
|
125
|
+
Runs the probe up to PROBE_ATTEMPTS times so transient failures
|
|
126
|
+
(file locks lingering from a previous PID, slow disk, cold GPU init)
|
|
127
|
+
do not trigger a destructive quarantine. TimeoutExpired is treated
|
|
128
|
+
as inconclusive — the index is kept and the user can debug from
|
|
129
|
+
preflight.log without losing the corpus. Only PROBE_ATTEMPTS
|
|
130
|
+
consecutive non-zero exits quarantine.
|
|
131
|
+
|
|
132
|
+
If os.rename fails while quarantining (e.g. another process still
|
|
133
|
+
holds a segment file open on Windows) the index is left untouched
|
|
134
|
+
and the process exits with EX_TEMPFAIL (75) instead of crashing
|
|
135
|
+
halfway with a partial move.
|
|
136
|
+
"""
|
|
137
|
+
last_result: subprocess.CompletedProcess[str] | None = None
|
|
138
|
+
for attempt in range(1, PROBE_ATTEMPTS + 1):
|
|
139
|
+
try:
|
|
140
|
+
result = _probe_chroma(timeout_seconds=timeout_seconds)
|
|
141
|
+
except subprocess.TimeoutExpired:
|
|
142
|
+
_log(
|
|
143
|
+
f"[PREFLIGHT] attempt {attempt}/{PROBE_ATTEMPTS}: probe timed out "
|
|
144
|
+
f"after {timeout_seconds}s — inconclusive, keeping index"
|
|
145
|
+
)
|
|
146
|
+
return False
|
|
147
|
+
if result.returncode == 0:
|
|
148
|
+
if attempt > 1:
|
|
149
|
+
_log(f"[PREFLIGHT] attempt {attempt}: OK (recovered from transient failure)")
|
|
150
|
+
return False
|
|
151
|
+
stderr_tail = (result.stderr or "").strip()[-1500:]
|
|
152
|
+
_log(
|
|
153
|
+
f"[PREFLIGHT] attempt {attempt}/{PROBE_ATTEMPTS}: probe failed with code {result.returncode}\n{stderr_tail}"
|
|
154
|
+
)
|
|
155
|
+
last_result = result
|
|
156
|
+
if attempt < PROBE_ATTEMPTS:
|
|
157
|
+
time.sleep(PROBE_RETRY_DELAY_SECONDS)
|
|
158
|
+
|
|
159
|
+
assert last_result is not None
|
|
160
|
+
reason = "segfault" if last_result.returncode in (-11, 139) else "failed"
|
|
161
|
+
_log(f"[RECOVERY] preflight failed {PROBE_ATTEMPTS}x (last exit code {last_result.returncode}); moving index aside")
|
|
162
|
+
try:
|
|
163
|
+
backup_dir = _backup_active_index(reason)
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
_log(
|
|
166
|
+
f"[PREFLIGHT] could not move index aside ({exc}); leaving it untouched "
|
|
167
|
+
f"to avoid partial corruption. Retry after freeing file handles."
|
|
168
|
+
)
|
|
169
|
+
raise SystemExit(75) from exc
|
|
170
|
+
_log(f"[RECOVERY] moved active index to {backup_dir}")
|
|
171
|
+
return True
|
|
@@ -4793,9 +4793,15 @@ def main():
|
|
|
4793
4793
|
print("[FALLBACK] Attempting regular index instead...")
|
|
4794
4794
|
stats = orchestrator.index_all(force=True)
|
|
4795
4795
|
elif orchestrator.collection.count() == 0:
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4796
|
+
# GH #216: initial indexing runs in a background thread so it
|
|
4797
|
+
# does not block the MCP `initialize` handshake. Blocking here
|
|
4798
|
+
# for a large corpus (~150k chunks takes minutes) caused stdio
|
|
4799
|
+
# clients to timeout, kill the process, and restart into an
|
|
4800
|
+
# empty index — an endless full-reindex loop. Users poll
|
|
4801
|
+
# get_reindex_status() to track progress.
|
|
4802
|
+
print("[INFO] No documents indexed. Starting initial indexing in background...")
|
|
4803
|
+
orchestrator.start_reindex_background("incremental")
|
|
4804
|
+
print("[INFO] Initial indexing runs in background. Track progress with get_reindex_status.")
|
|
4799
4805
|
|
|
4800
4806
|
# Start file watcher for auto-reindex on document changes
|
|
4801
4807
|
if os.environ.get("KNOWLEDGE_RAG_WATCHER_DISABLED", "").strip() == "1":
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "knowledge-rag"
|
|
7
|
-
version = "4.9.
|
|
7
|
+
version = "4.9.2"
|
|
8
8
|
description = "Local RAG System for Claude Code — Hybrid search + Cross-encoder Reranking + 13 MCP Tools + 20 Format Parsers. Zero external servers."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
"""Startup preflight checks for persistent ChromaDB state."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
import shutil
|
|
7
|
-
import subprocess
|
|
8
|
-
import sys
|
|
9
|
-
from datetime import datetime
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
from .config import BASE_DIR, config
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _backup_active_index(reason: str) -> Path:
|
|
16
|
-
"""Move active ChromaDB state aside so the server can rebuild cleanly."""
|
|
17
|
-
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
18
|
-
backup_dir = config.data_dir / "backups" / f"auto-repair-{stamp}"
|
|
19
|
-
backup_dir.mkdir(parents=True, exist_ok=False)
|
|
20
|
-
|
|
21
|
-
if config.chroma_dir.exists():
|
|
22
|
-
shutil.move(str(config.chroma_dir), str(backup_dir / f"chroma_db.{reason}"))
|
|
23
|
-
|
|
24
|
-
metadata_file = config.data_dir / "index_metadata.json"
|
|
25
|
-
if metadata_file.exists():
|
|
26
|
-
shutil.move(str(metadata_file), str(backup_dir / f"index_metadata.{reason}.json"))
|
|
27
|
-
|
|
28
|
-
return backup_dir
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _probe_chroma(timeout_seconds: int = 30) -> subprocess.CompletedProcess[str]:
|
|
32
|
-
"""Check Chroma in a child process so native crashes do not kill MCP startup."""
|
|
33
|
-
code = r"""
|
|
34
|
-
import chromadb
|
|
35
|
-
|
|
36
|
-
from mcp_server.config import config
|
|
37
|
-
|
|
38
|
-
if not config.chroma_dir.exists():
|
|
39
|
-
print("missing")
|
|
40
|
-
raise SystemExit(0)
|
|
41
|
-
|
|
42
|
-
client = chromadb.PersistentClient(path=str(config.chroma_dir))
|
|
43
|
-
collection = client.get_or_create_collection(name=config.collection_name)
|
|
44
|
-
print(collection.count())
|
|
45
|
-
"""
|
|
46
|
-
env = os.environ.copy()
|
|
47
|
-
env.setdefault("KNOWLEDGE_RAG_DIR", str(BASE_DIR))
|
|
48
|
-
return subprocess.run(
|
|
49
|
-
[sys.executable, "-c", code],
|
|
50
|
-
cwd=str(BASE_DIR),
|
|
51
|
-
env=env,
|
|
52
|
-
text=True,
|
|
53
|
-
stdout=subprocess.PIPE,
|
|
54
|
-
stderr=subprocess.PIPE,
|
|
55
|
-
timeout=timeout_seconds,
|
|
56
|
-
check=False,
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def run_preflight(timeout_seconds: int = 30) -> bool:
|
|
61
|
-
"""Return True when active Chroma state was moved aside for repair."""
|
|
62
|
-
result = _probe_chroma(timeout_seconds=timeout_seconds)
|
|
63
|
-
if result.returncode == 0:
|
|
64
|
-
return False
|
|
65
|
-
|
|
66
|
-
reason = "segfault" if result.returncode in (-11, 139) else "failed"
|
|
67
|
-
backup_dir = _backup_active_index(reason)
|
|
68
|
-
print(
|
|
69
|
-
f"[RECOVERY] Chroma preflight failed with code {result.returncode}; moved active index to {backup_dir}",
|
|
70
|
-
file=sys.stderr,
|
|
71
|
-
)
|
|
72
|
-
if result.stderr:
|
|
73
|
-
print(result.stderr[-2000:], file=sys.stderr)
|
|
74
|
-
return True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|