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,367 @@
|
|
|
1
|
+
"""LanceDB vector store implementation."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import lancedb
|
|
7
|
+
import pyarrow as pa
|
|
8
|
+
|
|
9
|
+
from docugraph.core.config import get_config
|
|
10
|
+
from docugraph.core.embeddings import EmbeddingProviderBase, get_embedder
|
|
11
|
+
from docugraph.core.models import Chunk, SearchResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class VectorStore:
|
|
15
|
+
"""Vector store backed by LanceDB."""
|
|
16
|
+
|
|
17
|
+
TABLE_NAME = "chunks"
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
db_path: Path | str | None = None,
|
|
22
|
+
embedder: EmbeddingProviderBase | None = None,
|
|
23
|
+
):
|
|
24
|
+
"""Initialize the vector store.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
db_path: Path to the LanceDB database directory.
|
|
28
|
+
embedder: Embedding provider to use. If None, uses default from config.
|
|
29
|
+
"""
|
|
30
|
+
if db_path is None:
|
|
31
|
+
config = get_config()
|
|
32
|
+
db_path = config.storage.data_dir / "vectors"
|
|
33
|
+
|
|
34
|
+
self._db_path = Path(db_path)
|
|
35
|
+
self._db_path.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
self._db = lancedb.connect(str(self._db_path))
|
|
38
|
+
self._embedder = embedder or get_embedder()
|
|
39
|
+
self._table: lancedb.table.Table | None = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def embedding_model(self) -> str:
|
|
43
|
+
"""The model this store is actually using, defaults already resolved.
|
|
44
|
+
|
|
45
|
+
Not the same as the configured value: config leaves the model unset for
|
|
46
|
+
the local backends and lets each one name its own, so reporting the
|
|
47
|
+
config field would show a user on fastembed that their model is None.
|
|
48
|
+
"""
|
|
49
|
+
return self._embedder.model_name
|
|
50
|
+
|
|
51
|
+
def _get_schema(self) -> pa.Schema:
|
|
52
|
+
"""Get PyArrow schema for the chunks table."""
|
|
53
|
+
return pa.schema(
|
|
54
|
+
[
|
|
55
|
+
pa.field("id", pa.string()),
|
|
56
|
+
pa.field("document_id", pa.string()),
|
|
57
|
+
pa.field("content", pa.string()),
|
|
58
|
+
pa.field("vector", pa.list_(pa.float32(), self._embedder.dimensions)),
|
|
59
|
+
pa.field("start_char", pa.int64()),
|
|
60
|
+
pa.field("end_char", pa.int64()),
|
|
61
|
+
pa.field("metadata", pa.string()), # JSON string
|
|
62
|
+
pa.field("created_at", pa.string()), # ISO format
|
|
63
|
+
pa.field("embedding_model", pa.string()), # Model that produced the vectors
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def _validate_existing_table(self, table: lancedb.table.Table) -> None:
|
|
68
|
+
"""Guard against opening a store embedded with a different model.
|
|
69
|
+
|
|
70
|
+
Dimensions alone are insufficient (all-MiniLM-L6-v2 and
|
|
71
|
+
bge-small-en-v1.5 are both 384-dim but live in different spaces);
|
|
72
|
+
model identity is checked too.
|
|
73
|
+
"""
|
|
74
|
+
schema = table.schema
|
|
75
|
+
vector_field = schema.field("vector")
|
|
76
|
+
existing_dims = vector_field.type.list_size
|
|
77
|
+
|
|
78
|
+
if existing_dims != self._embedder.dimensions:
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
f"Vector store was built with {existing_dims}-dim embeddings, but "
|
|
81
|
+
f"'{self._embedder.model_name}' produces {self._embedder.dimensions}-dim "
|
|
82
|
+
"vectors. Run 'docugraph clear' and re-index to rebuild the store."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if "embedding_model" not in schema.names:
|
|
86
|
+
raise RuntimeError(
|
|
87
|
+
"Vector store predates per-model tracking (no 'embedding_model' column) "
|
|
88
|
+
"and may have been built with a different model. Run 'docugraph clear' "
|
|
89
|
+
"and re-index to rebuild the store."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Check the model recorded on the first row (stores are homogeneous).
|
|
93
|
+
if table.count_rows() > 0:
|
|
94
|
+
existing_model = table.to_arrow().column("embedding_model")[0].as_py()
|
|
95
|
+
if existing_model != self._embedder.model_name:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"Vector store was built with '{existing_model}', but the configured "
|
|
98
|
+
f"embedder is '{self._embedder.model_name}'. Run 'docugraph clear' "
|
|
99
|
+
"and re-index to rebuild the store."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _ensure_table(self) -> lancedb.table.Table:
|
|
103
|
+
"""Ensure the table exists and return it."""
|
|
104
|
+
if self._table is not None:
|
|
105
|
+
return self._table
|
|
106
|
+
|
|
107
|
+
if self.TABLE_NAME in self._db.table_names():
|
|
108
|
+
self._table = self._db.open_table(self.TABLE_NAME)
|
|
109
|
+
self._validate_existing_table(self._table)
|
|
110
|
+
else:
|
|
111
|
+
# Create empty table with schema
|
|
112
|
+
self._table = self._db.create_table(
|
|
113
|
+
self.TABLE_NAME,
|
|
114
|
+
schema=self._get_schema(),
|
|
115
|
+
mode="overwrite",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return self._table
|
|
119
|
+
|
|
120
|
+
def add_chunks(self, chunks: list[Chunk]) -> int:
|
|
121
|
+
"""Add chunks to the vector store.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
chunks: List of chunks to add.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Number of chunks added.
|
|
128
|
+
"""
|
|
129
|
+
if not chunks:
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
table = self._ensure_table()
|
|
133
|
+
|
|
134
|
+
# Generate embeddings for chunks without them
|
|
135
|
+
texts_to_embed = []
|
|
136
|
+
indices_to_embed = []
|
|
137
|
+
for i, chunk in enumerate(chunks):
|
|
138
|
+
if chunk.embedding is None:
|
|
139
|
+
texts_to_embed.append(chunk.content)
|
|
140
|
+
indices_to_embed.append(i)
|
|
141
|
+
|
|
142
|
+
if texts_to_embed:
|
|
143
|
+
embeddings = self._embedder.embed(texts_to_embed)
|
|
144
|
+
for idx, embedding in zip(indices_to_embed, embeddings, strict=False):
|
|
145
|
+
chunks[idx].embedding = embedding
|
|
146
|
+
|
|
147
|
+
# Convert to records
|
|
148
|
+
import json
|
|
149
|
+
|
|
150
|
+
records = []
|
|
151
|
+
for chunk in chunks:
|
|
152
|
+
records.append(
|
|
153
|
+
{
|
|
154
|
+
"id": chunk.id,
|
|
155
|
+
"document_id": chunk.document_id,
|
|
156
|
+
"content": chunk.content,
|
|
157
|
+
"vector": chunk.embedding,
|
|
158
|
+
"start_char": chunk.start_char,
|
|
159
|
+
"end_char": chunk.end_char,
|
|
160
|
+
"metadata": json.dumps(chunk.metadata),
|
|
161
|
+
"created_at": chunk.created_at.isoformat(),
|
|
162
|
+
"embedding_model": self._embedder.model_name,
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
table.add(records)
|
|
167
|
+
return len(records)
|
|
168
|
+
|
|
169
|
+
def search(
|
|
170
|
+
self,
|
|
171
|
+
query: str,
|
|
172
|
+
top_k: int = 10,
|
|
173
|
+
filter_expr: str | None = None,
|
|
174
|
+
) -> list[SearchResult]:
|
|
175
|
+
"""Search for similar chunks.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
query: Search query text.
|
|
179
|
+
top_k: Number of results to return.
|
|
180
|
+
filter_expr: Optional SQL-like filter expression.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
List of search results with scores.
|
|
184
|
+
"""
|
|
185
|
+
table = self._ensure_table()
|
|
186
|
+
|
|
187
|
+
# Embed the query
|
|
188
|
+
query_embedding = self._embedder.embed_query(query)
|
|
189
|
+
|
|
190
|
+
# Build search query
|
|
191
|
+
search_query = table.search(query_embedding).limit(top_k)
|
|
192
|
+
|
|
193
|
+
if filter_expr:
|
|
194
|
+
search_query = search_query.where(filter_expr)
|
|
195
|
+
|
|
196
|
+
# Execute search
|
|
197
|
+
results = search_query.to_pandas()
|
|
198
|
+
|
|
199
|
+
if results.empty:
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
# Convert to SearchResult objects
|
|
203
|
+
import json
|
|
204
|
+
|
|
205
|
+
search_results = []
|
|
206
|
+
for _, row in results.iterrows():
|
|
207
|
+
chunk = Chunk(
|
|
208
|
+
id=row["id"],
|
|
209
|
+
document_id=row["document_id"],
|
|
210
|
+
content=row["content"],
|
|
211
|
+
embedding=row["vector"].tolist()
|
|
212
|
+
if hasattr(row["vector"], "tolist")
|
|
213
|
+
else row["vector"],
|
|
214
|
+
start_char=row["start_char"],
|
|
215
|
+
end_char=row["end_char"],
|
|
216
|
+
metadata=json.loads(row["metadata"]) if row["metadata"] else {},
|
|
217
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
218
|
+
)
|
|
219
|
+
# LanceDB returns _distance, lower is better
|
|
220
|
+
# Convert to similarity score (1 / (1 + distance))
|
|
221
|
+
distance = row.get("_distance", 0)
|
|
222
|
+
score = 1.0 / (1.0 + distance)
|
|
223
|
+
|
|
224
|
+
search_results.append(SearchResult(chunk=chunk, score=score))
|
|
225
|
+
|
|
226
|
+
return search_results
|
|
227
|
+
|
|
228
|
+
def search_hybrid(
|
|
229
|
+
self,
|
|
230
|
+
query: str,
|
|
231
|
+
top_k: int = 10,
|
|
232
|
+
filter_expr: str | None = None,
|
|
233
|
+
) -> list[SearchResult]:
|
|
234
|
+
"""Hybrid search combining vector and full-text search (LanceDB-native fusion).
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
query: Search query text.
|
|
238
|
+
top_k: Number of results to return.
|
|
239
|
+
filter_expr: Optional SQL-like filter expression.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
List of search results with combined scores.
|
|
243
|
+
"""
|
|
244
|
+
table = self._ensure_table()
|
|
245
|
+
|
|
246
|
+
# Embed the query
|
|
247
|
+
query_embedding = self._embedder.embed_query(query)
|
|
248
|
+
|
|
249
|
+
# LanceDB hybrid search
|
|
250
|
+
try:
|
|
251
|
+
search_query = table.search(query_embedding, query_type="hybrid").limit(top_k)
|
|
252
|
+
if filter_expr:
|
|
253
|
+
search_query = search_query.where(filter_expr)
|
|
254
|
+
results = search_query.to_pandas()
|
|
255
|
+
except Exception:
|
|
256
|
+
# Fall back to vector-only search if hybrid not available
|
|
257
|
+
return self.search(query, top_k, filter_expr)
|
|
258
|
+
|
|
259
|
+
if results.empty:
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
import json
|
|
263
|
+
|
|
264
|
+
search_results = []
|
|
265
|
+
for _, row in results.iterrows():
|
|
266
|
+
chunk = Chunk(
|
|
267
|
+
id=row["id"],
|
|
268
|
+
document_id=row["document_id"],
|
|
269
|
+
content=row["content"],
|
|
270
|
+
embedding=row["vector"].tolist()
|
|
271
|
+
if hasattr(row["vector"], "tolist")
|
|
272
|
+
else row["vector"],
|
|
273
|
+
start_char=row["start_char"],
|
|
274
|
+
end_char=row["end_char"],
|
|
275
|
+
metadata=json.loads(row["metadata"]) if row["metadata"] else {},
|
|
276
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
277
|
+
)
|
|
278
|
+
distance = row.get("_distance", 0)
|
|
279
|
+
score = 1.0 / (1.0 + distance)
|
|
280
|
+
|
|
281
|
+
search_results.append(SearchResult(chunk=chunk, score=score))
|
|
282
|
+
|
|
283
|
+
return search_results
|
|
284
|
+
|
|
285
|
+
def get_chunk(self, chunk_id: str) -> Chunk | None:
|
|
286
|
+
"""Get a chunk by ID.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
chunk_id: The chunk ID.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
The chunk if found, None otherwise.
|
|
293
|
+
"""
|
|
294
|
+
table = self._ensure_table()
|
|
295
|
+
|
|
296
|
+
results = table.search().where(f"id = '{chunk_id}'").limit(1).to_pandas()
|
|
297
|
+
|
|
298
|
+
if results.empty:
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
import json
|
|
302
|
+
|
|
303
|
+
row = results.iloc[0]
|
|
304
|
+
return Chunk(
|
|
305
|
+
id=row["id"],
|
|
306
|
+
document_id=row["document_id"],
|
|
307
|
+
content=row["content"],
|
|
308
|
+
embedding=row["vector"].tolist() if hasattr(row["vector"], "tolist") else row["vector"],
|
|
309
|
+
start_char=row["start_char"],
|
|
310
|
+
end_char=row["end_char"],
|
|
311
|
+
metadata=json.loads(row["metadata"]) if row["metadata"] else {},
|
|
312
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def delete_chunks(self, chunk_ids: list[str]) -> int:
|
|
316
|
+
"""Delete chunks by ID.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
chunk_ids: List of chunk IDs to delete.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Number of chunks deleted.
|
|
323
|
+
"""
|
|
324
|
+
if not chunk_ids:
|
|
325
|
+
return 0
|
|
326
|
+
|
|
327
|
+
table = self._ensure_table()
|
|
328
|
+
|
|
329
|
+
# Build delete filter
|
|
330
|
+
id_list = ", ".join(f"'{cid}'" for cid in chunk_ids)
|
|
331
|
+
table.delete(f"id IN ({id_list})")
|
|
332
|
+
|
|
333
|
+
return len(chunk_ids)
|
|
334
|
+
|
|
335
|
+
def delete_by_document(self, document_id: str) -> int:
|
|
336
|
+
"""Delete all chunks for a document.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
document_id: The document ID.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Number of chunks deleted.
|
|
343
|
+
"""
|
|
344
|
+
table = self._ensure_table()
|
|
345
|
+
|
|
346
|
+
# Count before delete
|
|
347
|
+
count = len(table.search().where(f"document_id = '{document_id}'").to_pandas())
|
|
348
|
+
|
|
349
|
+
table.delete(f"document_id = '{document_id}'")
|
|
350
|
+
|
|
351
|
+
return count
|
|
352
|
+
|
|
353
|
+
def count(self) -> int:
|
|
354
|
+
"""Get the total number of chunks."""
|
|
355
|
+
table = self._ensure_table()
|
|
356
|
+
return int(table.count_rows())
|
|
357
|
+
|
|
358
|
+
def clear(self) -> None:
|
|
359
|
+
"""Clear all chunks from the store."""
|
|
360
|
+
if self.TABLE_NAME in self._db.table_names():
|
|
361
|
+
self._db.drop_table(self.TABLE_NAME)
|
|
362
|
+
self._table = None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def get_vector_store() -> VectorStore:
|
|
366
|
+
"""Get the default vector store instance."""
|
|
367
|
+
return VectorStore()
|