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,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)
|