candlekeep 0.1.0__py3-none-any.whl
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.
- candlekeep/__init__.py +36 -0
- candlekeep/__main__.py +5 -0
- candlekeep/config.py +150 -0
- candlekeep/database/__init__.py +1 -0
- candlekeep/database/embeddings.py +115 -0
- candlekeep/database/interface.py +86 -0
- candlekeep/database/vector_store.py +331 -0
- candlekeep/eval/metrics.py +132 -0
- candlekeep/eval/runner.py +156 -0
- candlekeep/eval/statistics.py +42 -0
- candlekeep/mcp/__init__.py +1 -0
- candlekeep/mcp/server.py +762 -0
- candlekeep/providers/__init__.py +15 -0
- candlekeep/providers/anthropic.py +77 -0
- candlekeep/providers/base.py +37 -0
- candlekeep/providers/bedrock.py +71 -0
- candlekeep/providers/factory.py +101 -0
- candlekeep/providers/openai.py +66 -0
- candlekeep/providers/openai_compat.py +66 -0
- candlekeep/rag/__init__.py +1 -0
- candlekeep/rag/arcane_recall.py +202 -0
- candlekeep/rag/colbert.py +457 -0
- candlekeep/rag/extractor.py +41 -0
- candlekeep/rag/hybrid.py +297 -0
- candlekeep/rag/processor.py +143 -0
- candlekeep/rag/reranker.py +88 -0
- candlekeep/rag/router.py +148 -0
- candlekeep/rag/search.py +47 -0
- candlekeep-0.1.0.dist-info/METADATA +143 -0
- candlekeep-0.1.0.dist-info/RECORD +33 -0
- candlekeep-0.1.0.dist-info/WHEEL +4 -0
- candlekeep-0.1.0.dist-info/entry_points.txt +2 -0
- candlekeep-0.1.0.dist-info/licenses/LICENSE +674 -0
candlekeep/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Candlekeep: RAG knowledge base server."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
# Check Python version
|
|
5
|
+
if sys.version_info < (3, 10):
|
|
6
|
+
raise RuntimeError(
|
|
7
|
+
f"Candlekeep requires Python 3.10+, found {sys.version_info.major}.{sys.version_info.minor}"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
# Check SQLite support (ChromaDB dependency)
|
|
11
|
+
try:
|
|
12
|
+
import sqlite3
|
|
13
|
+
except ImportError:
|
|
14
|
+
# Try pysqlite3-binary as fallback
|
|
15
|
+
try:
|
|
16
|
+
import pysqlite3 as sqlite3
|
|
17
|
+
sys.modules['sqlite3'] = sqlite3
|
|
18
|
+
except ImportError:
|
|
19
|
+
raise RuntimeError(
|
|
20
|
+
"Candlekeep requires Python with SQLite support (ChromaDB dependency)."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# ChromaDB requires SQLite >= 3.35.0
|
|
24
|
+
if sqlite3.sqlite_version_info < (3, 35, 0):
|
|
25
|
+
try:
|
|
26
|
+
__import__("pysqlite3")
|
|
27
|
+
sys.modules["sqlite3"] = sys.modules.pop("pysqlite3")
|
|
28
|
+
except ImportError:
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
f"ChromaDB requires SQLite >= 3.35.0, found {sqlite3.sqlite_version}\n"
|
|
31
|
+
"Fix: pip install pysqlite3-binary"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from candlekeep.mcp.server import mcp, main
|
|
35
|
+
|
|
36
|
+
__all__ = ["mcp", "main"]
|
candlekeep/__main__.py
ADDED
candlekeep/config.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Configuration for candlekeep with ChromaDB authentication."""
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
EmbeddingModel = Literal["minilm", "bge-small", "nomic"]
|
|
9
|
+
DeviceType = Literal["auto", "cpu", "mps", "cuda"]
|
|
10
|
+
|
|
11
|
+
EMBEDDING_MODELS = {
|
|
12
|
+
"minilm": "sentence-transformers/all-MiniLM-L6-v2",
|
|
13
|
+
"bge-small": "BAAI/bge-small-en-v1.5",
|
|
14
|
+
"nomic": "nomic-ai/nomic-embed-text-v1.5",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def detect_device() -> str:
|
|
19
|
+
"""Detect best available compute device."""
|
|
20
|
+
import torch
|
|
21
|
+
if torch.cuda.is_available():
|
|
22
|
+
return "cuda"
|
|
23
|
+
if torch.backends.mps.is_available():
|
|
24
|
+
return "mps"
|
|
25
|
+
return "cpu"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_dotenv(env_file: Path | None = None) -> None:
|
|
29
|
+
"""Load .env file into environment variables."""
|
|
30
|
+
if env_file is None:
|
|
31
|
+
for candidate in [Path(".env"), Path(__file__).parent.parent.parent / ".env"]:
|
|
32
|
+
if candidate.exists():
|
|
33
|
+
env_file = candidate
|
|
34
|
+
break
|
|
35
|
+
if env_file is None or not env_file.exists():
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
for line in env_file.read_text().splitlines():
|
|
39
|
+
line = line.strip()
|
|
40
|
+
if line and not line.startswith("#") and "=" in line:
|
|
41
|
+
if line.startswith("export "):
|
|
42
|
+
line = line[7:]
|
|
43
|
+
key, _, value = line.partition("=")
|
|
44
|
+
key, value = key.strip(), value.strip().strip('"').strip("'")
|
|
45
|
+
if key and key not in os.environ:
|
|
46
|
+
os.environ[key] = value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
load_dotenv()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_data_dir() -> Path:
|
|
53
|
+
"""Get cross-platform data directory."""
|
|
54
|
+
if os.name == "nt":
|
|
55
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
56
|
+
elif os.name == "posix" and "darwin" in os.uname().sysname.lower():
|
|
57
|
+
base = Path.home() / "Library" / "Application Support"
|
|
58
|
+
else:
|
|
59
|
+
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
60
|
+
return base / "candlekeep"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Settings:
|
|
65
|
+
"""Candlekeep configuration settings."""
|
|
66
|
+
|
|
67
|
+
# ChromaDB connection
|
|
68
|
+
chroma_url: str = field(default_factory=lambda: os.getenv("CHROMA_URL", "http://localhost:8000"))
|
|
69
|
+
chroma_auth_token: str = field(default_factory=lambda: os.getenv("CHROMA_AUTH_TOKEN", ""))
|
|
70
|
+
|
|
71
|
+
# Embedding settings
|
|
72
|
+
embedding_model: EmbeddingModel = field(default_factory=lambda: os.getenv("CANDLEKEEP_EMBEDDING", "bge-small"))
|
|
73
|
+
embedding_cache_size: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_EMBEDDING_CACHE_SIZE", "500")))
|
|
74
|
+
|
|
75
|
+
# Inference device
|
|
76
|
+
device: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_DEVICE", "auto"))
|
|
77
|
+
|
|
78
|
+
# Document processing
|
|
79
|
+
chunk_size: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_CHUNK_SIZE", "512")))
|
|
80
|
+
chunk_overlap: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_CHUNK_OVERLAP", "50")))
|
|
81
|
+
|
|
82
|
+
# Personality
|
|
83
|
+
spice: bool = field(default_factory=lambda: os.getenv("CANDLEKEEP_SPICE", "false").lower() == "true")
|
|
84
|
+
|
|
85
|
+
# Structural Integrity (Bardic Knowledge)
|
|
86
|
+
bardic_knowledge: bool = field(default_factory=lambda: os.getenv("CANDLEKEEP_BARDIC_KNOWLEDGE", "true").lower() == "true")
|
|
87
|
+
|
|
88
|
+
# Sparse backend for hybrid path (bm25 or colbert)
|
|
89
|
+
sparse_backend: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_SPARSE_BACKEND", "bm25"))
|
|
90
|
+
|
|
91
|
+
# Transport settings (multi-agent HTTP mode)
|
|
92
|
+
transport: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_TRANSPORT", "stdio"))
|
|
93
|
+
http_host: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_HTTP_HOST", "127.0.0.1"))
|
|
94
|
+
http_port: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_HTTP_PORT", "8111")))
|
|
95
|
+
mcp_token: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_MCP_TOKEN", ""))
|
|
96
|
+
|
|
97
|
+
# Rate limiting (HTTP mode only, per MCP session)
|
|
98
|
+
rate_limit_search: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_RATE_LIMIT_SEARCH", "30")))
|
|
99
|
+
rate_limit_write: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_RATE_LIMIT_WRITE", "5")))
|
|
100
|
+
rate_limit_window: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_RATE_LIMIT_WINDOW", "60")))
|
|
101
|
+
|
|
102
|
+
# LLM / Vision provider selection (anthropic, openai, bedrock, openai_compat)
|
|
103
|
+
llm_provider: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_LLM_PROVIDER", ""))
|
|
104
|
+
vlm_provider: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_VLM_PROVIDER", ""))
|
|
105
|
+
|
|
106
|
+
# Data directory
|
|
107
|
+
data_dir: Path = field(default_factory=get_data_dir)
|
|
108
|
+
|
|
109
|
+
def __post_init__(self):
|
|
110
|
+
if self.device == "auto":
|
|
111
|
+
self.device = detect_device()
|
|
112
|
+
self.data_dir = Path(self.data_dir)
|
|
113
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
(self.data_dir / "models").mkdir(exist_ok=True)
|
|
115
|
+
(self.data_dir / "chroma").mkdir(exist_ok=True)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_env(cls) -> "Settings":
|
|
119
|
+
"""Create settings from environment variables."""
|
|
120
|
+
return cls()
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def chroma_host(self) -> str:
|
|
124
|
+
"""Extract host from CHROMA_URL."""
|
|
125
|
+
parsed = urlparse(self.chroma_url)
|
|
126
|
+
return parsed.hostname or "localhost"
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def chroma_port(self) -> int:
|
|
130
|
+
"""Extract port from CHROMA_URL."""
|
|
131
|
+
parsed = urlparse(self.chroma_url)
|
|
132
|
+
return parsed.port or 8000
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def chroma_ssl(self) -> bool:
|
|
136
|
+
"""Check if HTTPS is used."""
|
|
137
|
+
parsed = urlparse(self.chroma_url)
|
|
138
|
+
return parsed.scheme == "https"
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def models_dir(self) -> Path:
|
|
142
|
+
return self.data_dir / "models"
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def chroma_dir(self) -> Path:
|
|
146
|
+
return self.data_dir / "chroma"
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def embedding_model_name(self) -> str:
|
|
150
|
+
return EMBEDDING_MODELS[self.embedding_model]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Database layer for candlekeep."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Embedding manager with model caching and selection."""
|
|
2
|
+
import threading
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from sentence_transformers import SentenceTransformer
|
|
5
|
+
from candlekeep.config import Settings, EMBEDDING_MODELS, EmbeddingModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EmbeddingManager:
|
|
9
|
+
_instance: "EmbeddingManager | None" = None
|
|
10
|
+
_model: SentenceTransformer | None = None
|
|
11
|
+
_current_model: str | None = None
|
|
12
|
+
|
|
13
|
+
def __init__(self, settings: Settings | None = None):
|
|
14
|
+
self.settings = settings or Settings.from_env()
|
|
15
|
+
self._cache = OrderedDict()
|
|
16
|
+
self._cache_lock = threading.Lock()
|
|
17
|
+
self._hits = 0
|
|
18
|
+
self._misses = 0
|
|
19
|
+
self._max_cache_size = self.settings.embedding_cache_size
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def get_instance(cls, settings: Settings | None = None) -> "EmbeddingManager":
|
|
24
|
+
if cls._instance is None:
|
|
25
|
+
cls._instance = cls(settings)
|
|
26
|
+
return cls._instance
|
|
27
|
+
|
|
28
|
+
def get_model(self, model_name: EmbeddingModel | None = None) -> SentenceTransformer:
|
|
29
|
+
"""Load model from local cache. Exits if not found locally."""
|
|
30
|
+
model_name = model_name or self.settings.embedding_model
|
|
31
|
+
model_id = EMBEDDING_MODELS[model_name]
|
|
32
|
+
|
|
33
|
+
if self._model is not None and self._current_model == model_id:
|
|
34
|
+
return self._model
|
|
35
|
+
|
|
36
|
+
cache_dir = self.settings.models_dir
|
|
37
|
+
# Check if model exists locally before loading
|
|
38
|
+
if not any(cache_dir.glob(f"models--{model_id.replace('/', '--')}*")):
|
|
39
|
+
import sys
|
|
40
|
+
print(f"[candlekeep] ❌ Model '{model_id}' not found locally in {cache_dir}. "
|
|
41
|
+
f"Run ./scripts/setup.sh to download models.", file=sys.stderr)
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
trust_remote_code = model_name == "nomic"
|
|
45
|
+
self._model = SentenceTransformer(
|
|
46
|
+
model_id,
|
|
47
|
+
cache_folder=str(cache_dir),
|
|
48
|
+
trust_remote_code=trust_remote_code,
|
|
49
|
+
local_files_only=True,
|
|
50
|
+
device=self.settings.device,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Promote to float64 on CPU for better determinism and to match reranker precision
|
|
54
|
+
if self.settings.device == "cpu":
|
|
55
|
+
try:
|
|
56
|
+
import torch
|
|
57
|
+
self._model.to(torch.float64)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
# Fallback to float32 if conversion fails (e.g. meta tensors in CI)
|
|
60
|
+
import sys
|
|
61
|
+
print(f"[candlekeep] ⚠ Could not promote embedding model to float64: {e}. "
|
|
62
|
+
f"Continuing with float32.", file=sys.stderr)
|
|
63
|
+
|
|
64
|
+
self._current_model = model_id
|
|
65
|
+
return self._model
|
|
66
|
+
|
|
67
|
+
def embed(self, texts: list[str] | str, model_name: EmbeddingModel | None = None, is_query: bool = False) -> list[list[float]]:
|
|
68
|
+
"""Generate embeddings for texts."""
|
|
69
|
+
model_name = model_name or self.settings.embedding_model
|
|
70
|
+
model = self.get_model(model_name)
|
|
71
|
+
if isinstance(texts, str):
|
|
72
|
+
texts = [texts]
|
|
73
|
+
|
|
74
|
+
# Nomic requires prefixes
|
|
75
|
+
if model_name == "nomic":
|
|
76
|
+
prefix = "search_query: " if is_query else "search_document: "
|
|
77
|
+
texts = [prefix + t for t in texts]
|
|
78
|
+
|
|
79
|
+
embeddings = model.encode(texts, convert_to_numpy=True)
|
|
80
|
+
return embeddings.tolist()
|
|
81
|
+
|
|
82
|
+
def embed_query(self, query: str, model_name: EmbeddingModel | None = None) -> list[float]:
|
|
83
|
+
"""Generate embedding for a single query with LRU cache."""
|
|
84
|
+
model_name = model_name or self.settings.embedding_model
|
|
85
|
+
|
|
86
|
+
if self._max_cache_size == 0:
|
|
87
|
+
return self.embed([query], model_name, is_query=True)[0]
|
|
88
|
+
|
|
89
|
+
cache_key = (query, model_name)
|
|
90
|
+
with self._cache_lock:
|
|
91
|
+
if cache_key in self._cache:
|
|
92
|
+
self._hits += 1
|
|
93
|
+
self._cache.move_to_end(cache_key)
|
|
94
|
+
return self._cache[cache_key]
|
|
95
|
+
|
|
96
|
+
# Cache miss - compute embedding
|
|
97
|
+
embedding = self.embed([query], model_name, is_query=True)[0]
|
|
98
|
+
|
|
99
|
+
with self._cache_lock:
|
|
100
|
+
self._misses += 1
|
|
101
|
+
self._cache[cache_key] = embedding
|
|
102
|
+
self._cache.move_to_end(cache_key)
|
|
103
|
+
if len(self._cache) > self._max_cache_size:
|
|
104
|
+
self._cache.popitem(last=False)
|
|
105
|
+
|
|
106
|
+
return embedding
|
|
107
|
+
|
|
108
|
+
def get_cache_stats(self) -> dict:
|
|
109
|
+
"""Get embedding cache hit/miss statistics."""
|
|
110
|
+
with self._cache_lock:
|
|
111
|
+
return {
|
|
112
|
+
"embedding_cache_hits": self._hits,
|
|
113
|
+
"embedding_cache_misses": self._misses,
|
|
114
|
+
"embedding_cache_size": len(self._cache)
|
|
115
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Abstract database interface for candlekeep."""
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import List, Dict, Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class SearchResult:
|
|
9
|
+
"""Search result from vector database."""
|
|
10
|
+
text: str
|
|
11
|
+
metadata: Dict[str, Any]
|
|
12
|
+
score: float
|
|
13
|
+
doc_id: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Chunk:
|
|
18
|
+
"""Document chunk for indexing."""
|
|
19
|
+
text: str
|
|
20
|
+
metadata: Dict[str, Any]
|
|
21
|
+
chunk_index: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class VectorDatabase(ABC):
|
|
25
|
+
"""Abstract interface for vector database operations."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def search(self, query: str, n_results: int = 5, category: str | None = None) -> List[SearchResult]:
|
|
29
|
+
"""Search for similar documents."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def add_documents(self, chunks: List[Chunk], collection: str = "default") -> int:
|
|
34
|
+
"""Add document chunks to database."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def delete_by_source(self, source: str) -> int:
|
|
39
|
+
"""Delete all chunks from a source."""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def clear(self) -> None:
|
|
44
|
+
"""Clear all documents from database."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
49
|
+
"""Get database statistics."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def list_documents(self) -> List[Dict[str, Any]]:
|
|
54
|
+
"""List all indexed documents."""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def get_chunks_by_source(self, source: str) -> List[SearchResult]:
|
|
59
|
+
"""Get all chunks from a specific source document."""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def get_all_chunks(self) -> List[SearchResult]:
|
|
64
|
+
"""Get all chunks from the database."""
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def get_embeddings(self, texts: List[str]) -> List[List[float]]:
|
|
69
|
+
"""Get embeddings for a list of texts."""
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def embed_query(self, query: str) -> List[float]:
|
|
74
|
+
"""Get cached embedding for a single query."""
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def get_stored_embeddings_by_source(self, source: str) -> Dict[int, List[float]]:
|
|
79
|
+
|
|
80
|
+
"""Get stored embeddings for all chunks from a source document.
|
|
81
|
+
|
|
82
|
+
Returns a dict mapping chunk_index to the embedding vector that was
|
|
83
|
+
computed at ingestion time. This avoids re-computing embeddings at
|
|
84
|
+
query time for similarity-weighted expansion.
|
|
85
|
+
"""
|
|
86
|
+
pass
|