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.
Files changed (37) hide show
  1. docugraph/__init__.py +24 -0
  2. docugraph/_version.py +28 -0
  3. docugraph/agents/__init__.py +27 -0
  4. docugraph/agents/coding_agent.py +532 -0
  5. docugraph/agents/tools/__init__.py +62 -0
  6. docugraph/agents/tools/graph_query.py +203 -0
  7. docugraph/agents/tools/memory_ops.py +305 -0
  8. docugraph/agents/tools/search_code.py +305 -0
  9. docugraph/agents/tools/search_docs.py +148 -0
  10. docugraph/core/__init__.py +34 -0
  11. docugraph/core/config.py +183 -0
  12. docugraph/core/embeddings.py +394 -0
  13. docugraph/core/llm.py +892 -0
  14. docugraph/core/models.py +145 -0
  15. docugraph/ingestion/__init__.py +64 -0
  16. docugraph/ingestion/api_docs.py +646 -0
  17. docugraph/ingestion/chunker.py +382 -0
  18. docugraph/ingestion/crawler.py +305 -0
  19. docugraph/ingestion/git_indexer.py +556 -0
  20. docugraph/ingestion/local_files.py +516 -0
  21. docugraph/interfaces/__init__.py +1 -0
  22. docugraph/interfaces/cli.py +983 -0
  23. docugraph/interfaces/mcp_server.py +918 -0
  24. docugraph/retrieval/__init__.py +54 -0
  25. docugraph/retrieval/context_builder.py +522 -0
  26. docugraph/retrieval/hybrid_search.py +534 -0
  27. docugraph/retrieval/reranker.py +532 -0
  28. docugraph/storage/__init__.py +14 -0
  29. docugraph/storage/graph_store.py +602 -0
  30. docugraph/storage/memory_store.py +529 -0
  31. docugraph/storage/vector_store.py +367 -0
  32. docugraph-0.1.0.dist-info/METADATA +354 -0
  33. docugraph-0.1.0.dist-info/RECORD +37 -0
  34. docugraph-0.1.0.dist-info/WHEEL +4 -0
  35. docugraph-0.1.0.dist-info/entry_points.txt +3 -0
  36. docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
  37. 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()