Spaces:
Running
Running
| """Production-ready hybrid retrieval helpers for GraphRAG. | |
| This module is intentionally standalone. It consumes existing ChromaDB | |
| collections, NetworkX graphs, ingestion chunks, embedding clients, and optional | |
| BM25 indexes through injection. It does not create persistence layers or change | |
| storage contracts. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import inspect | |
| import re | |
| from dataclasses import dataclass, field | |
| from typing import Any, Iterable, Mapping, Protocol, Sequence | |
| try: # Optional at import time so injected BM25 indexes keep this module usable. | |
| from rank_bm25 import BM25Okapi | |
| except ImportError: # pragma: no cover - exercised only when dependency absent. | |
| BM25Okapi = None # type: ignore[assignment] | |
| RRF_K = 60 | |
| class RetrievalChunk: | |
| """Adapter-safe representation of an ingestion chunk.""" | |
| chunk_id: str | |
| content: str | |
| source: str = "" | |
| content_type: str = "text" | |
| title: str = "" | |
| sequence: int = 0 | |
| metadata: dict[str, Any] = field(default_factory=dict) | |
| class FusedChunk: | |
| """Chunk plus Reciprocal Rank Fusion diagnostics.""" | |
| chunk: RetrievalChunk | |
| fusion_score: float | |
| fused_rank: int | |
| semantic_rank: int | None = None | |
| lexical_rank: int | None = None | |
| class RetrievalResponse: | |
| """Return object for LLM context assembly and benchmark logging.""" | |
| retrieved_chunks: list[RetrievalChunk] | |
| fusion_scores: dict[str, float] | |
| assembled_context: str | |
| fused_ranking: list[FusedChunk] | |
| class Embedder(Protocol): | |
| async def embed_texts(self, texts: list[str], **kwargs: Any) -> list[list[float]]: | |
| """Return one embedding per input text.""" | |
| class VectorCollection(Protocol): | |
| def query(self, **kwargs: Any) -> Mapping[str, Any]: | |
| """Chroma-like collection query API.""" | |
| class GraphLike(Protocol): | |
| def has_node(self, node_for_adding: Any) -> bool: | |
| """Return whether a node exists.""" | |
| def neighbors(self, n: Any) -> Iterable[Any]: | |
| """Return graph neighbors.""" | |
| class BM25Like(Protocol): | |
| def get_scores(self, query_tokens: list[str]) -> Sequence[float]: | |
| """Return one lexical score per indexed document.""" | |
| def tokenize(text: str) -> list[str]: | |
| """Deterministic tokenizer for BM25 with no heavy NLP dependencies.""" | |
| return re.findall(r"[a-z0-9]+", text.lower()) | |
| def adapt_chunk(value: Any) -> RetrievalChunk: | |
| """Convert existing chunk objects or dicts to a stable local shape.""" | |
| if isinstance(value, RetrievalChunk): | |
| return value | |
| if isinstance(value, Mapping): | |
| metadata = value.get("metadata", {}) | |
| return RetrievalChunk( | |
| chunk_id=str(value.get("chunk_id") or value.get("id") or ""), | |
| content=str(value.get("content") or value.get("document") or value.get("text") or ""), | |
| source=str(value.get("source") or value.get("doc_id") or ""), | |
| content_type=str(value.get("content_type") or value.get("type") or "text"), | |
| title=str(value.get("title") or value.get("header") or ""), | |
| sequence=int(value.get("sequence") or 0), | |
| metadata=dict(metadata) if isinstance(metadata, Mapping) else {}, | |
| ) | |
| return RetrievalChunk( | |
| chunk_id=str(getattr(value, "chunk_id", getattr(value, "id", ""))), | |
| content=str(getattr(value, "content", getattr(value, "text", ""))), | |
| source=str(getattr(value, "source", getattr(value, "doc_id", ""))), | |
| content_type=str(getattr(value, "content_type", getattr(value, "type", "text"))), | |
| title=str(getattr(value, "title", getattr(value, "header", ""))), | |
| sequence=int(getattr(value, "sequence", 0) or 0), | |
| metadata=dict(getattr(value, "metadata", {}) or {}), | |
| ) | |
| class HybridRetrievalService: | |
| """Semantic + BM25 + RRF retrieval with shallow graph expansion.""" | |
| def __init__( | |
| self, | |
| *, | |
| collection: VectorCollection, | |
| graph: GraphLike, | |
| chunks: Sequence[Any], | |
| embedder: Embedder, | |
| bm25_index: BM25Like | None = None, | |
| semantic_top_k: int = 20, | |
| lexical_top_k: int = 20, | |
| final_k: int = 6, | |
| doc_id_field: str = "source", | |
| ) -> None: | |
| if semantic_top_k < 1: | |
| raise ValueError("semantic_top_k must be positive.") | |
| if lexical_top_k < 1: | |
| raise ValueError("lexical_top_k must be positive.") | |
| if final_k < 1: | |
| raise ValueError("final_k must be positive.") | |
| adapted = [adapt_chunk(chunk) for chunk in chunks] | |
| self.collection = collection | |
| self.graph = graph | |
| self.embedder = embedder | |
| self.chunks = adapted | |
| self.chunks_by_id = {chunk.chunk_id: chunk for chunk in adapted if chunk.chunk_id} | |
| self.semantic_top_k = semantic_top_k | |
| self.lexical_top_k = lexical_top_k | |
| self.final_k = final_k | |
| self.doc_id_field = doc_id_field | |
| self.bm25_index = bm25_index or self._build_bm25(adapted) | |
| async def retrieve_and_rerank(self, query: str, doc_id: str) -> RetrievalResponse: | |
| """Run semantic retrieval, BM25 retrieval, RRF fusion, and graph expansion.""" | |
| clean_query = _clean_query(query) | |
| clean_doc_id = doc_id.strip() | |
| if not clean_query: | |
| return RetrievalResponse([], {}, "", []) | |
| semantic_task = asyncio.create_task(self._semantic_ranking(clean_query, clean_doc_id)) | |
| lexical_task = asyncio.create_task(asyncio.to_thread(self._lexical_ranking, clean_query, clean_doc_id)) | |
| semantic_ids, lexical_ids = await asyncio.gather(semantic_task, lexical_task) | |
| fused = self._fuse_rankings(semantic_ids, lexical_ids) | |
| retrieved = [item.chunk for item in fused[: self.final_k]] | |
| context = self._assemble_context(fused[0].chunk if fused else None, retrieved) | |
| return RetrievalResponse( | |
| retrieved_chunks=retrieved, | |
| fusion_scores={item.chunk.chunk_id: item.fusion_score for item in fused}, | |
| assembled_context=context, | |
| fused_ranking=fused, | |
| ) | |
| async def _semantic_ranking(self, query: str, doc_id: str) -> list[str]: | |
| embedding = await _embed_query(self.embedder, query) | |
| kwargs: dict[str, Any] = { | |
| "query_embeddings": [embedding], | |
| "n_results": max(1, self.semantic_top_k), | |
| "include": ["documents", "metadatas", "distances"], | |
| } | |
| if doc_id: | |
| kwargs["where"] = {self.doc_id_field: doc_id} | |
| response = await asyncio.to_thread(self.collection.query, **kwargs) | |
| return _extract_chroma_ids(response) | |
| def _lexical_ranking(self, query: str, doc_id: str) -> list[str]: | |
| query_tokens = tokenize(query) | |
| if not query_tokens: | |
| return [] | |
| scores = list(self.bm25_index.get_scores(query_tokens)) | |
| ranked_indices = sorted(range(len(scores)), key=lambda index: scores[index], reverse=True) | |
| ranked_ids: list[str] = [] | |
| for index in ranked_indices: | |
| if len(ranked_ids) >= self.lexical_top_k: | |
| break | |
| if index >= len(self.chunks) or scores[index] <= 0: | |
| continue | |
| chunk = self.chunks[index] | |
| if doc_id and not self._chunk_matches_doc_id(chunk, doc_id): | |
| continue | |
| ranked_ids.append(chunk.chunk_id) | |
| return ranked_ids | |
| def _chunk_matches_doc_id(self, chunk: RetrievalChunk, doc_id: str) -> bool: | |
| if chunk.source == doc_id: | |
| return True | |
| metadata_value = chunk.metadata.get(self.doc_id_field) | |
| return metadata_value == doc_id | |
| def _fuse_rankings(self, semantic_ids: Sequence[str], lexical_ids: Sequence[str]) -> list[FusedChunk]: | |
| scores: dict[str, float] = {} | |
| semantic_rank: dict[str, int] = {} | |
| lexical_rank: dict[str, int] = {} | |
| for rank, chunk_id in enumerate(_dedupe(semantic_ids), start=1): | |
| if chunk_id in self.chunks_by_id: | |
| semantic_rank[chunk_id] = rank | |
| scores[chunk_id] = scores.get(chunk_id, 0.0) + (1.0 / (RRF_K + rank)) | |
| for rank, chunk_id in enumerate(_dedupe(lexical_ids), start=1): | |
| if chunk_id in self.chunks_by_id: | |
| lexical_rank[chunk_id] = rank | |
| scores[chunk_id] = scores.get(chunk_id, 0.0) + (1.0 / (RRF_K + rank)) | |
| ordered_ids = sorted(scores, key=lambda chunk_id: (-scores[chunk_id], semantic_rank.get(chunk_id, 10**9), lexical_rank.get(chunk_id, 10**9), chunk_id)) | |
| return [ | |
| FusedChunk( | |
| chunk=self.chunks_by_id[chunk_id], | |
| fusion_score=scores[chunk_id], | |
| fused_rank=rank, | |
| semantic_rank=semantic_rank.get(chunk_id), | |
| lexical_rank=lexical_rank.get(chunk_id), | |
| ) | |
| for rank, chunk_id in enumerate(ordered_ids, start=1) | |
| ] | |
| def _assemble_context(self, top_chunk: RetrievalChunk | None, retrieved: Sequence[RetrievalChunk]) -> str: | |
| if top_chunk is None: | |
| return "" | |
| ordered: list[RetrievalChunk] = [] | |
| seen: set[str] = set() | |
| def append(chunk: RetrievalChunk) -> None: | |
| if chunk.chunk_id and chunk.chunk_id not in seen: | |
| ordered.append(chunk) | |
| seen.add(chunk.chunk_id) | |
| for parent in self._parent_headers(top_chunk): | |
| append(parent) | |
| append(top_chunk) | |
| for sibling in self._immediate_siblings(top_chunk): | |
| append(sibling) | |
| for chunk in retrieved: | |
| append(chunk) | |
| blocks = [] | |
| for index, chunk in enumerate(ordered, start=1): | |
| title = f" | {chunk.title}" if chunk.title else "" | |
| blocks.append( | |
| "\n".join( | |
| [ | |
| f"[{index}] chunk_id={chunk.chunk_id} source={chunk.source} type={chunk.content_type}{title}", | |
| chunk.content.strip(), | |
| ] | |
| ).strip() | |
| ) | |
| return "\n\n---\n\n".join(blocks) | |
| def _parent_headers(self, chunk: RetrievalChunk) -> list[RetrievalChunk]: | |
| parent_ids = _metadata_ids(chunk.metadata, ("parent_header_id", "parent_id", "header_id")) | |
| parent_ids.extend(self._graph_related_ids(chunk.chunk_id, relation_names={"parent", "parent_header", "header", "belongs_to"})) | |
| parents = [self.chunks_by_id[item_id] for item_id in _dedupe(parent_ids) if item_id in self.chunks_by_id] | |
| return sorted(parents, key=lambda item: item.sequence) | |
| def _immediate_siblings(self, chunk: RetrievalChunk) -> list[RetrievalChunk]: | |
| sibling_ids = _metadata_ids(chunk.metadata, ("previous_sibling_id", "next_sibling_id", "sibling_ids")) | |
| sibling_ids.extend(self._graph_related_ids(chunk.chunk_id, relation_names={"sibling", "previous_sibling", "next_sibling"})) | |
| parent_ids = _metadata_ids(chunk.metadata, ("parent_header_id", "parent_id", "header_id")) | |
| for parent_id in parent_ids: | |
| sibling_ids.extend(self._graph_related_ids(parent_id, relation_names={"child", "contains"})) | |
| siblings = [ | |
| self.chunks_by_id[item_id] | |
| for item_id in _dedupe(sibling_ids) | |
| if item_id in self.chunks_by_id and item_id != chunk.chunk_id | |
| ] | |
| return sorted(siblings, key=lambda item: item.sequence) | |
| def _graph_related_ids(self, node_id: str, *, relation_names: set[str]) -> list[str]: | |
| if not node_id or not self.graph.has_node(node_id): | |
| return [] | |
| related: list[str] = [] | |
| for neighbor in self.graph.neighbors(node_id): | |
| edge_data = _edge_data(self.graph, node_id, neighbor) | |
| relation = str(edge_data.get("relation", edge_data.get("type", ""))).lower() | |
| if not relation or relation in relation_names: | |
| related.append(str(neighbor)) | |
| predecessors = getattr(self.graph, "predecessors", None) | |
| if callable(predecessors): | |
| for neighbor in predecessors(node_id): | |
| edge_data = _edge_data(self.graph, neighbor, node_id) | |
| relation = str(edge_data.get("relation", edge_data.get("type", ""))).lower() | |
| if not relation or relation in relation_names: | |
| related.append(str(neighbor)) | |
| return related | |
| def _build_bm25(self, chunks: Sequence[RetrievalChunk]) -> BM25Like: | |
| if BM25Okapi is None: | |
| raise RuntimeError("rank_bm25 is required when bm25_index is not injected.") | |
| corpus = [tokenize(chunk.content) for chunk in chunks] | |
| return BM25Okapi(corpus) | |
| async def retrieve_and_rerank( | |
| query: str, | |
| doc_id: str, | |
| *, | |
| collection: VectorCollection, | |
| graph: GraphLike, | |
| chunks: Sequence[Any], | |
| embedder: Embedder, | |
| bm25_index: BM25Like | None = None, | |
| semantic_top_k: int = 20, | |
| lexical_top_k: int = 20, | |
| final_k: int = 6, | |
| doc_id_field: str = "source", | |
| ) -> RetrievalResponse: | |
| """Function-parameter injection wrapper around ``HybridRetrievalService``.""" | |
| service = HybridRetrievalService( | |
| collection=collection, | |
| graph=graph, | |
| chunks=chunks, | |
| embedder=embedder, | |
| bm25_index=bm25_index, | |
| semantic_top_k=semantic_top_k, | |
| lexical_top_k=lexical_top_k, | |
| final_k=final_k, | |
| doc_id_field=doc_id_field, | |
| ) | |
| return await service.retrieve_and_rerank(query, doc_id) | |
| async def _embed_query(embedder: Embedder, query: str) -> list[float]: | |
| """Call NVIDIA embeddings with input_type='query' when the client supports it.""" | |
| embed_query = getattr(embedder, "embed_query", None) | |
| if callable(embed_query): | |
| value = embed_query(query, input_type="query") | |
| result = await value if inspect.isawaitable(value) else value | |
| return list(result) | |
| try: | |
| value = embedder.embed_texts([query], input_type="query") | |
| embeddings = await value if inspect.isawaitable(value) else value | |
| except TypeError: | |
| value = embedder.embed_texts([query]) | |
| embeddings = await value if inspect.isawaitable(value) else value | |
| return list(embeddings[0]) | |
| def _extract_chroma_ids(response: Mapping[str, Any]) -> list[str]: | |
| ids = response.get("ids", []) | |
| if not ids: | |
| return [] | |
| first = ids[0] if isinstance(ids, Sequence) and not isinstance(ids, (str, bytes)) else ids | |
| return [str(item) for item in first] | |
| def _clean_query(query: str) -> str: | |
| return re.sub(r"\s+", " ", query).strip() | |
| def _dedupe(values: Iterable[str]) -> list[str]: | |
| seen: set[str] = set() | |
| ordered: list[str] = [] | |
| for value in values: | |
| if value and value not in seen: | |
| ordered.append(value) | |
| seen.add(value) | |
| return ordered | |
| def _metadata_ids(metadata: Mapping[str, Any], keys: Sequence[str]) -> list[str]: | |
| ids: list[str] = [] | |
| for key in keys: | |
| value = metadata.get(key) | |
| if value is None: | |
| continue | |
| if isinstance(value, (list, tuple, set)): | |
| ids.extend(str(item) for item in value if item) | |
| else: | |
| ids.append(str(value)) | |
| return ids | |
| def _edge_data(graph: Any, source: str, target: str) -> Mapping[str, Any]: | |
| getter = getattr(graph, "get_edge_data", None) | |
| if not callable(getter): | |
| return {} | |
| data = getter(source, target, default={}) | |
| if not isinstance(data, Mapping): | |
| return {} | |
| if "relation" in data or "type" in data: | |
| return data | |
| for value in data.values(): | |
| if isinstance(value, Mapping): | |
| return value | |
| return data | |