knowledge-rag 4.11.0__tar.gz → 5.0.0__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.11.0 → knowledge_rag-5.0.0}/PKG-INFO +1 -2
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/README.md +0 -1
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/bench/dashboard/README.md +3 -3
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/config.example.yaml +5 -5
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/__init__.py +1 -1
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/config.py +48 -6
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/data/config.example.yaml +5 -5
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/file_transaction.py +27 -3
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/fts5_index.py +3 -1
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/ingestion.py +1 -1
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/preflight.py +52 -19
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/security.py +6 -2
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/server.py +199 -60
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/pyproject.toml +4 -2
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/.gitignore +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/LICENSE +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/data/cybersecurity.yaml +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/data/developer.yaml +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/data/general.yaml +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/data/multilingual.yaml +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/data/research.yaml +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/filesystem.py +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/guarded.py +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/health.py +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/instance_lock.py +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/logging_config.py +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/metrics.py +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/pdf_support.py +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/publication.py +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/query_router.py +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/ratelimit.py +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/search_candidates.py +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/mcp_server/search_scope.py +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/npm/README.md +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/presets/cybersecurity.yaml +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/presets/developer.yaml +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/presets/general.yaml +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/presets/multilingual.yaml +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/presets/research.yaml +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/requirements.txt +0 -0
- {knowledge_rag-4.11.0 → knowledge_rag-5.0.0}/skills/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: knowledge-rag
|
|
3
|
-
Version:
|
|
3
|
+
Version: 5.0.0
|
|
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
|
|
@@ -87,7 +87,6 @@ Structured data (schema.org SoftwareApplication) — indexed by search engines,
|
|
|
87
87
|

|
|
88
88
|

|
|
89
89
|

|
|
90
|
-
[](https://github.com/lyonzin/knowledge-rag/actions/workflows/ci.yml)
|
|
91
90
|
[](https://github.com/lyonzin/knowledge-rag/actions/workflows/security.yml)
|
|
92
91
|
[](https://github.com/lyonzin/knowledge-rag/actions/workflows/quality-gate.yml)
|
|
93
92
|
[](https://glama.ai/mcp/servers/lyonzin/knowledge-rag)
|
|
@@ -38,7 +38,6 @@ Structured data (schema.org SoftwareApplication) — indexed by search engines,
|
|
|
38
38
|

|
|
39
39
|

|
|
40
40
|

|
|
41
|
-
[](https://github.com/lyonzin/knowledge-rag/actions/workflows/ci.yml)
|
|
42
41
|
[](https://github.com/lyonzin/knowledge-rag/actions/workflows/security.yml)
|
|
43
42
|
[](https://github.com/lyonzin/knowledge-rag/actions/workflows/quality-gate.yml)
|
|
44
43
|
[](https://glama.ai/mcp/servers/lyonzin/knowledge-rag)
|
|
@@ -51,13 +51,13 @@ python -m http.server 8000 --directory /tmp/dashboard-preview
|
|
|
51
51
|
| **FTS5** | `fts5 / lexical / fast[_-]?path` | 10 ms / 30 ms |
|
|
52
52
|
| **Reindex** | `reindex / rebuild / swap` | 10 s / 60 s |
|
|
53
53
|
| **Indexing** | `index / ingest / parse / chunk` | 100 ms / 500 ms |
|
|
54
|
-
| **Memory** | Explicit `measurement_unit: bytes` overrides name matching | Per-workload
|
|
54
|
+
| **Memory** | Explicit `measurement_unit: bytes` overrides name matching | Per-workload allocation budget in bytes |
|
|
55
55
|
| **Concurrent** | `concurrent / thread / parallel` | 100 ms / 500 ms |
|
|
56
56
|
| **Other** | — | 100 ms / 1 s |
|
|
57
57
|
|
|
58
58
|
New benchmark files under `bench/` are automatically categorized as long as the test name contains one of these keywords. The first matching pattern wins. Explicit byte measurements always use the memory category. A cache-hit latency benchmark remains a timing measurement rather than a RAM measurement. Timing thresholds are display heuristics, not service-level guarantees.
|
|
59
59
|
|
|
60
|
-
Memory rows read `
|
|
60
|
+
Memory rows read `tracemalloc_peak_bytes` and `memory_budget_bytes` from the benchmark's `extra_info`. Each workload runs five times under `tracemalloc` and applies an absolute budget to every run. The peak counts every Python allocation the workload makes, so it does not depend on pages the allocator already holds; native allocations outside Python's allocator are not counted. Older artifacts without traced bytes display `not measured` / `unavailable`; elapsed garbage-collection time is never relabeled as memory.
|
|
61
61
|
|
|
62
62
|
## Data schema (`data.json`)
|
|
63
63
|
|
|
@@ -82,6 +82,6 @@ Memory rows read `rss_peak_delta_bytes`, `rss_retained_delta_bytes`, `rss_budget
|
|
|
82
82
|
}
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
For memory rows, `measurement_unit` is `bytes`; `median_ns`, `stddev_ns`, `ops` and `iqr_ratio` are null. `measurement_value` contains
|
|
85
|
+
For memory rows, `measurement_unit` is `bytes`; `median_ns`, `stddev_ns`, `ops` and `iqr_ratio` are null. `measurement_value` contains the traced allocation peak or null when the legacy artifact has no traced bytes. `budget_bytes` is also kept. Sorting groups measurements by unit instead of treating bytes as nanoseconds.
|
|
86
86
|
|
|
87
87
|
`dashboard.js` enriches each row with `category`, `category_label`, `category_color`, `status`, and `stddev_pct` at render time. `workload_version` identifies changes to the benchmark scenario; the CI gate reports different versions as incomparable and keeps enforcing its 10% threshold for unchanged, comparable scenarios.
|
|
@@ -221,11 +221,11 @@ models:
|
|
|
221
221
|
|
|
222
222
|
# Query / passage prefixes — prepended to text BEFORE embedding.
|
|
223
223
|
# Required by some model families (intfloat/e5, thenlper/gte, ...);
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
query_prefix: ""
|
|
228
|
-
passage_prefix: ""
|
|
224
|
+
# not used by bge / mxbai. Keep them commented out with a profile: it
|
|
225
|
+
# sets its own prefixes, and any value here, even "", overrides them.
|
|
226
|
+
# Uncomment only for a custom model that needs them (e5 shown).
|
|
227
|
+
# query_prefix: "query: "
|
|
228
|
+
# passage_prefix: "passage: "
|
|
229
229
|
|
|
230
230
|
# GPU acceleration mode (v4.8.0+):
|
|
231
231
|
# "auto" → probe CUDA at first embedding; use if ready, fall back to CPU (default)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Configuration for Knowledge RAG System v4.0.0 — YAML-configurable"""
|
|
2
2
|
|
|
3
|
+
import datetime
|
|
3
4
|
import os
|
|
4
5
|
import sys
|
|
5
6
|
from dataclasses import dataclass, field
|
|
@@ -559,6 +560,11 @@ def _yaml_embedding_has(key: str) -> bool:
|
|
|
559
560
|
return key in emb
|
|
560
561
|
|
|
561
562
|
|
|
563
|
+
# YAML scalars that keyword_routes / category_mappings convert to text (GH #261).
|
|
564
|
+
# YAML reads an unquoted 2024-01-01 as a date; str() gives the folder name back.
|
|
565
|
+
_TEXT_SCALAR_TYPES = (str, int, float, bool, datetime.date)
|
|
566
|
+
|
|
567
|
+
|
|
562
568
|
# ============================================================================
|
|
563
569
|
# CONFIG DATACLASS
|
|
564
570
|
# ============================================================================
|
|
@@ -939,7 +945,11 @@ class Config:
|
|
|
939
945
|
self._apply_embedding_profile(profile)
|
|
940
946
|
|
|
941
947
|
def _apply_embedding_profile(self, profile: dict) -> None:
|
|
942
|
-
"""Copy profile model/dim into config; prefixes only if user did not declare them.
|
|
948
|
+
"""Copy profile model/dim into config; prefixes only if user did not declare them.
|
|
949
|
+
|
|
950
|
+
A declared prefix that differs from a non-empty profile prefix still wins,
|
|
951
|
+
with a warning, so an empty one left from an old template is visible (GH #261).
|
|
952
|
+
"""
|
|
943
953
|
if _yaml_embedding_has("model"):
|
|
944
954
|
print(
|
|
945
955
|
f"[WARN] Both models.embedding.model and profile="
|
|
@@ -953,6 +963,13 @@ class Config:
|
|
|
953
963
|
self.query_prefix = profile["query_prefix"]
|
|
954
964
|
if not _yaml_embedding_has("passage_prefix"):
|
|
955
965
|
self.passage_prefix = profile["passage_prefix"]
|
|
966
|
+
for key in ("query_prefix", "passage_prefix"):
|
|
967
|
+
if profile[key] and getattr(self, key) != profile[key]:
|
|
968
|
+
print(
|
|
969
|
+
f"[WARN] models.embedding.{key}={getattr(self, key)!r} overrides profile="
|
|
970
|
+
f"'{self.embedding_profile}' prefix {profile[key]!r}; remove the key to use the profile's",
|
|
971
|
+
file=sys.stderr,
|
|
972
|
+
)
|
|
956
973
|
|
|
957
974
|
def _validate_embedding_types(self) -> None:
|
|
958
975
|
"""Type-check prefixes + embedding_dim + reranker settings (runs AFTER profile)."""
|
|
@@ -1036,22 +1053,47 @@ class Config:
|
|
|
1036
1053
|
self.supported_formats = list(_DEFAULT_SUPPORTED_FORMATS)
|
|
1037
1054
|
|
|
1038
1055
|
def _validate_lists_and_maps(self) -> None:
|
|
1039
|
-
"""Type-check exclude_patterns + keyword_routes + query_expansions in order."""
|
|
1056
|
+
"""Type-check exclude_patterns + keyword_routes + category_mappings + query_expansions in order."""
|
|
1040
1057
|
self._validate_exclude_and_routes()
|
|
1041
1058
|
self._validate_query_expansions()
|
|
1042
1059
|
|
|
1043
1060
|
def _validate_exclude_and_routes(self) -> None:
|
|
1044
|
-
"""
|
|
1061
|
+
"""Keep exclude_patterns str-only; convert keyword_routes and category_mappings scalars to text.
|
|
1062
|
+
|
|
1063
|
+
YAML parses a bare ``4624`` as int, which breaks the str methods that
|
|
1064
|
+
indexing and query routing call on these tables (GH #261). YAML has
|
|
1065
|
+
already converted the scalar, so ``0123`` or ``yes`` must be quoted to
|
|
1066
|
+
keep their spelling. Null, list and mapping entries are skipped with a
|
|
1067
|
+
warning rather than turned into names such as ``"['a', 'b']"``. New
|
|
1068
|
+
dicts are built so the module-level defaults are never mutated.
|
|
1069
|
+
"""
|
|
1045
1070
|
if not isinstance(self.exclude_patterns, list):
|
|
1046
1071
|
print(f"[WARN] exclude_patterns={self.exclude_patterns!r} invalid, using []", file=sys.stderr)
|
|
1047
1072
|
self.exclude_patterns = []
|
|
1048
1073
|
else:
|
|
1049
1074
|
self.exclude_patterns = [p for p in self.exclude_patterns if isinstance(p, str)]
|
|
1050
1075
|
|
|
1051
|
-
|
|
1052
|
-
|
|
1076
|
+
routes: Dict[str, List[str]] = {}
|
|
1077
|
+
for cat, keywords in self.keyword_routes.items():
|
|
1078
|
+
if not isinstance(cat, _TEXT_SCALAR_TYPES):
|
|
1079
|
+
print(f"[WARN] keyword_routes: ignoring key {cat!r}", file=sys.stderr)
|
|
1080
|
+
elif not isinstance(keywords, list):
|
|
1053
1081
|
print(f"[WARN] keyword_routes.{cat} is not a list, removing", file=sys.stderr)
|
|
1054
|
-
|
|
1082
|
+
else:
|
|
1083
|
+
routes[str(cat)] = [str(k) for k in keywords if isinstance(k, _TEXT_SCALAR_TYPES)]
|
|
1084
|
+
for ignored in (k for k in keywords if not isinstance(k, _TEXT_SCALAR_TYPES)):
|
|
1085
|
+
print(f"[WARN] keyword_routes.{cat}: ignoring {ignored!r}", file=sys.stderr)
|
|
1086
|
+
self.keyword_routes = routes
|
|
1087
|
+
mappings: Dict[str, str] = {}
|
|
1088
|
+
for path, category in self.category_mappings.items():
|
|
1089
|
+
if not isinstance(path, _TEXT_SCALAR_TYPES):
|
|
1090
|
+
print(f"[WARN] category_mappings: ignoring key {path!r}", file=sys.stderr)
|
|
1091
|
+
elif not isinstance(category, _TEXT_SCALAR_TYPES):
|
|
1092
|
+
print(f"[WARN] category_mappings.{path}: ignoring {category!r}", file=sys.stderr)
|
|
1093
|
+
else:
|
|
1094
|
+
# _detect_category matches against the lower-cased relative path.
|
|
1095
|
+
mappings[str(path).lower()] = str(category)
|
|
1096
|
+
self.category_mappings = mappings
|
|
1055
1097
|
|
|
1056
1098
|
def _validate_query_expansions(self) -> None:
|
|
1057
1099
|
"""Type-check + merge query_expansions with query_expansion_groups."""
|
|
@@ -221,11 +221,11 @@ models:
|
|
|
221
221
|
|
|
222
222
|
# Query / passage prefixes — prepended to text BEFORE embedding.
|
|
223
223
|
# Required by some model families (intfloat/e5, thenlper/gte, ...);
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
query_prefix: ""
|
|
228
|
-
passage_prefix: ""
|
|
224
|
+
# not used by bge / mxbai. Keep them commented out with a profile: it
|
|
225
|
+
# sets its own prefixes, and any value here, even "", overrides them.
|
|
226
|
+
# Uncomment only for a custom model that needs them (e5 shown).
|
|
227
|
+
# query_prefix: "query: "
|
|
228
|
+
# passage_prefix: "passage: "
|
|
229
229
|
|
|
230
230
|
# GPU acceleration mode (v4.8.0+):
|
|
231
231
|
# "auto" → probe CUDA at first embedding; use if ready, fall back to CPU (default)
|
|
@@ -8,9 +8,33 @@ import tempfile
|
|
|
8
8
|
from collections.abc import Iterator
|
|
9
9
|
from contextlib import contextmanager
|
|
10
10
|
from pathlib import Path
|
|
11
|
+
from time import sleep
|
|
11
12
|
|
|
12
13
|
logger = logging.getLogger(__name__)
|
|
13
14
|
|
|
15
|
+
# ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION: a scanner, indexer or reader holds
|
|
16
|
+
# the path without FILE_SHARE_DELETE, and Windows cannot replace over it until it lets go.
|
|
17
|
+
_WINDOWS_HELD_ERRORS = frozenset({5, 32})
|
|
18
|
+
_REPLACE_RETRY_DELAYS = (0.05, 0.1, 0.2, 0.4, 0.8)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def replace_with_retry(source: str | os.PathLike[str], destination: str | os.PathLike[str]) -> None:
|
|
22
|
+
"""``os.replace`` that waits out a transient Windows handle on either path (GH #249).
|
|
23
|
+
|
|
24
|
+
Only PermissionError carrying winerror 5 or 32 is retried, for about 1.5 s; then
|
|
25
|
+
it is re-raised, so a real permission problem still surfaces. Elsewhere this is
|
|
26
|
+
a plain ``os.replace``.
|
|
27
|
+
"""
|
|
28
|
+
for delay in _REPLACE_RETRY_DELAYS:
|
|
29
|
+
try:
|
|
30
|
+
os.replace(source, destination)
|
|
31
|
+
return
|
|
32
|
+
except PermissionError as exc:
|
|
33
|
+
if getattr(exc, "winerror", None) not in _WINDOWS_HELD_ERRORS:
|
|
34
|
+
raise
|
|
35
|
+
sleep(delay)
|
|
36
|
+
os.replace(source, destination)
|
|
37
|
+
|
|
14
38
|
|
|
15
39
|
class StagedTextFile:
|
|
16
40
|
"""Prepare beside the destination so rename works on every supported OS."""
|
|
@@ -47,19 +71,19 @@ class StagedTextFile:
|
|
|
47
71
|
) as stream:
|
|
48
72
|
backup = Path(stream.name)
|
|
49
73
|
try:
|
|
50
|
-
|
|
74
|
+
replace_with_retry(self.destination, backup)
|
|
51
75
|
except BaseException:
|
|
52
76
|
backup.unlink(missing_ok=True)
|
|
53
77
|
raise
|
|
54
78
|
self.backup = backup
|
|
55
|
-
|
|
79
|
+
replace_with_retry(self.path, self.destination)
|
|
56
80
|
self.published = True
|
|
57
81
|
|
|
58
82
|
def close(self) -> None:
|
|
59
83
|
"""Restore old bytes after failure; never delete a backup whose restore failed."""
|
|
60
84
|
if not self.committed:
|
|
61
85
|
if self.backup is not None:
|
|
62
|
-
|
|
86
|
+
replace_with_retry(self.backup, self.destination)
|
|
63
87
|
elif self.published:
|
|
64
88
|
self.destination.unlink(missing_ok=True)
|
|
65
89
|
for temporary in (self.path, self.backup):
|
|
@@ -27,6 +27,8 @@ from itertools import islice
|
|
|
27
27
|
from pathlib import Path
|
|
28
28
|
from typing import Any, Callable, Iterable, List, Optional, Sequence, Tuple
|
|
29
29
|
|
|
30
|
+
from .file_transaction import replace_with_retry
|
|
31
|
+
|
|
30
32
|
ChunkRow = Tuple[str, str, str, str]
|
|
31
33
|
ChunkIterFactory = Callable[[], Iterable[ChunkRow]]
|
|
32
34
|
ProgressCallback = Callable[[int, int], None]
|
|
@@ -137,7 +139,7 @@ class Fts5MigrationState:
|
|
|
137
139
|
json.dump(payload, tmp)
|
|
138
140
|
tmp.flush()
|
|
139
141
|
os.fsync(tmp.fileno())
|
|
140
|
-
|
|
142
|
+
replace_with_retry(tmp_name, self._path)
|
|
141
143
|
except Exception:
|
|
142
144
|
try:
|
|
143
145
|
os.unlink(tmp_name)
|
|
@@ -406,7 +406,7 @@ class DocumentParser:
|
|
|
406
406
|
]
|
|
407
407
|
|
|
408
408
|
for fname in files:
|
|
409
|
-
if fname.startswith(".rag-pending-"):
|
|
409
|
+
if fname.startswith((".rag-pending-", "~$")): # staging file or Office owner file
|
|
410
410
|
continue
|
|
411
411
|
filepath = Path(root) / fname
|
|
412
412
|
# supported_formats holds suffixes (".go") and exact filenames ("Dockerfile")
|
|
@@ -10,8 +10,10 @@ fixes below were validated on their environment before merging.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import contextlib
|
|
13
14
|
import os
|
|
14
15
|
import re
|
|
16
|
+
import shutil
|
|
15
17
|
import subprocess
|
|
16
18
|
import sys
|
|
17
19
|
import time
|
|
@@ -23,6 +25,8 @@ from .config import BASE_DIR, _get, config
|
|
|
23
25
|
PROBE_ATTEMPTS = 3
|
|
24
26
|
PROBE_RETRY_DELAY_SECONDS = 5
|
|
25
27
|
PROBE_TIMEOUT_SECONDS = 30
|
|
28
|
+
AUTO_REPAIR_BACKUPS_KEPT = 3 # GH #267: unbounded auto-repair backups once filled a disk (197 copies, 76 GB)
|
|
29
|
+
_AUTO_REPAIR_DIR_NAME = re.compile(r"auto-repair-[0-9]{8}-[0-9]{6}") # what _backup_active_index creates
|
|
26
30
|
|
|
27
31
|
# Cloud-sync folders break ChromaDB (HNSW mmap) and SQLite WAL/SHM:
|
|
28
32
|
# the provider opens handles on live files to upload them, racing with
|
|
@@ -134,32 +138,50 @@ def _backup_active_index(reason: str) -> Path:
|
|
|
134
138
|
metadata_file = config.data_dir / "index_metadata.json"
|
|
135
139
|
metadata_backup = backup_dir / f"index_metadata.{reason}.json"
|
|
136
140
|
metadata_moved = False
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
try:
|
|
141
|
+
try:
|
|
142
|
+
if metadata_file.exists():
|
|
143
|
+
os.rename(str(metadata_file), str(metadata_backup))
|
|
144
|
+
metadata_moved = True
|
|
145
|
+
if config.chroma_dir.exists():
|
|
143
146
|
os.rename(str(config.chroma_dir), str(backup_dir / f"chroma_db.{reason}"))
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
147
|
+
except OSError:
|
|
148
|
+
if metadata_moved:
|
|
149
|
+
# Rollback the metadata move so the two files stay consistent.
|
|
150
|
+
# If the rollback itself fails (unlikely — dst dir is fresh)
|
|
151
|
+
# we still re-raise the original OSError so the caller can
|
|
152
|
+
# exit cleanly instead of leaving the system in a wedged state.
|
|
153
|
+
with contextlib.suppress(OSError):
|
|
154
|
+
os.rename(str(metadata_backup), str(metadata_file))
|
|
155
|
+
raise
|
|
156
|
+
finally:
|
|
157
|
+
with contextlib.suppress(OSError):
|
|
158
|
+
backup_dir.rmdir() # GH #267: an empty folder would take a kept slot; rmdir spares any holding data
|
|
155
159
|
|
|
156
160
|
return backup_dir
|
|
157
161
|
|
|
158
162
|
|
|
163
|
+
def _prune_auto_repair_backups(new_backup: Path) -> None:
|
|
164
|
+
"""Keep only the newest AUTO_REPAIR_BACKUPS_KEPT auto-repair backups, ``new_backup`` included."""
|
|
165
|
+
with os.scandir(new_backup.parent) as entries:
|
|
166
|
+
# Symlinks are skipped here; rmtree refuses Windows junctions, so no link is ever followed.
|
|
167
|
+
other_dirs = {entry.name for entry in entries if entry.is_dir(follow_symlinks=False)} - {new_backup.name}
|
|
168
|
+
older = sorted((name for name in other_dirs if _AUTO_REPAIR_DIR_NAME.fullmatch(name)), reverse=True)
|
|
169
|
+
removed = 0
|
|
170
|
+
# new_backup takes one kept slot even when a clock change made its stamp sort below the others.
|
|
171
|
+
for name in older[AUTO_REPAIR_BACKUPS_KEPT - 1 :]:
|
|
172
|
+
try:
|
|
173
|
+
shutil.rmtree(new_backup.parent / name)
|
|
174
|
+
removed += 1
|
|
175
|
+
except OSError as exc: # e.g. a file still held open on Windows
|
|
176
|
+
_log(f"[RECOVERY] could not delete old backup {new_backup.parent / name} ({exc}); will retry next repair")
|
|
177
|
+
_log(f"[RECOVERY] removed {removed} old auto-repair backup(s)")
|
|
178
|
+
|
|
179
|
+
|
|
159
180
|
def _probe_chroma(timeout_seconds: int = PROBE_TIMEOUT_SECONDS) -> subprocess.CompletedProcess[str]:
|
|
160
181
|
"""Check Chroma in a child process so native crashes do not kill MCP startup."""
|
|
161
182
|
code = r"""
|
|
162
183
|
import chromadb
|
|
184
|
+
from chromadb.errors import NotFoundError
|
|
163
185
|
|
|
164
186
|
from mcp_server.config import config
|
|
165
187
|
|
|
@@ -168,7 +190,13 @@ if not config.chroma_dir.exists():
|
|
|
168
190
|
raise SystemExit(0)
|
|
169
191
|
|
|
170
192
|
client = chromadb.PersistentClient(path=str(config.chroma_dir))
|
|
171
|
-
|
|
193
|
+
try:
|
|
194
|
+
# GH #248/#252: read only. Creating it here persisted Chroma's default
|
|
195
|
+
# embedding function, which the server then refused to open.
|
|
196
|
+
collection = client.get_collection(name=config.collection_name, embedding_function=None)
|
|
197
|
+
except NotFoundError:
|
|
198
|
+
print("missing")
|
|
199
|
+
raise SystemExit(0)
|
|
172
200
|
print(collection.count())
|
|
173
201
|
"""
|
|
174
202
|
env = os.environ.copy()
|
|
@@ -235,7 +263,12 @@ def run_preflight(timeout_seconds: int = PROBE_TIMEOUT_SECONDS) -> bool:
|
|
|
235
263
|
f"to avoid partial corruption. Retry after freeing file handles."
|
|
236
264
|
)
|
|
237
265
|
raise SystemExit(75) from exc
|
|
238
|
-
|
|
266
|
+
try:
|
|
267
|
+
if backup_dir.exists(): # GH #267: the empty folder is dropped when there was nothing to move
|
|
268
|
+
_log(f"[RECOVERY] moved active index to {backup_dir}")
|
|
269
|
+
_prune_auto_repair_backups(backup_dir)
|
|
270
|
+
except OSError as exc: # best effort: old backups never fail the repair
|
|
271
|
+
_log(f"[RECOVERY] could not prune old backups in {backup_dir.parent} ({exc})")
|
|
239
272
|
return True
|
|
240
273
|
|
|
241
274
|
|
|
@@ -272,6 +272,10 @@ def neutralize_injection_sentinels(text: str) -> str:
|
|
|
272
272
|
return text
|
|
273
273
|
|
|
274
274
|
|
|
275
|
+
#: Longest source identifier the provenance marker keeps; longer ones are cut.
|
|
276
|
+
_MAX_SOURCE_CHARS: Final[int] = 512
|
|
277
|
+
|
|
278
|
+
|
|
275
279
|
def _escape_attribute(value: str) -> str:
|
|
276
280
|
"""Make a source identifier safe to embed in an XML-ish attribute.
|
|
277
281
|
|
|
@@ -284,11 +288,11 @@ def _escape_attribute(value: str) -> str:
|
|
|
284
288
|
|
|
285
289
|
Returns:
|
|
286
290
|
str: Value with quotes, angle brackets and control characters removed,
|
|
287
|
-
truncated to 512 characters.
|
|
291
|
+
truncated to ``_MAX_SOURCE_CHARS`` (512) characters.
|
|
288
292
|
"""
|
|
289
293
|
cleaned = "".join(ch for ch in value if ch.isprintable())
|
|
290
294
|
cleaned = cleaned.replace('"', "").replace("'", "").replace("<", "").replace(">", "")
|
|
291
|
-
return cleaned[:
|
|
295
|
+
return cleaned[:_MAX_SOURCE_CHARS]
|
|
292
296
|
|
|
293
297
|
|
|
294
298
|
def wrap_external_content(content: str, source: str, digest: str | None = None) -> str:
|
|
@@ -64,7 +64,7 @@ from watchdog.observers import Observer
|
|
|
64
64
|
# Local imports
|
|
65
65
|
from . import __version__
|
|
66
66
|
from .config import config
|
|
67
|
-
from .file_transaction import staged_text_file
|
|
67
|
+
from .file_transaction import replace_with_retry, staged_text_file
|
|
68
68
|
from .filesystem import io_path
|
|
69
69
|
from .fts5_index import Fts5LexicalIndex, Fts5MigrationError, Fts5NotReadyError
|
|
70
70
|
from .ingestion import Document, DocumentParser
|
|
@@ -85,8 +85,12 @@ from .ratelimit import rate_limited
|
|
|
85
85
|
from .search_candidates import select_rerank_candidates
|
|
86
86
|
from .search_scope import FolderScope, ScopeKey
|
|
87
87
|
from .security import (
|
|
88
|
+
_MAX_SOURCE_CHARS,
|
|
88
89
|
BearerAuthMiddleware,
|
|
89
90
|
PathEscapeError,
|
|
91
|
+
_escape_attribute,
|
|
92
|
+
content_sha256,
|
|
93
|
+
detect_external_marker,
|
|
90
94
|
is_path_within,
|
|
91
95
|
sanitize_external_content,
|
|
92
96
|
validate_path_within,
|
|
@@ -1468,7 +1472,7 @@ class DocumentWatcher(FileSystemEventHandler):
|
|
|
1468
1472
|
Suffixes are lowercased to match DocumentParser.parse_directory().
|
|
1469
1473
|
"""
|
|
1470
1474
|
p = Path(path)
|
|
1471
|
-
if p.name.startswith(".rag-pending-"):
|
|
1475
|
+
if p.name.startswith((".rag-pending-", "~$")): # staging file or Office owner file
|
|
1472
1476
|
return False
|
|
1473
1477
|
return p.suffix.lower() in config.supported_formats or p.name in config.supported_formats
|
|
1474
1478
|
|
|
@@ -1541,6 +1545,30 @@ class DocumentWatcher(FileSystemEventHandler):
|
|
|
1541
1545
|
# distinguishes from ``None`` (no existing doc yet, index as fresh).
|
|
1542
1546
|
_SKIP_DOC = object()
|
|
1543
1547
|
|
|
1548
|
+
# Characters read to find a saved page's provenance marker. It is the first line,
|
|
1549
|
+
# and its tag, source (at most 512 characters) and SHA-256 take at most 614.
|
|
1550
|
+
_MARKER_HEAD_CHARS = 1024
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def _is_saved_from(path: Path, url: str) -> bool:
|
|
1554
|
+
"""Whether the provenance marker at the top of ``path`` proves it holds ``url``'s page.
|
|
1555
|
+
|
|
1556
|
+
A file without the marker, or one that cannot be read, proves nothing. The
|
|
1557
|
+
marker keeps the URL as ``_escape_attribute`` leaves it (quotes, angle brackets
|
|
1558
|
+
and non-printable characters removed), so two URLs that differ only in those
|
|
1559
|
+
characters count as the same page. A marker URL cut at ``_MAX_SOURCE_CHARS``
|
|
1560
|
+
proves nothing either, so each re-add of a URL that long takes the next free
|
|
1561
|
+
name, until the full-digest name is reached and then refreshed.
|
|
1562
|
+
"""
|
|
1563
|
+
try:
|
|
1564
|
+
with path.open(encoding="utf-8", errors="replace") as page:
|
|
1565
|
+
marker = detect_external_marker(page.read(_MARKER_HEAD_CHARS))
|
|
1566
|
+
except OSError as exc:
|
|
1567
|
+
# Held by another program or an offline cloud placeholder: owner unproven.
|
|
1568
|
+
print(f"[WARN] add_from_url: cannot read {path}, saving under a hashed name: {exc}", file=sys.stderr)
|
|
1569
|
+
return False
|
|
1570
|
+
return marker is not None and len(marker[0]) < _MAX_SOURCE_CHARS and marker[0] == _escape_attribute(url)
|
|
1571
|
+
|
|
1544
1572
|
|
|
1545
1573
|
class KnowledgeOrchestrator:
|
|
1546
1574
|
"""Main orchestrator for knowledge retrieval with semantic search + keyword routing"""
|
|
@@ -1642,17 +1670,41 @@ class KnowledgeOrchestrator:
|
|
|
1642
1670
|
def _safe_get_collection(self):
|
|
1643
1671
|
"""Open the collection without discarding data on initialization errors."""
|
|
1644
1672
|
try:
|
|
1645
|
-
return self.
|
|
1646
|
-
name=config.collection_name,
|
|
1647
|
-
embedding_function=self.embed_fn,
|
|
1648
|
-
metadata={"description": "Knowledge base for RAG"},
|
|
1649
|
-
)
|
|
1673
|
+
return self._open_collection()
|
|
1650
1674
|
except Exception as exc:
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1675
|
+
if not self._drop_empty_default_ef_collection():
|
|
1676
|
+
raise RuntimeError(
|
|
1677
|
+
f"Could not open ChromaDB collection {config.collection_name!r} "
|
|
1678
|
+
f"at {config.chroma_dir}: {exc}. Existing index preserved. "
|
|
1679
|
+
"Check database access and embedding configuration before retrying."
|
|
1680
|
+
) from exc
|
|
1681
|
+
return self._open_collection()
|
|
1682
|
+
|
|
1683
|
+
def _open_collection(self) -> Any:
|
|
1684
|
+
"""Get or create the production collection bound to the FastEmbed function."""
|
|
1685
|
+
return self.chroma_client.get_or_create_collection(
|
|
1686
|
+
name=config.collection_name,
|
|
1687
|
+
embedding_function=self.embed_fn,
|
|
1688
|
+
metadata={"description": "Knowledge base for RAG"},
|
|
1689
|
+
)
|
|
1690
|
+
|
|
1691
|
+
def _drop_empty_default_ef_collection(self) -> bool:
|
|
1692
|
+
"""Drop the collection only when it is empty and bound to Chroma's default EF.
|
|
1693
|
+
|
|
1694
|
+
GH #248/#252: the preflight probe used to create it without an embedding
|
|
1695
|
+
function, so Chroma persisted ``default`` and refused FastEmbed on every
|
|
1696
|
+
later open. An empty one holds nothing; one with data is never touched.
|
|
1697
|
+
"""
|
|
1698
|
+
try:
|
|
1699
|
+
stale = self.chroma_client.get_collection(name=config.collection_name, embedding_function=None)
|
|
1700
|
+
ef_name = (stale.configuration_json.get("embedding_function") or {}).get("name")
|
|
1701
|
+
if ef_name != "default" or stale.count() != 0:
|
|
1702
|
+
return False
|
|
1703
|
+
self.chroma_client.delete_collection(name=config.collection_name)
|
|
1704
|
+
except Exception:
|
|
1705
|
+
return False
|
|
1706
|
+
print("[RECOVERY] Recreating empty collection bound to Chroma's default embedding (GH #248)", file=sys.stderr)
|
|
1707
|
+
return True
|
|
1656
1708
|
|
|
1657
1709
|
def _check_dimension_mismatch(self) -> bool:
|
|
1658
1710
|
"""Check if stored embeddings have different dimension than current config.
|
|
@@ -1721,8 +1773,10 @@ class KnowledgeOrchestrator:
|
|
|
1721
1773
|
try:
|
|
1722
1774
|
count = self.collection.count()
|
|
1723
1775
|
if count == 0:
|
|
1724
|
-
# Empty collection — nothing to build
|
|
1725
|
-
#
|
|
1776
|
+
# Empty collection — nothing to build, and the old generation must
|
|
1777
|
+
# not outlive its chunks (#259). Leave flag False so a later
|
|
1778
|
+
# call retries once documents become available.
|
|
1779
|
+
self.bm25_index.clear()
|
|
1726
1780
|
return
|
|
1727
1781
|
|
|
1728
1782
|
# Batch to avoid SQLite "too many SQL variables" (chromadb 1.x
|
|
@@ -1851,8 +1905,17 @@ class KnowledgeOrchestrator:
|
|
|
1851
1905
|
|
|
1852
1906
|
Done BEFORE indexing so moved files are not blocked by stale content
|
|
1853
1907
|
hashes. Mutates ``stats`` (chunks_removed, deleted) and evicts from
|
|
1854
|
-
both ``_indexed_docs`` and ``_source_to_docid``.
|
|
1908
|
+
both ``_indexed_docs`` and ``_source_to_docid``. An unavailable or empty
|
|
1909
|
+
``documents_dir`` (unplugged drive, unmounted share, folder recreated at
|
|
1910
|
+
startup) prunes nothing; remove_document removes on purpose (#259).
|
|
1855
1911
|
"""
|
|
1912
|
+
if not io_path(config.documents_dir).is_dir() or (not documents and self._indexed_docs):
|
|
1913
|
+
print(
|
|
1914
|
+
f"[WARN] documents_dir unavailable or empty, skipping orphan pruning: {config.documents_dir}",
|
|
1915
|
+
file=sys.stderr,
|
|
1916
|
+
)
|
|
1917
|
+
stats["errors"] += 1
|
|
1918
|
+
return
|
|
1856
1919
|
current_paths = {str(Path(getattr(doc, "source", doc)).resolve()) for doc in documents}
|
|
1857
1920
|
for doc_id, info in list(self._indexed_docs.items()):
|
|
1858
1921
|
source = Path(info.get("source", ""))
|
|
@@ -1945,11 +2008,20 @@ class KnowledgeOrchestrator:
|
|
|
1945
2008
|
if not is_path_within(config.documents_dir, doc):
|
|
1946
2009
|
raise PathEscapeError(f"File escaped documents directory: {doc}")
|
|
1947
2010
|
doc = self.parser.parse_file(doc)
|
|
1948
|
-
if doc is None:
|
|
1949
|
-
raise ValueError("Document has no indexable content")
|
|
1950
2011
|
after_parse = io_path(source).stat()
|
|
1951
2012
|
if (source_stat.st_mtime_ns, source_stat.st_size) != (after_parse.st_mtime_ns, after_parse.st_size):
|
|
1952
2013
|
raise RuntimeError("Source changed during parsing; retry indexing this document")
|
|
2014
|
+
# Empty or text-less files are skipped, never an error that aborts a rebuild (#259).
|
|
2015
|
+
if doc is None:
|
|
2016
|
+
if repair_ids is not None:
|
|
2017
|
+
self._remove_repair_chunks(source, [value for value in repair_ids if value != existing])
|
|
2018
|
+
if existing:
|
|
2019
|
+
self._evict_stale_doc(existing, stats)
|
|
2020
|
+
else:
|
|
2021
|
+
stats["skipped"] += 1
|
|
2022
|
+
if repair_ids is not None:
|
|
2023
|
+
tracking["repaired_sources"].add(str(source.resolve()))
|
|
2024
|
+
return
|
|
1953
2025
|
existing_doc_id = self._resolve_existing_or_skip(doc, force, path_to_docid, stats, tracking)
|
|
1954
2026
|
if existing_doc_id is _SKIP_DOC:
|
|
1955
2027
|
return
|
|
@@ -2042,14 +2114,15 @@ class KnowledgeOrchestrator:
|
|
|
2042
2114
|
return stored_mtime == current_mtime and stored_size == current_size
|
|
2043
2115
|
|
|
2044
2116
|
def _evict_stale_doc(self, existing_doc_id: str, stats: Dict[str, Any]) -> None:
|
|
2045
|
-
"""Remove chunks + metadata
|
|
2117
|
+
"""Remove chunks + metadata of a doc whose file has no indexable content left. Bumps deleted."""
|
|
2046
2118
|
removed = self._remove_document_chunks(existing_doc_id)
|
|
2047
2119
|
stats["chunks_removed"] += removed
|
|
2048
2120
|
src = self._indexed_docs[existing_doc_id].get("source", "")
|
|
2049
2121
|
if src:
|
|
2050
2122
|
self._source_to_docid.pop(str(Path(src).resolve()), None)
|
|
2051
2123
|
del self._indexed_docs[existing_doc_id]
|
|
2052
|
-
stats["
|
|
2124
|
+
stats["deleted"] += 1
|
|
2125
|
+
print(f"[WARN] Removed {removed} chunks of {src}: the file has no indexable content", file=sys.stderr)
|
|
2053
2126
|
|
|
2054
2127
|
def _register_indexed_doc(self, doc, chunks_added: int, source_stat=None) -> None:
|
|
2055
2128
|
"""Record the source version actually parsed, before potentially slow inference."""
|
|
@@ -2387,6 +2460,17 @@ class KnowledgeOrchestrator:
|
|
|
2387
2460
|
if self._reindex_progress.get("active"):
|
|
2388
2461
|
return {"status": "already_running", "progress": dict(self._reindex_progress)}
|
|
2389
2462
|
|
|
2463
|
+
# GH #249: index_all refuses at once while a watcher pass or a document
|
|
2464
|
+
# write holds the index lock, so "started" would hide the refusal.
|
|
2465
|
+
# nuclear_rebuild waits for the lock instead.
|
|
2466
|
+
# ponytail: a probe, not admission. A pass that takes the lock between this
|
|
2467
|
+
# check and the worker's index_all is still refused, as before, and shows up
|
|
2468
|
+
# as last_result.skipped_reason; move admission into the worker if it matters.
|
|
2469
|
+
if mode != "nuclear_rebuild":
|
|
2470
|
+
if not self._index_lock.acquire(blocking=False):
|
|
2471
|
+
return {"status": "already_running", "progress": {**self._reindex_progress, "operation": None}}
|
|
2472
|
+
self._index_lock.release()
|
|
2473
|
+
|
|
2390
2474
|
self._reindex_progress = self._fresh_reindex_progress(mode, resume_state)
|
|
2391
2475
|
|
|
2392
2476
|
# GH #163 (v4.8.3): propagate force through smart_reindex so
|
|
@@ -2440,7 +2524,7 @@ class KnowledgeOrchestrator:
|
|
|
2440
2524
|
resume_state: Optional[Dict[str, Any]] = None,
|
|
2441
2525
|
force: bool = False,
|
|
2442
2526
|
) -> Dict[str, Any]:
|
|
2443
|
-
"""Smart reindex:
|
|
2527
|
+
"""Smart reindex: index_all (unchanged files skipped unless ``force``) + BM25 rebuild + orphan cleanup.
|
|
2444
2528
|
|
|
2445
2529
|
When ``resume_state`` is provided (v4.8.0 Fase 4), docs listed in
|
|
2446
2530
|
it are skipped so the interrupted previous run picks up where it
|
|
@@ -2471,7 +2555,7 @@ class KnowledgeOrchestrator:
|
|
|
2471
2555
|
return stats
|
|
2472
2556
|
|
|
2473
2557
|
print("[REINDEX] Rebuilding BM25 index...", file=sys.stderr)
|
|
2474
|
-
|
|
2558
|
+
# No clear(): queries keep the published generation until the rebuild replaces it (#259).
|
|
2475
2559
|
self._bm25_initialized = False
|
|
2476
2560
|
self._ensure_bm25_index()
|
|
2477
2561
|
|
|
@@ -3704,7 +3788,8 @@ class KnowledgeOrchestrator:
|
|
|
3704
3788
|
window: Number of adjacent chunks to fetch on each side (default: 1)
|
|
3705
3789
|
|
|
3706
3790
|
Returns:
|
|
3707
|
-
Results with expanded content field
|
|
3791
|
+
Results with expanded content field; ``match_offset`` marks where the
|
|
3792
|
+
matched chunk starts inside it, so snippets can skip the context before it.
|
|
3708
3793
|
"""
|
|
3709
3794
|
if not results:
|
|
3710
3795
|
return results
|
|
@@ -3757,6 +3842,7 @@ class KnowledgeOrchestrator:
|
|
|
3757
3842
|
expanded = "\n\n".join(parts_before + [results[result_idx]["content"]] + parts_after)
|
|
3758
3843
|
results[result_idx]["content"] = expanded
|
|
3759
3844
|
results[result_idx]["context_expanded"] = True
|
|
3845
|
+
results[result_idx]["match_offset"] = sum(len(part) + len("\n\n") for part in parts_before)
|
|
3760
3846
|
|
|
3761
3847
|
return results
|
|
3762
3848
|
|
|
@@ -4064,14 +4150,36 @@ class KnowledgeOrchestrator:
|
|
|
4064
4150
|
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
4065
4151
|
clean_text = f"# {title}\n\nSource: {url}\n\n" + "\n\n".join(lines)
|
|
4066
4152
|
|
|
4067
|
-
safe_title = re.sub(r"[^\w\s-]", "", title).strip().replace(" ", "-").lower()[:60]
|
|
4068
|
-
filename = f"{safe_title}.md"
|
|
4069
|
-
filepath = f"{category}/{filename}"
|
|
4153
|
+
safe_title = re.sub(r"[^\w\s-]", "", title).strip().replace(" ", "-").lower()[:60] or "page"
|
|
4070
4154
|
|
|
4071
4155
|
# The body was fetched from an untrusted URL; the sanitizer inserts a
|
|
4072
4156
|
# provenance fence and defuses model control tokens before the text
|
|
4073
4157
|
# ever reaches the parser or the LLM that will consume the RAG output.
|
|
4074
|
-
|
|
4158
|
+
# The lock spans name check and write, so parallel adds cannot both claim a free name.
|
|
4159
|
+
with self._index_lock:
|
|
4160
|
+
filepath = self._url_document_path(url, category, safe_title)
|
|
4161
|
+
return self.add_document_from_content(clean_text, filepath, category, external_source=url)
|
|
4162
|
+
|
|
4163
|
+
@staticmethod
|
|
4164
|
+
def _url_document_path(url: str, category: str, safe_title: str) -> str:
|
|
4165
|
+
"""Pick the first name that no other source owns for ``url``'s page (GH #262).
|
|
4166
|
+
|
|
4167
|
+
Titles are cut to 60 characters, so different pages can map to one name.
|
|
4168
|
+
Tried in order: the title-based name, then that name plus the first 8 and
|
|
4169
|
+
16 hex characters of the URL's SHA-256. The first that is free, or that
|
|
4170
|
+
``_is_saved_from`` shows already holds this URL's page, wins. The name with
|
|
4171
|
+
the full digest is the last resort and is used without a check.
|
|
4172
|
+
"""
|
|
4173
|
+
digest = content_sha256(url)
|
|
4174
|
+
for stem in (safe_title, f"{safe_title}-{digest[:8]}", f"{safe_title}-{digest[:16]}"):
|
|
4175
|
+
filepath = f"{category}/{stem}.md"
|
|
4176
|
+
try:
|
|
4177
|
+
existing = validate_path_within(config.documents_dir, filepath)
|
|
4178
|
+
except PathEscapeError:
|
|
4179
|
+
return filepath # add_document_from_content rejects it and reports why
|
|
4180
|
+
if not existing.is_file() or _is_saved_from(existing, url):
|
|
4181
|
+
return filepath
|
|
4182
|
+
return f"{category}/{safe_title}-{digest}.md"
|
|
4075
4183
|
|
|
4076
4184
|
@collection_reader
|
|
4077
4185
|
def search_similar(self, filepath: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
|
@@ -4097,16 +4205,21 @@ class KnowledgeOrchestrator:
|
|
|
4097
4205
|
if not results["ids"] or embeddings is None or len(embeddings) == 0:
|
|
4098
4206
|
return []
|
|
4099
4207
|
query_embedding = embeddings[0]
|
|
4100
|
-
except Exception:
|
|
4208
|
+
except Exception as exc:
|
|
4209
|
+
print(f"[WARN] search_similar query failed for {filepath!r}: {exc}", file=sys.stderr)
|
|
4101
4210
|
return []
|
|
4102
4211
|
|
|
4103
4212
|
try:
|
|
4213
|
+
# Exclude the reference in the query: filtering afterwards let a long
|
|
4214
|
+
# document's own chunks take every candidate slot.
|
|
4104
4215
|
similar = self.collection.query(
|
|
4105
4216
|
query_embeddings=[query_embedding],
|
|
4106
4217
|
n_results=max_results + 20,
|
|
4218
|
+
where={"doc_id": {"$ne": doc_id}},
|
|
4107
4219
|
include=["documents", "metadatas", "distances"],
|
|
4108
4220
|
)
|
|
4109
|
-
except Exception:
|
|
4221
|
+
except Exception as exc:
|
|
4222
|
+
print(f"[WARN] search_similar query failed for {filepath!r}: {exc}", file=sys.stderr)
|
|
4110
4223
|
return []
|
|
4111
4224
|
|
|
4112
4225
|
if not similar["ids"] or not similar["ids"][0]:
|
|
@@ -4118,8 +4231,6 @@ class KnowledgeOrchestrator:
|
|
|
4118
4231
|
meta = (similar["metadatas"][0][i] or {}) if similar["metadatas"] else {}
|
|
4119
4232
|
source = meta.get("source", "")
|
|
4120
4233
|
|
|
4121
|
-
if meta.get("doc_id") == doc_id:
|
|
4122
|
-
continue
|
|
4123
4234
|
if source in seen_sources:
|
|
4124
4235
|
continue
|
|
4125
4236
|
seen_sources.add(source)
|
|
@@ -4367,7 +4478,7 @@ class KnowledgeOrchestrator:
|
|
|
4367
4478
|
temporary.unlink(missing_ok=True)
|
|
4368
4479
|
raise
|
|
4369
4480
|
try:
|
|
4370
|
-
|
|
4481
|
+
replace_with_retry(temporary, path)
|
|
4371
4482
|
finally:
|
|
4372
4483
|
temporary.unlink(missing_ok=True)
|
|
4373
4484
|
|
|
@@ -4497,7 +4608,7 @@ class KnowledgeOrchestrator:
|
|
|
4497
4608
|
temporary.unlink(missing_ok=True)
|
|
4498
4609
|
raise
|
|
4499
4610
|
try:
|
|
4500
|
-
|
|
4611
|
+
replace_with_retry(temporary, self._metadata_file)
|
|
4501
4612
|
finally:
|
|
4502
4613
|
temporary.unlink(missing_ok=True)
|
|
4503
4614
|
|
|
@@ -4556,7 +4667,7 @@ class KnowledgeOrchestrator:
|
|
|
4556
4667
|
json.dumps(payload, indent=2, ensure_ascii=False),
|
|
4557
4668
|
encoding="utf-8",
|
|
4558
4669
|
)
|
|
4559
|
-
|
|
4670
|
+
replace_with_retry(tmp_path, self._checkpoint_file)
|
|
4560
4671
|
# Reflect in progress dict for status polling.
|
|
4561
4672
|
self._reindex_progress["checkpoint_saved_at"] = payload["checkpoint_at"]
|
|
4562
4673
|
|
|
@@ -4688,7 +4799,7 @@ def _make_snippet(content: str, max_chars: int = 500) -> str:
|
|
|
4688
4799
|
def search_knowledge(
|
|
4689
4800
|
query: str,
|
|
4690
4801
|
max_results: int = 5,
|
|
4691
|
-
category: str = None,
|
|
4802
|
+
category: Optional[str] = None,
|
|
4692
4803
|
hybrid_alpha: float = 0.3,
|
|
4693
4804
|
min_score: float = 0.0,
|
|
4694
4805
|
snippet_mode: bool = True,
|
|
@@ -4712,9 +4823,10 @@ def search_knowledge(
|
|
|
4712
4823
|
min_score: Minimum normalized relevance score (0.0–1.0) to include a result. Results scoring
|
|
4713
4824
|
below this threshold are discarded. Default 0.0 returns all results. Use 0.2–0.4 to cut
|
|
4714
4825
|
low-relevance noise.
|
|
4715
|
-
snippet_mode: When true (default), truncates content to ~500 characters
|
|
4716
|
-
point and adds a content_length field with the
|
|
4717
|
-
fetch full content when needed. Set to false to return full
|
|
4826
|
+
snippet_mode: When true (default), truncates content to ~500 characters from the start of the
|
|
4827
|
+
matching chunk, at a natural break point, and adds a content_length field with the full
|
|
4828
|
+
size. Use get_document() to fetch full content when needed. Set to false to return the full
|
|
4829
|
+
content, which can include adjacent chunks; match_offset, when present, is where the match starts.
|
|
4718
4830
|
search_method: Dispatch selector (v4.8.2+). One of ``"auto"`` (router picks FTS5 fast-path
|
|
4719
4831
|
for lexical queries when enabled, hybrid otherwise), ``"hybrid"`` (force hybrid path —
|
|
4720
4832
|
kill switch for suspected router misclassification), or ``"fts5"`` (force FTS5 fast-path
|
|
@@ -4789,7 +4901,11 @@ def search_knowledge(
|
|
|
4789
4901
|
|
|
4790
4902
|
if snippet_mode:
|
|
4791
4903
|
results = [
|
|
4792
|
-
{
|
|
4904
|
+
{
|
|
4905
|
+
**row,
|
|
4906
|
+
"content": _make_snippet(row["content"][row.get("match_offset", 0) :]),
|
|
4907
|
+
"content_length": len(row.get("content", "")),
|
|
4908
|
+
}
|
|
4793
4909
|
for row in results
|
|
4794
4910
|
]
|
|
4795
4911
|
|
|
@@ -4820,15 +4936,15 @@ def get_document(filepath: str) -> str:
|
|
|
4820
4936
|
Args:
|
|
4821
4937
|
filepath: Relative path to the document within the documents directory
|
|
4822
4938
|
(e.g., "security/technique.md"). Must be an indexed file — use
|
|
4823
|
-
|
|
4824
|
-
|
|
4939
|
+
search_knowledge() to find the filepath by topic first, or browse
|
|
4940
|
+
list_documents(), which returns one page at a time (see its offset).
|
|
4825
4941
|
|
|
4826
4942
|
Returns:
|
|
4827
4943
|
JSON string with full document content and metadata (filepath, category, size).
|
|
4828
4944
|
|
|
4829
4945
|
Usage: Use when you need the complete text of a known file — search_knowledge()
|
|
4830
4946
|
returns chunks, not full docs. Use search_knowledge() first to find the filepath
|
|
4831
|
-
if unknown. Use list_documents() to browse
|
|
4947
|
+
if unknown. Use list_documents() to browse files by category, one page at a time.
|
|
4832
4948
|
"""
|
|
4833
4949
|
orchestrator = get_orchestrator()
|
|
4834
4950
|
doc = orchestrator.get_document(filepath)
|
|
@@ -4914,8 +5030,12 @@ def reindex_documents(
|
|
|
4914
5030
|
) -> str:
|
|
4915
5031
|
"""Index or reindex all documents in the knowledge base (runs in background).
|
|
4916
5032
|
|
|
4917
|
-
|
|
4918
|
-
filesystem edits outside
|
|
5033
|
+
No flags — incremental pass: index new or changed files, skip unchanged
|
|
5034
|
+
ones, prune deleted sources. Use after filesystem edits outside
|
|
5035
|
+
add_document/update_document.
|
|
5036
|
+
``force`` — smart reindex: re-embed every file, unchanged ones included,
|
|
5037
|
+
and rebuild BM25. Slow on a large corpus; use after changing the
|
|
5038
|
+
query/passage prefixes or chunking settings.
|
|
4919
5039
|
``full_rebuild`` — nuclear rebuild (delete + re-embed). Use only after
|
|
4920
5040
|
embedding-model change or index corruption. Mutually exclusive with resume.
|
|
4921
5041
|
``resume`` — pick up an interrupted smart reindex from
|
|
@@ -4983,12 +5103,15 @@ def list_categories() -> str:
|
|
|
4983
5103
|
)
|
|
4984
5104
|
|
|
4985
5105
|
|
|
5106
|
+
_LIST_DOCUMENTS_MAX_LIMIT = 200 # ~75 KB of JSON: stays under common MCP client output limits
|
|
5107
|
+
|
|
5108
|
+
|
|
4986
5109
|
@mcp.tool()
|
|
4987
5110
|
@rate_limited
|
|
4988
5111
|
@instrument("list_documents")
|
|
4989
|
-
def list_documents(category: str = None) -> str:
|
|
5112
|
+
def list_documents(category: Optional[str] = None, limit: int = 100, offset: int = 0) -> str:
|
|
4990
5113
|
"""
|
|
4991
|
-
List
|
|
5114
|
+
List indexed documents one page at a time, optionally filtered by category.
|
|
4992
5115
|
|
|
4993
5116
|
Read-only. No side effects.
|
|
4994
5117
|
|
|
@@ -4996,19 +5119,35 @@ def list_documents(category: str = None) -> str:
|
|
|
4996
5119
|
category: Optional category filter. Must be a valid category name — call
|
|
4997
5120
|
list_categories() to see available options (e.g., security, ctf, logscale,
|
|
4998
5121
|
development, general, redteam, blueteam).
|
|
5122
|
+
limit: Maximum number of documents to return (default: 100, max: 200).
|
|
5123
|
+
offset: Number of matching documents to skip (default: 0). For the next page, call
|
|
5124
|
+
again with offset + count until offset reaches total.
|
|
4999
5125
|
|
|
5000
5126
|
Returns:
|
|
5001
|
-
JSON string with
|
|
5127
|
+
JSON string with total (documents matching the filter), offset, limit, count (documents
|
|
5128
|
+
in this page) and the filepaths, categories, and metadata of this page, ordered by filepath.
|
|
5002
5129
|
|
|
5003
|
-
Usage: Use to browse what's in the index
|
|
5004
|
-
list_categories() first to see valid category names. Use
|
|
5005
|
-
want to find documents by topic rather than browsing the full
|
|
5006
|
-
to read a specific file once you have its filepath.
|
|
5130
|
+
Usage: Use to browse what's in the index, one page at a time; to see every document, call
|
|
5131
|
+
again with offset + count until offset reaches total. Use list_categories() first to see valid category names. Use
|
|
5132
|
+
search_knowledge() when you want to find documents by topic rather than browsing the full
|
|
5133
|
+
list. Use get_document() to read a specific file once you have its filepath.
|
|
5007
5134
|
"""
|
|
5135
|
+
offset = max(0, offset)
|
|
5136
|
+
limit = max(1, min(limit, _LIST_DOCUMENTS_MAX_LIMIT))
|
|
5008
5137
|
orchestrator = get_orchestrator()
|
|
5009
|
-
|
|
5138
|
+
# Source order keeps pages stable: re-indexing an edited file moves it to the end of the index.
|
|
5139
|
+
docs = sorted(orchestrator.list_documents(category=category), key=lambda doc: doc["source"])
|
|
5140
|
+
page = docs[offset : offset + limit]
|
|
5010
5141
|
return json.dumps(
|
|
5011
|
-
{
|
|
5142
|
+
{
|
|
5143
|
+
"status": "success",
|
|
5144
|
+
"filter": category or "all",
|
|
5145
|
+
"total": len(docs),
|
|
5146
|
+
"offset": offset,
|
|
5147
|
+
"limit": limit,
|
|
5148
|
+
"count": len(page),
|
|
5149
|
+
"documents": page,
|
|
5150
|
+
},
|
|
5012
5151
|
indent=2,
|
|
5013
5152
|
ensure_ascii=False,
|
|
5014
5153
|
)
|
|
@@ -5092,7 +5231,7 @@ def update_document(filepath: str, content: str) -> str:
|
|
|
5092
5231
|
|
|
5093
5232
|
Args:
|
|
5094
5233
|
filepath: Full or relative path to the document file. Must be an already-indexed
|
|
5095
|
-
file —
|
|
5234
|
+
file — list_documents() lists indexed paths, one page at a time.
|
|
5096
5235
|
content: New full-text content to replace the existing content entirely
|
|
5097
5236
|
|
|
5098
5237
|
Returns:
|
|
@@ -5127,8 +5266,8 @@ def remove_document(filepath: str, delete_file: bool = False) -> str:
|
|
|
5127
5266
|
the file from disk (irreversible, cannot be undone).
|
|
5128
5267
|
|
|
5129
5268
|
Args:
|
|
5130
|
-
filepath: Path to the document file. Must be an indexed document —
|
|
5131
|
-
list_documents()
|
|
5269
|
+
filepath: Path to the document file. Must be an indexed document —
|
|
5270
|
+
list_documents() lists indexed paths, one page at a time.
|
|
5132
5271
|
delete_file: If True, permanently deletes the file from disk in addition to
|
|
5133
5272
|
removing from the index (default: False).
|
|
5134
5273
|
|
|
@@ -5155,7 +5294,7 @@ def remove_document(filepath: str, delete_file: bool = False) -> str:
|
|
|
5155
5294
|
@mcp.tool()
|
|
5156
5295
|
@rate_limited
|
|
5157
5296
|
@instrument("add_from_url")
|
|
5158
|
-
def add_from_url(url: str, category: str = "general", title: str = None) -> str:
|
|
5297
|
+
def add_from_url(url: str, category: str = "general", title: Optional[str] = None) -> str:
|
|
5159
5298
|
"""
|
|
5160
5299
|
Fetch content from a URL, convert to markdown, and add to the knowledge base.
|
|
5161
5300
|
|
|
@@ -5197,8 +5336,8 @@ def search_similar(filepath: str, max_results: int = 5) -> str:
|
|
|
5197
5336
|
Read-only. No side effects. Uses the document's embedding for similarity comparison.
|
|
5198
5337
|
|
|
5199
5338
|
Args:
|
|
5200
|
-
filepath: Path to the reference document (must already be indexed —
|
|
5201
|
-
|
|
5339
|
+
filepath: Path to the reference document (must already be indexed — list_documents()
|
|
5340
|
+
lists indexed paths, one page at a time). E.g., "security/technique.md"
|
|
5202
5341
|
max_results: Number of similar documents to return (default: 5, max: 20)
|
|
5203
5342
|
|
|
5204
5343
|
Returns:
|
|
@@ -5206,8 +5345,8 @@ def search_similar(filepath: str, max_results: int = 5) -> str:
|
|
|
5206
5345
|
|
|
5207
5346
|
Usage: Use when you have a specific document and want to discover thematically related
|
|
5208
5347
|
ones. Use search_knowledge() instead when you have a text query rather than a reference
|
|
5209
|
-
document. The reference document must be indexed
|
|
5210
|
-
|
|
5348
|
+
document. The reference document must be indexed; list_documents() lists indexed paths
|
|
5349
|
+
one page at a time.
|
|
5211
5350
|
"""
|
|
5212
5351
|
if not filepath:
|
|
5213
5352
|
return json.dumps({"status": "error", "message": "Filepath required"})
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "knowledge-rag"
|
|
7
|
-
version = "
|
|
7
|
+
version = "5.0.0"
|
|
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"}
|
|
@@ -141,7 +141,9 @@ source = ["mcp_server"]
|
|
|
141
141
|
|
|
142
142
|
[tool.coverage.report]
|
|
143
143
|
show_missing = true
|
|
144
|
-
|
|
144
|
+
# pytest-cov enforces this in every test matrix cell; each cell measured
|
|
145
|
+
# 84.2-84.4% on 2026-11-05, so the floor catches a real drop, not noise.
|
|
146
|
+
fail_under = 80
|
|
145
147
|
|
|
146
148
|
# ── mypy: strict gradual rollout (Pillar 7) ─────────────────────────────────
|
|
147
149
|
# Strict mode is enabled GLOBALLY but with a per-module exclusion list while
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|