docugraph 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.
- docugraph/__init__.py +24 -0
- docugraph/_version.py +28 -0
- docugraph/agents/__init__.py +27 -0
- docugraph/agents/coding_agent.py +532 -0
- docugraph/agents/tools/__init__.py +62 -0
- docugraph/agents/tools/graph_query.py +203 -0
- docugraph/agents/tools/memory_ops.py +305 -0
- docugraph/agents/tools/search_code.py +305 -0
- docugraph/agents/tools/search_docs.py +148 -0
- docugraph/core/__init__.py +34 -0
- docugraph/core/config.py +183 -0
- docugraph/core/embeddings.py +394 -0
- docugraph/core/llm.py +892 -0
- docugraph/core/models.py +145 -0
- docugraph/ingestion/__init__.py +64 -0
- docugraph/ingestion/api_docs.py +646 -0
- docugraph/ingestion/chunker.py +382 -0
- docugraph/ingestion/crawler.py +305 -0
- docugraph/ingestion/git_indexer.py +556 -0
- docugraph/ingestion/local_files.py +516 -0
- docugraph/interfaces/__init__.py +1 -0
- docugraph/interfaces/cli.py +983 -0
- docugraph/interfaces/mcp_server.py +918 -0
- docugraph/retrieval/__init__.py +54 -0
- docugraph/retrieval/context_builder.py +522 -0
- docugraph/retrieval/hybrid_search.py +534 -0
- docugraph/retrieval/reranker.py +532 -0
- docugraph/storage/__init__.py +14 -0
- docugraph/storage/graph_store.py +602 -0
- docugraph/storage/memory_store.py +529 -0
- docugraph/storage/vector_store.py +367 -0
- docugraph-0.1.0.dist-info/METADATA +354 -0
- docugraph-0.1.0.dist-info/RECORD +37 -0
- docugraph-0.1.0.dist-info/WHEEL +4 -0
- docugraph-0.1.0.dist-info/entry_points.txt +3 -0
- docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
- docugraph-0.1.0.dist-info/licenses/NOTICE +10 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Embedding provider abstraction (local-first, LLM-agnostic)."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from docugraph.core.config import EmbeddingConfig, EmbeddingProvider, get_config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EmbeddingProviderBase(ABC):
|
|
10
|
+
"""Abstract base class for embedding providers."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
14
|
+
"""Embed a list of texts."""
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def embed_query(self, query: str) -> list[float]:
|
|
19
|
+
"""Embed a single query (may use different model/prefix)."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def dimensions(self) -> int:
|
|
25
|
+
"""Return the embedding dimensions."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def model_name(self) -> str:
|
|
31
|
+
"""Return the model name."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FastembedEmbedder(EmbeddingProviderBase):
|
|
36
|
+
"""Local embeddings via fastembed (pure ONNX, no torch).
|
|
37
|
+
|
|
38
|
+
Default model BAAI/bge-small-en-v1.5 (384 dims). Weights download on
|
|
39
|
+
first use and are cached under FASTEMBED_CACHE_PATH; unset, fastembed
|
|
40
|
+
falls back to a `fastembed_cache` subdirectory of the system temp
|
|
41
|
+
directory (its `define_cache_dir`), not to `~/.cache/fastembed`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# bge-en-v1.5 models are trained with a query/passage asymmetry; retrieval
|
|
45
|
+
# queries should carry this instruction prefix for best relevance.
|
|
46
|
+
BGE_QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
|
|
47
|
+
|
|
48
|
+
def __init__(self, model_name: str | None = None, model: Any | None = None):
|
|
49
|
+
self._model_name = model_name or "BAAI/bge-small-en-v1.5"
|
|
50
|
+
self._dimensions: int | None = None
|
|
51
|
+
if model is None:
|
|
52
|
+
# Lazy import so environments without fastembed degrade via the chain.
|
|
53
|
+
from fastembed import TextEmbedding
|
|
54
|
+
|
|
55
|
+
model = TextEmbedding(self._model_name)
|
|
56
|
+
# Stored only once non-None so mypy can narrow the attribute.
|
|
57
|
+
self._model: Any = model
|
|
58
|
+
|
|
59
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
60
|
+
"""Embed a list of texts (passages/documents)."""
|
|
61
|
+
if not texts:
|
|
62
|
+
return []
|
|
63
|
+
# fastembed yields numpy arrays; convert to plain float lists.
|
|
64
|
+
vectors: list[list[float]] = [[float(x) for x in vec] for vec in self._model.embed(texts)]
|
|
65
|
+
return vectors
|
|
66
|
+
|
|
67
|
+
def embed_query(self, query: str) -> list[float]:
|
|
68
|
+
"""Embed a retrieval query, applying the bge instruction prefix."""
|
|
69
|
+
prefixed = self.BGE_QUERY_PREFIX + query if "bge" in self._model_name.lower() else query
|
|
70
|
+
return self.embed([prefixed])[0]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def dimensions(self) -> int:
|
|
74
|
+
if self._dimensions is None:
|
|
75
|
+
self._dimensions = len(self.embed(["dimension probe"])[0])
|
|
76
|
+
return self._dimensions
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def model_name(self) -> str:
|
|
80
|
+
return self._model_name
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SentenceTransformersEmbedder(EmbeddingProviderBase):
|
|
84
|
+
"""Local embeddings using sentence-transformers (optional; requires torch).
|
|
85
|
+
|
|
86
|
+
Not a managed dependency anymore — works if the user has
|
|
87
|
+
sentence-transformers installed independently.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
model_name: str | None = None,
|
|
93
|
+
device: str = "cpu",
|
|
94
|
+
batch_size: int = 32,
|
|
95
|
+
):
|
|
96
|
+
self._model_name = model_name or "all-MiniLM-L6-v2"
|
|
97
|
+
self._batch_size = batch_size
|
|
98
|
+
|
|
99
|
+
# Lazy load to avoid import overhead
|
|
100
|
+
from sentence_transformers import SentenceTransformer
|
|
101
|
+
|
|
102
|
+
self._model = SentenceTransformer(self._model_name, device=device)
|
|
103
|
+
detected = self._model.get_sentence_embedding_dimension()
|
|
104
|
+
self._dimensions: int = int(detected) if detected is not None else 384
|
|
105
|
+
|
|
106
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
107
|
+
"""Embed a list of texts."""
|
|
108
|
+
embeddings = self._model.encode(
|
|
109
|
+
texts,
|
|
110
|
+
batch_size=self._batch_size,
|
|
111
|
+
show_progress_bar=len(texts) > 100,
|
|
112
|
+
convert_to_numpy=True,
|
|
113
|
+
)
|
|
114
|
+
vectors: list[list[float]] = embeddings.tolist()
|
|
115
|
+
return vectors
|
|
116
|
+
|
|
117
|
+
def embed_query(self, query: str) -> list[float]:
|
|
118
|
+
"""Embed a single query."""
|
|
119
|
+
embedding = self._model.encode(query, convert_to_numpy=True)
|
|
120
|
+
vector: list[float] = embedding.tolist()
|
|
121
|
+
return vector
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def dimensions(self) -> int:
|
|
125
|
+
return self._dimensions
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def model_name(self) -> str:
|
|
129
|
+
return self._model_name
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class OllamaEmbedder(EmbeddingProviderBase):
|
|
133
|
+
"""Embeddings via Ollama (local LLM server)."""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
model_name: str | None = None,
|
|
138
|
+
base_url: str = "http://localhost:11434",
|
|
139
|
+
):
|
|
140
|
+
# Local, so it keeps a default: the server is on this machine and
|
|
141
|
+
# `ollama pull nomic-embed-text` is a command the reader can run.
|
|
142
|
+
self._model_name = model_name or "nomic-embed-text"
|
|
143
|
+
self._base_url = base_url
|
|
144
|
+
self._dimensions: int | None = None
|
|
145
|
+
|
|
146
|
+
def _get_client(self) -> Any:
|
|
147
|
+
"""Get Ollama client."""
|
|
148
|
+
try:
|
|
149
|
+
import ollama
|
|
150
|
+
|
|
151
|
+
return ollama.Client(host=self._base_url)
|
|
152
|
+
except ImportError as e:
|
|
153
|
+
raise ImportError(
|
|
154
|
+
"Ollama support requires the 'ollama' package. "
|
|
155
|
+
"Install with: pip install 'docugraph[ollama]'"
|
|
156
|
+
) from e
|
|
157
|
+
|
|
158
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
159
|
+
"""Embed a list of texts."""
|
|
160
|
+
client = self._get_client()
|
|
161
|
+
embeddings = []
|
|
162
|
+
for text in texts:
|
|
163
|
+
response = client.embeddings(model=self._model_name, prompt=text)
|
|
164
|
+
embeddings.append(response["embedding"])
|
|
165
|
+
if self._dimensions is None:
|
|
166
|
+
self._dimensions = len(response["embedding"])
|
|
167
|
+
return embeddings
|
|
168
|
+
|
|
169
|
+
def embed_query(self, query: str) -> list[float]:
|
|
170
|
+
"""Embed a single query."""
|
|
171
|
+
return self.embed([query])[0]
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def dimensions(self) -> int:
|
|
175
|
+
if self._dimensions is None:
|
|
176
|
+
# Get dimensions by embedding a test string
|
|
177
|
+
self.embed(["test"])
|
|
178
|
+
return self._dimensions or 768
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def model_name(self) -> str:
|
|
182
|
+
return self._model_name
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class OpenAIEmbedder(EmbeddingProviderBase):
|
|
186
|
+
"""Embeddings via OpenAI API.
|
|
187
|
+
|
|
188
|
+
``model_name`` has no default. An embedding model's vectors live in their
|
|
189
|
+
own space and cannot be mixed with another's, so a name compiled in here is
|
|
190
|
+
a name that silently becomes wrong the day OpenAI retires it -- and the
|
|
191
|
+
store's dimension and identity guards would then be comparing against a
|
|
192
|
+
model nobody chose.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
model_name: str | None = None,
|
|
198
|
+
api_key: str | None = None,
|
|
199
|
+
):
|
|
200
|
+
if not model_name:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
"embeddings.model must name an OpenAI embedding model when "
|
|
203
|
+
"embeddings.provider is 'openai' (e.g. DOCUGRAPH_EMBEDDINGS__MODEL). "
|
|
204
|
+
"There is no default: an embedding model fixes the vector space "
|
|
205
|
+
"of the whole index, so it is not something to guess at."
|
|
206
|
+
)
|
|
207
|
+
self._model_name = model_name
|
|
208
|
+
self._api_key = api_key
|
|
209
|
+
# Measured from a real response rather than looked up in a table. The
|
|
210
|
+
# table was a second place to keep a fact the API already reports, and
|
|
211
|
+
# it was wrong for every model it did not list -- it defaulted to 1536,
|
|
212
|
+
# so an unlisted model got a schema that could not hold its vectors.
|
|
213
|
+
self._dimensions: int | None = None
|
|
214
|
+
|
|
215
|
+
def _get_client(self) -> Any:
|
|
216
|
+
"""Get OpenAI client."""
|
|
217
|
+
try:
|
|
218
|
+
from openai import OpenAI
|
|
219
|
+
|
|
220
|
+
api_key = self._api_key
|
|
221
|
+
if api_key is None:
|
|
222
|
+
config = get_config()
|
|
223
|
+
api_key = config.llm.openai_api_key
|
|
224
|
+
|
|
225
|
+
if not api_key:
|
|
226
|
+
raise ValueError("OpenAI API key not configured")
|
|
227
|
+
|
|
228
|
+
return OpenAI(api_key=api_key)
|
|
229
|
+
except ImportError as e:
|
|
230
|
+
raise ImportError(
|
|
231
|
+
"OpenAI support requires the 'openai' package. "
|
|
232
|
+
"Install with: pip install 'docugraph[cloud]'"
|
|
233
|
+
) from e
|
|
234
|
+
|
|
235
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
236
|
+
"""Embed a list of texts."""
|
|
237
|
+
client = self._get_client()
|
|
238
|
+
response = client.embeddings.create(
|
|
239
|
+
model=self._model_name,
|
|
240
|
+
input=texts,
|
|
241
|
+
)
|
|
242
|
+
vectors = [item.embedding for item in response.data]
|
|
243
|
+
if self._dimensions is None and vectors:
|
|
244
|
+
self._dimensions = len(vectors[0])
|
|
245
|
+
return vectors
|
|
246
|
+
|
|
247
|
+
def embed_query(self, query: str) -> list[float]:
|
|
248
|
+
"""Embed a single query."""
|
|
249
|
+
return self.embed([query])[0]
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def dimensions(self) -> int:
|
|
253
|
+
if self._dimensions is None:
|
|
254
|
+
# Nothing has been embedded yet and the caller needs the width before
|
|
255
|
+
# it can build a schema, so ask the model directly.
|
|
256
|
+
self.embed(["dimension probe"])
|
|
257
|
+
return self._dimensions or 0
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def model_name(self) -> str:
|
|
261
|
+
return self._model_name
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class CohereEmbedder(EmbeddingProviderBase):
|
|
265
|
+
"""Embeddings via Cohere API.
|
|
266
|
+
|
|
267
|
+
``model_name`` has no default, for the same reason as OpenAIEmbedder: the
|
|
268
|
+
vector space is a property of the model. It used to default to
|
|
269
|
+
``embed-english-v3.0`` with a hard-coded ``1024`` dimensions, which is that
|
|
270
|
+
one model's width -- every other Cohere embedding model got a schema built
|
|
271
|
+
to the wrong size.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def __init__(
|
|
275
|
+
self,
|
|
276
|
+
model_name: str | None = None,
|
|
277
|
+
api_key: str | None = None,
|
|
278
|
+
):
|
|
279
|
+
if not model_name:
|
|
280
|
+
raise ValueError(
|
|
281
|
+
"embeddings.model must name a Cohere embedding model when "
|
|
282
|
+
"embeddings.provider is 'cohere' (e.g. DOCUGRAPH_EMBEDDINGS__MODEL). "
|
|
283
|
+
"There is no default: the model fixes the vector space of the "
|
|
284
|
+
"whole index."
|
|
285
|
+
)
|
|
286
|
+
self._model_name = model_name
|
|
287
|
+
self._api_key = api_key
|
|
288
|
+
self._dimensions: int | None = None
|
|
289
|
+
|
|
290
|
+
def _get_client(self) -> Any:
|
|
291
|
+
"""Get Cohere client."""
|
|
292
|
+
try:
|
|
293
|
+
import cohere
|
|
294
|
+
|
|
295
|
+
api_key = self._api_key
|
|
296
|
+
if api_key is None:
|
|
297
|
+
import os
|
|
298
|
+
|
|
299
|
+
api_key = os.getenv("COHERE_API_KEY")
|
|
300
|
+
|
|
301
|
+
if not api_key:
|
|
302
|
+
raise ValueError("Cohere API key not configured")
|
|
303
|
+
|
|
304
|
+
return cohere.Client(api_key)
|
|
305
|
+
except ImportError as e:
|
|
306
|
+
raise ImportError(
|
|
307
|
+
"Cohere support requires the 'cohere' package. "
|
|
308
|
+
"Install with: pip install 'docugraph[cloud]'"
|
|
309
|
+
) from e
|
|
310
|
+
|
|
311
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
312
|
+
"""Embed a list of texts."""
|
|
313
|
+
client = self._get_client()
|
|
314
|
+
response = client.embed(
|
|
315
|
+
texts=texts,
|
|
316
|
+
model=self._model_name,
|
|
317
|
+
input_type="search_document",
|
|
318
|
+
)
|
|
319
|
+
vectors = [list(emb) for emb in response.embeddings]
|
|
320
|
+
if self._dimensions is None and vectors:
|
|
321
|
+
self._dimensions = len(vectors[0])
|
|
322
|
+
return vectors
|
|
323
|
+
|
|
324
|
+
def embed_query(self, query: str) -> list[float]:
|
|
325
|
+
"""Embed a single query."""
|
|
326
|
+
client = self._get_client()
|
|
327
|
+
response = client.embed(
|
|
328
|
+
texts=[query],
|
|
329
|
+
model=self._model_name,
|
|
330
|
+
input_type="search_query",
|
|
331
|
+
)
|
|
332
|
+
return list(response.embeddings[0])
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def dimensions(self) -> int:
|
|
336
|
+
if self._dimensions is None:
|
|
337
|
+
self.embed(["dimension probe"])
|
|
338
|
+
return self._dimensions or 0
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def model_name(self) -> str:
|
|
342
|
+
return self._model_name
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def get_embedder(config: EmbeddingConfig | None = None) -> EmbeddingProviderBase:
|
|
346
|
+
"""Factory function to get the configured embedder.
|
|
347
|
+
|
|
348
|
+
With provider=auto: fastembed (local, pure ONNX) -> sentence-transformers
|
|
349
|
+
(only if already installed). Nothing about the environment is consulted,
|
|
350
|
+
so an unrelated OPENAI_API_KEY in your shell cannot start sending document
|
|
351
|
+
text to a third party. A cloud provider is used only when it is named
|
|
352
|
+
explicitly, and then it fails loudly on a missing package or key.
|
|
353
|
+
"""
|
|
354
|
+
if config is None:
|
|
355
|
+
config = get_config().embeddings
|
|
356
|
+
|
|
357
|
+
provider = config.provider
|
|
358
|
+
|
|
359
|
+
if provider == EmbeddingProvider.FASTEMBED:
|
|
360
|
+
return FastembedEmbedder(model_name=config.model)
|
|
361
|
+
elif provider == EmbeddingProvider.SENTENCE_TRANSFORMERS:
|
|
362
|
+
return SentenceTransformersEmbedder(
|
|
363
|
+
model_name=config.model,
|
|
364
|
+
batch_size=config.batch_size,
|
|
365
|
+
)
|
|
366
|
+
elif provider == EmbeddingProvider.OLLAMA:
|
|
367
|
+
return OllamaEmbedder(model_name=config.model)
|
|
368
|
+
elif provider == EmbeddingProvider.OPENAI:
|
|
369
|
+
return OpenAIEmbedder(model_name=config.model)
|
|
370
|
+
elif provider == EmbeddingProvider.COHERE:
|
|
371
|
+
return CohereEmbedder(model_name=config.model)
|
|
372
|
+
else:
|
|
373
|
+
# AUTO: local backends only, and `os.environ` is deliberately not
|
|
374
|
+
# consulted here. A key in the environment is not a request to use it:
|
|
375
|
+
# resolving to OpenAI on the mere presence of OPENAI_API_KEY meant that
|
|
376
|
+
# a user who happened to have the variable exported (or the `cloud`
|
|
377
|
+
# extra installed) had the text of every document they embedded sent to
|
|
378
|
+
# a third party, with nothing in the config saying so. Naming the
|
|
379
|
+
# provider is the request; `provider: openai` sends it there.
|
|
380
|
+
try:
|
|
381
|
+
return FastembedEmbedder(model_name=config.model)
|
|
382
|
+
except Exception: # nosec B110 -- provider chain: try the next backend
|
|
383
|
+
pass
|
|
384
|
+
try:
|
|
385
|
+
return SentenceTransformersEmbedder(
|
|
386
|
+
model_name=config.model,
|
|
387
|
+
batch_size=config.batch_size,
|
|
388
|
+
)
|
|
389
|
+
except ImportError as e:
|
|
390
|
+
raise RuntimeError(
|
|
391
|
+
"No embedding provider available. Install fastembed "
|
|
392
|
+
"(pip install fastembed) for local ONNX embeddings, or set "
|
|
393
|
+
"embeddings.provider to 'openai' or 'cohere' for a cloud one."
|
|
394
|
+
) from e
|