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,534 @@
1
+ """Hybrid search combining vector, keyword, and graph retrieval.
2
+
3
+ Implements multiple fusion strategies:
4
+ - Reciprocal Rank Fusion (RRF) - default, robust
5
+ - Weighted Score Fusion - for fine-tuned weights
6
+ - Interleaved - alternating results from each source
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from dataclasses import dataclass, field
13
+ from enum import StrEnum
14
+ from typing import Any
15
+
16
+ from docugraph.core.models import Chunk, SearchResult
17
+
18
+
19
+ class FusionStrategy(StrEnum):
20
+ """Strategy for combining search results."""
21
+
22
+ RRF = "rrf" # Reciprocal Rank Fusion
23
+ WEIGHTED = "weighted" # Weighted score combination
24
+ INTERLEAVED = "interleaved" # Alternating results
25
+
26
+
27
+ class SearchMode(StrEnum):
28
+ """Search modes available."""
29
+
30
+ VECTOR = "vector" # Vector similarity only
31
+ KEYWORD = "keyword" # BM25/keyword only
32
+ HYBRID = "hybrid" # Vector + keyword
33
+ GRAPH = "graph" # Graph traversal only
34
+ ALL = "all" # Vector + keyword + graph
35
+
36
+
37
+ @dataclass
38
+ class HybridSearchConfig:
39
+ """Configuration for hybrid search."""
40
+
41
+ # Weights for each search type (used in weighted fusion)
42
+ vector_weight: float = 0.5
43
+ keyword_weight: float = 0.3
44
+ graph_weight: float = 0.2
45
+
46
+ # RRF parameter (higher = more emphasis on top ranks)
47
+ rrf_k: int = 60
48
+
49
+ # Whether to include graph search
50
+ include_graph: bool = False
51
+
52
+ # Minimum score threshold (0-1)
53
+ min_score: float = 0.0
54
+
55
+ # Deduplication
56
+ deduplicate: bool = True
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class GraphLegStatus:
61
+ """What happened to the graph leg of the most recent search.
62
+
63
+ The graph leg is optional -- it needs a graph store on disk, which needs an
64
+ LLM. When it cannot run, the caller still gets vector and/or keyword
65
+ results, and that is the whole point of degrading. What must not happen is
66
+ the *reason* disappearing: "asked for graph results and got none" would then
67
+ be indistinguishable from "the graph had nothing to say", and a silent
68
+ failure would read as a successful empty answer.
69
+ """
70
+
71
+ requested: bool = False
72
+ available: bool = False
73
+ reason: str | None = None
74
+
75
+
76
+ @dataclass
77
+ class HybridResult:
78
+ """Result from hybrid search with source attribution."""
79
+
80
+ chunk: Chunk
81
+ score: float
82
+ sources: list[str] = field(default_factory=list) # Which searches found this
83
+ vector_score: float | None = None
84
+ keyword_score: float | None = None
85
+ graph_score: float | None = None
86
+ metadata: dict[str, Any] = field(default_factory=dict)
87
+
88
+ def to_search_result(self) -> SearchResult:
89
+ """Convert to standard SearchResult."""
90
+ return SearchResult(chunk=self.chunk, score=self.score)
91
+
92
+
93
+ class HybridRetriever:
94
+ """Hybrid retriever combining multiple search strategies.
95
+
96
+ Combines results from:
97
+ - Vector search (semantic similarity via embeddings)
98
+ - Keyword search (BM25 full-text search)
99
+ - Graph search (knowledge graph traversal, optional)
100
+
101
+ Uses configurable fusion strategies to combine results.
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ vector_store: Any | None = None,
107
+ graph_store: Any | None = None,
108
+ config: HybridSearchConfig | None = None,
109
+ ) -> None:
110
+ """Initialize the hybrid retriever.
111
+
112
+ Args:
113
+ vector_store: VectorStore instance (created if not provided)
114
+ graph_store: GraphStore instance (optional, for graph search)
115
+ config: Search configuration
116
+ """
117
+ self._vector_store = vector_store
118
+ self._graph_store = graph_store
119
+ self._config = config or HybridSearchConfig()
120
+ # Why the graph store could not be built or queried during this call.
121
+ # Carried forward to `graph_status`; see GraphLegStatus.
122
+ self._graph_store_error: str | None = None
123
+ # Set by every `search()` call: whether the graph leg was asked for and
124
+ # whether it actually ran.
125
+ self.graph_status = GraphLegStatus()
126
+
127
+ def _get_vector_store(self) -> Any:
128
+ """Lazy load vector store."""
129
+ if self._vector_store is None:
130
+ from docugraph.storage.vector_store import VectorStore
131
+
132
+ self._vector_store = VectorStore()
133
+ return self._vector_store
134
+
135
+ def _get_graph_store(self) -> Any | None:
136
+ """Lazy load graph store if configured.
137
+
138
+ A failure here is not raised -- the graph is optional -- but the reason
139
+ is kept in ``self._graph_store_error`` rather than discarded, so the
140
+ caller can be told the graph did not run.
141
+ """
142
+ if self._graph_store is None and self._config.include_graph:
143
+ try:
144
+ from docugraph.storage.graph_store import GraphStore
145
+
146
+ self._graph_store = GraphStore()
147
+ except Exception as e: # nosec B110 -- graph is optional; degrade to vector/keyword
148
+ self._graph_store_error = f"the graph store could not be opened: {e}"
149
+ return self._graph_store
150
+
151
+ async def asearch(
152
+ self,
153
+ query: str,
154
+ top_k: int = 10,
155
+ mode: SearchMode = SearchMode.HYBRID,
156
+ fusion: FusionStrategy = FusionStrategy.RRF,
157
+ filters: dict[str, Any] | None = None,
158
+ config: HybridSearchConfig | None = None,
159
+ ) -> list[HybridResult]:
160
+ """Perform hybrid search.
161
+
162
+ This is the implementation. Callers that are already async -- the MCP
163
+ server, the agent loop -- await it directly, which is the only correct
164
+ thing to do from inside a running event loop.
165
+
166
+ Args:
167
+ query: Search query text
168
+ top_k: Number of results to return
169
+ mode: Search mode (vector, keyword, hybrid, graph, all)
170
+ fusion: Fusion strategy for combining results
171
+ filters: Optional filters (passed to vector store)
172
+ config: Override default config for this search
173
+
174
+ Returns:
175
+ List of hybrid results with scores and source attribution
176
+ """
177
+ cfg = config or self._config
178
+
179
+ # Collect results from each source
180
+ results_by_source: dict[str, list[tuple[str, float]]] = {}
181
+
182
+ # Vector search
183
+ if mode in (SearchMode.VECTOR, SearchMode.HYBRID, SearchMode.ALL):
184
+ vector_results = self._vector_search(query, top_k * 2, filters)
185
+ results_by_source["vector"] = vector_results
186
+
187
+ # Keyword search
188
+ if mode in (SearchMode.KEYWORD, SearchMode.HYBRID, SearchMode.ALL):
189
+ keyword_results = self._keyword_search(query, top_k * 2, filters)
190
+ results_by_source["keyword"] = keyword_results
191
+
192
+ # Graph search. Optional: it is skipped or fails without stopping the
193
+ # search, but what happened is recorded either way and reported through
194
+ # `graph_status`. It is awaited in place -- `asearch` is already async,
195
+ # so there is no loop to acquire here.
196
+ graph_requested = mode in (SearchMode.GRAPH, SearchMode.ALL)
197
+ self._graph_store_error = None
198
+ graph_available = False
199
+ graph_reason: str | None = None
200
+
201
+ if graph_requested and cfg.include_graph:
202
+ try:
203
+ graph_results = await self._graph_search(query, top_k * 2)
204
+ results_by_source["graph"] = graph_results
205
+ graph_available = True
206
+ except Exception as e: # nosec B110 -- graph search may fail if not configured
207
+ graph_reason = f"the graph search could not be run: {e}"
208
+
209
+ # A store that failed to open reports through `_graph_store_error`
210
+ # rather than as an exception, so it is resolved after the fact.
211
+ if self._graph_store_error is not None:
212
+ graph_available = False
213
+ graph_reason = self._graph_store_error
214
+ elif graph_requested:
215
+ graph_reason = "graph search is disabled (set include_graph=true)"
216
+
217
+ self.graph_status = GraphLegStatus(
218
+ requested=graph_requested,
219
+ available=graph_available,
220
+ reason=graph_reason,
221
+ )
222
+
223
+ # Fuse results
224
+ if fusion == FusionStrategy.RRF:
225
+ fused = self._fuse_rrf(results_by_source, cfg.rrf_k)
226
+ elif fusion == FusionStrategy.WEIGHTED:
227
+ fused = self._fuse_weighted(results_by_source, cfg)
228
+ else: # INTERLEAVED
229
+ fused = self._fuse_interleaved(results_by_source)
230
+
231
+ # Deduplicate if configured
232
+ if cfg.deduplicate:
233
+ fused = self._deduplicate(fused)
234
+
235
+ # Apply minimum score filter (fused tuples are (chunk_id, score, sources))
236
+ if cfg.min_score > 0:
237
+ fused = [r for r in fused if r[1] >= cfg.min_score]
238
+
239
+ # Retrieve full chunks and build results
240
+ return self._build_results(fused, results_by_source, top_k)
241
+
242
+ def search(
243
+ self,
244
+ query: str,
245
+ top_k: int = 10,
246
+ mode: SearchMode = SearchMode.HYBRID,
247
+ fusion: FusionStrategy = FusionStrategy.RRF,
248
+ filters: dict[str, Any] | None = None,
249
+ config: HybridSearchConfig | None = None,
250
+ ) -> list[HybridResult]:
251
+ """Perform hybrid search from synchronous code.
252
+
253
+ A thin wrapper over ``asearch``, for callers that have no event loop --
254
+ the CLI. It must not be called from inside a running loop: this drives
255
+ a fresh loop, and calling it from within one raises. Async callers
256
+ await ``asearch`` instead; they do not use this.
257
+
258
+ Args:
259
+ As ``asearch``.
260
+
261
+ Returns:
262
+ List of hybrid results with scores and source attribution
263
+ """
264
+ return asyncio.run(
265
+ self.asearch(
266
+ query,
267
+ top_k=top_k,
268
+ mode=mode,
269
+ fusion=fusion,
270
+ filters=filters,
271
+ config=config,
272
+ )
273
+ )
274
+
275
+ def _vector_search(
276
+ self,
277
+ query: str,
278
+ top_k: int,
279
+ filters: dict[str, Any] | None,
280
+ ) -> list[tuple[str, float]]:
281
+ """Perform vector similarity search.
282
+
283
+ Returns:
284
+ List of (chunk_id, score) tuples
285
+ """
286
+ store = self._get_vector_store()
287
+ filter_expr = self._build_filter_expr(filters) if filters else None
288
+
289
+ results = store.search(query, top_k=top_k, filter_expr=filter_expr)
290
+ return [(r.chunk.id, r.score) for r in results]
291
+
292
+ def _keyword_search(
293
+ self,
294
+ query: str,
295
+ top_k: int,
296
+ filters: dict[str, Any] | None,
297
+ ) -> list[tuple[str, float]]:
298
+ """Perform keyword/BM25 search.
299
+
300
+ Returns:
301
+ List of (chunk_id, score) tuples
302
+ """
303
+ store = self._get_vector_store()
304
+ filter_expr = self._build_filter_expr(filters) if filters else None
305
+
306
+ # Keyword-side source: LanceDB native hybrid (vector+FTS) with any
307
+ # filters applied, so keyword mode respects the same scoping as vector.
308
+ results = store.search_hybrid(query, top_k=top_k, filter_expr=filter_expr)
309
+ return [(r.chunk.id, r.score) for r in results]
310
+
311
+ async def _graph_search(
312
+ self,
313
+ query: str,
314
+ top_k: int,
315
+ ) -> list[tuple[str, float]]:
316
+ """Perform graph-based search.
317
+
318
+ Returns:
319
+ List of (chunk_id, score) tuples
320
+ """
321
+ store = self._get_graph_store()
322
+ if store is None:
323
+ return []
324
+
325
+ try:
326
+ results = await store.search(query, num_results=top_k)
327
+ # Graph search returns facts, not chunks directly
328
+ # We'll use the fact UUID as an identifier and assign scores
329
+ return [
330
+ (r.get("uuid", ""), 1.0 / (i + 1)) # Score by rank
331
+ for i, r in enumerate(results)
332
+ ]
333
+ except Exception as e:
334
+ self._graph_store_error = f"the graph store could not be queried: {e}"
335
+ return []
336
+
337
+ def _fuse_rrf(
338
+ self,
339
+ results_by_source: dict[str, list[tuple[str, float]]],
340
+ k: int = 60,
341
+ ) -> list[tuple[str, float, list[str]]]:
342
+ """Fuse results using Reciprocal Rank Fusion.
343
+
344
+ RRF score = sum(1 / (k + rank)) for each source
345
+
346
+ Args:
347
+ results_by_source: Dict mapping source name to (id, score) tuples
348
+ k: RRF constant (default 60)
349
+
350
+ Returns:
351
+ List of (id, fused_score, sources) tuples sorted by score
352
+ """
353
+ scores: dict[str, float] = {}
354
+ sources: dict[str, list[str]] = {}
355
+
356
+ for source, results in results_by_source.items():
357
+ for rank, (doc_id, _) in enumerate(results, 1):
358
+ rrf_score = 1.0 / (k + rank)
359
+ scores[doc_id] = scores.get(doc_id, 0) + rrf_score
360
+
361
+ if doc_id not in sources:
362
+ sources[doc_id] = []
363
+ sources[doc_id].append(source)
364
+
365
+ # Sort by score descending
366
+ sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
367
+ return [(doc_id, score, sources.get(doc_id, [])) for doc_id, score in sorted_results]
368
+
369
+ def _fuse_weighted(
370
+ self,
371
+ results_by_source: dict[str, list[tuple[str, float]]],
372
+ config: HybridSearchConfig,
373
+ ) -> list[tuple[str, float, list[str]]]:
374
+ """Fuse results using weighted score combination.
375
+
376
+ Args:
377
+ results_by_source: Dict mapping source name to (id, score) tuples
378
+ config: Config with weight settings
379
+
380
+ Returns:
381
+ List of (id, fused_score, sources) tuples sorted by score
382
+ """
383
+ weights = {
384
+ "vector": config.vector_weight,
385
+ "keyword": config.keyword_weight,
386
+ "graph": config.graph_weight,
387
+ }
388
+
389
+ scores: dict[str, float] = {}
390
+ sources: dict[str, list[str]] = {}
391
+
392
+ for source, results in results_by_source.items():
393
+ weight = weights.get(source, 0.0)
394
+ for doc_id, score in results:
395
+ weighted_score = score * weight
396
+ scores[doc_id] = scores.get(doc_id, 0) + weighted_score
397
+
398
+ if doc_id not in sources:
399
+ sources[doc_id] = []
400
+ sources[doc_id].append(source)
401
+
402
+ # Sort by score descending
403
+ sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
404
+ return [(doc_id, score, sources.get(doc_id, [])) for doc_id, score in sorted_results]
405
+
406
+ def _fuse_interleaved(
407
+ self,
408
+ results_by_source: dict[str, list[tuple[str, float]]],
409
+ ) -> list[tuple[str, float, list[str]]]:
410
+ """Fuse results by interleaving from each source.
411
+
412
+ Takes results round-robin from each source.
413
+
414
+ Returns:
415
+ List of (id, fused_score, sources) tuples
416
+ """
417
+ seen: set[str] = set()
418
+ fused: list[tuple[str, float, list[str]]] = []
419
+
420
+ # Get iterators for each source
421
+ iterators = {source: iter(results) for source, results in results_by_source.items()}
422
+
423
+ # Round-robin interleave
424
+ rank = 0
425
+ while iterators:
426
+ exhausted = []
427
+ for source, it in iterators.items():
428
+ try:
429
+ doc_id, score = next(it)
430
+ if doc_id not in seen:
431
+ seen.add(doc_id)
432
+ rank += 1
433
+ # Assign score based on interleaved rank
434
+ fused.append((doc_id, 1.0 / rank, [source]))
435
+ except StopIteration:
436
+ exhausted.append(source)
437
+
438
+ for source in exhausted:
439
+ del iterators[source]
440
+
441
+ return fused
442
+
443
+ def _deduplicate(
444
+ self,
445
+ results: list[tuple[str, float, list[str]]],
446
+ ) -> list[tuple[str, float, list[str]]]:
447
+ """Remove duplicate document IDs, keeping highest score."""
448
+ seen: set[str] = set()
449
+ deduped: list[tuple[str, float, list[str]]] = []
450
+
451
+ for doc_id, score, sources in results:
452
+ if doc_id not in seen:
453
+ seen.add(doc_id)
454
+ deduped.append((doc_id, score, sources))
455
+
456
+ return deduped
457
+
458
+ def _build_filter_expr(self, filters: dict[str, Any]) -> str:
459
+ """Build SQL-like filter expression from filter dict."""
460
+ conditions = []
461
+ for key, value in filters.items():
462
+ if isinstance(value, str):
463
+ conditions.append(f"{key} = '{value}'")
464
+ elif isinstance(value, (int, float)):
465
+ conditions.append(f"{key} = {value}")
466
+ elif isinstance(value, list):
467
+ values = ", ".join(f"'{v}'" if isinstance(v, str) else str(v) for v in value)
468
+ conditions.append(f"{key} IN ({values})")
469
+
470
+ return " AND ".join(conditions) if conditions else ""
471
+
472
+ def _build_results(
473
+ self,
474
+ fused: list[tuple[str, float, list[str]]],
475
+ results_by_source: dict[str, list[tuple[str, float]]],
476
+ top_k: int,
477
+ ) -> list[HybridResult]:
478
+ """Build HybridResult objects from fused scores.
479
+
480
+ Args:
481
+ fused: List of (id, score, sources) tuples
482
+ results_by_source: Original results with per-source scores
483
+ top_k: Maximum results to return
484
+
485
+ Returns:
486
+ List of HybridResult objects
487
+ """
488
+ # Build lookup for per-source scores
489
+ source_scores: dict[str, dict[str, float]] = {}
490
+ for source, results in results_by_source.items():
491
+ source_scores[source] = dict(results)
492
+
493
+ # Get chunks from vector store
494
+ store = self._get_vector_store()
495
+ hybrid_results: list[HybridResult] = []
496
+
497
+ for doc_id, score, sources in fused[:top_k]:
498
+ chunk = store.get_chunk(doc_id)
499
+ if chunk is None:
500
+ continue
501
+
502
+ hybrid_results.append(
503
+ HybridResult(
504
+ chunk=chunk,
505
+ score=score,
506
+ sources=sources,
507
+ vector_score=source_scores.get("vector", {}).get(doc_id),
508
+ keyword_score=source_scores.get("keyword", {}).get(doc_id),
509
+ graph_score=source_scores.get("graph", {}).get(doc_id),
510
+ )
511
+ )
512
+
513
+ return hybrid_results
514
+
515
+
516
+ def get_hybrid_retriever(
517
+ config: HybridSearchConfig | None = None,
518
+ include_graph: bool = False,
519
+ ) -> HybridRetriever:
520
+ """Factory function to get a HybridRetriever instance.
521
+
522
+ Args:
523
+ config: Optional search configuration
524
+ include_graph: Whether to include graph search
525
+
526
+ Returns:
527
+ HybridRetriever instance
528
+ """
529
+ if config is None:
530
+ config = HybridSearchConfig(include_graph=include_graph)
531
+ else:
532
+ config.include_graph = include_graph
533
+
534
+ return HybridRetriever(config=config)