Skip to content

Commit 0732505

Browse files
authored
Merge pull request #1 from deesatzed/port/fractal-retriever-plugin
Port FractRAG multi-scale retrieval as RetrieverPlugin
2 parents 3e2ba67 + 13c328a commit 0732505

19 files changed

Lines changed: 3808 additions & 0 deletions

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,24 @@ benchmark model-backed embeddings such as `intfloat/e5-base-v2`, BGE, Jina,
6161
Nomic, OpenAI, or Voyage adapters, then plug the winning backend into
6262
`DenseVectorRetriever`.
6363

64+
65+
## Fractal multi-scale retrieval (opt-in)
66+
67+
CAM-RAG can use fractLrag-style three-level indexing (sentence / paragraph /
68+
document) with derivative signals between levels. It is **not** on by default.
69+
70+
```python
71+
from cam_rag.rag.spec import RAGAppSpec
72+
from cam_rag.retrieval.fractal import FractalRetrieverPlugin, HashEmbedding
73+
74+
plugin = FractalRetrieverPlugin(backend=HashEmbedding(dim=64))
75+
spec = RAGAppSpec(name="my-app", use_pipeline=True, retriever_plugins=[plugin])
76+
```
77+
78+
`HashEmbedding` is deterministic and needs no GPU. Optional
79+
`SentenceTransformerEmbedding` backends (e.g. BGE-M3) require
80+
`sentence-transformers`.
81+
6482
## Current Status
6583

6684
Alpha platform scaffold with working document-folder ingestion, chunking,

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ authors = [
1414
]
1515
dependencies = [
1616
"pydantic>=2.0",
17+
"numpy>=1.21.0",
18+
"pyyaml>=6.0",
1719
]
1820

1921
[project.optional-dependencies]
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Fractal multi-scale retrieval with derivative signals between levels.
2+
3+
Ported from fractLrag (deesatzed/fractLrag) into CAM-RAG as an optional
4+
``RetrieverPlugin``. The fractal index is **not** enabled by default —
5+
callers must pass ``FractalRetrieverPlugin`` in ``RAGAppSpec.retriever_plugins``.
6+
7+
Documents are indexed at three self-similar levels (sentence / paragraph /
8+
document). First- and second-order derivative signals between levels act as
9+
scoring bonuses. ``retrieve_adaptive`` classifies the query type and routes
10+
to flat, reranked, or RRF retrieval.
11+
12+
Hash embeddings work out of the box so tests do not need sentence-transformers
13+
or a GPU. Real semantic backends (e.g. BGE-M3) are optional.
14+
"""
15+
16+
from cam_rag.retrieval.fractal.core import (
17+
EmbeddingBackend,
18+
HashEmbedding,
19+
SentenceTransformerEmbedding,
20+
normalize,
21+
)
22+
from cam_rag.retrieval.fractal.engine import FractalRAG, IndexEntry
23+
from cam_rag.retrieval.fractal.plugin import FractalRetrieverPlugin
24+
from cam_rag.retrieval.fractal.profile import DocumentProfile
25+
from cam_rag.retrieval.fractal.query import (
26+
classify_query_type,
27+
extract_domain_hints,
28+
get_type_weights,
29+
)
30+
from cam_rag.retrieval.fractal.storage import DimensionMismatchError, load, save
31+
32+
__all__ = [
33+
"DimensionMismatchError",
34+
"DocumentProfile",
35+
"EmbeddingBackend",
36+
"FractalRAG",
37+
"FractalRetrieverPlugin",
38+
"HashEmbedding",
39+
"IndexEntry",
40+
"SentenceTransformerEmbedding",
41+
"classify_query_type",
42+
"extract_domain_hints",
43+
"get_type_weights",
44+
"load",
45+
"normalize",
46+
"save",
47+
]
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""
2+
Shared primitives for Fractal Latent RAG.
3+
4+
All embedding, normalization, and chunking logic lives here — ONE copy.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import hashlib
10+
from typing import Protocol, runtime_checkable
11+
12+
import numpy as np
13+
14+
15+
@runtime_checkable
16+
class EmbeddingBackend(Protocol):
17+
"""Protocol for pluggable embedding backends."""
18+
def embed(self, text: str) -> np.ndarray: ...
19+
@property
20+
def dim(self) -> int: ...
21+
22+
23+
class HashEmbedding:
24+
"""Deterministic MD5-seeded random vectors. For testing and reproducibility only.
25+
These vectors have NO semantic meaning — cosine similarity is random noise."""
26+
27+
def __init__(self, dim: int = 64):
28+
self._dim = dim
29+
30+
def embed(self, text: str) -> np.ndarray:
31+
seed = int(hashlib.md5(text.encode('utf-8')).hexdigest(), 16) % (2**32)
32+
rng = np.random.default_rng(seed)
33+
vec = rng.standard_normal(self._dim).astype(np.float32)
34+
return vec / np.linalg.norm(vec)
35+
36+
@property
37+
def dim(self) -> int:
38+
return self._dim
39+
40+
41+
class SentenceTransformerEmbedding:
42+
"""Real semantic embeddings via sentence-transformers.
43+
Default model: BAAI/bge-m3 (1024 dim, multi-granularity native).
44+
"""
45+
46+
def __init__(self, model_name: str = "BAAI/bge-m3"):
47+
from sentence_transformers import SentenceTransformer
48+
self._model = SentenceTransformer(model_name)
49+
self._dim = self._model.get_sentence_embedding_dimension()
50+
51+
def embed(self, text: str) -> np.ndarray:
52+
return self._model.encode(text, normalize_embeddings=True).astype(np.float32)
53+
54+
@property
55+
def dim(self) -> int:
56+
return self._dim
57+
58+
59+
def normalize(vec: np.ndarray) -> np.ndarray:
60+
"""Safe L2 normalization. Returns zero vector on zero-norm input."""
61+
norm = np.linalg.norm(vec)
62+
return vec / norm if norm > 0 else vec
63+
64+
65+
def chunk_fractal(text: str) -> tuple[list[str], list[str], str]:
66+
"""Fractal chunking: sentences (L0) -> paragraphs (L1) -> full doc (L2).
67+
68+
Sentence splitting uses regex boundaries that handle decimals, percentages,
69+
abbreviations, and statistical notation common in scientific text.
70+
Fragments shorter than 30 chars are merged into the preceding sentence.
71+
"""
72+
import re
73+
74+
# Split on period/!/? followed by whitespace and an uppercase letter.
75+
# This avoids splitting on decimals (0.79), abbreviations (e.g.), and
76+
# statistical notation (P = 0.001).
77+
raw = re.split(r'(?<=[.!?])\s+(?=[A-Z])', text)
78+
79+
# Clean and filter
80+
sentences = [s.strip() for s in raw if s.strip()]
81+
82+
# Merge short fragments (< 30 chars) into previous sentence
83+
if len(sentences) > 1:
84+
merged = [sentences[0]]
85+
for s in sentences[1:]:
86+
if len(s) < 30 and merged:
87+
merged[-1] = merged[-1] + ' ' + s
88+
else:
89+
merged.append(s)
90+
sentences = merged
91+
92+
# Ensure non-empty
93+
if not sentences:
94+
sentences = [text] if text.strip() else []
95+
96+
paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()] or [text]
97+
98+
# Virtual paragraph chunking for single-paragraph docs
99+
# When \n\n split produces only 1 paragraph but the doc has 4+ sentences,
100+
# group sentences into virtual paragraphs of ~3 sentences each.
101+
# This prevents L1 from being degenerate (identical to L2).
102+
if len(paragraphs) == 1 and len(sentences) >= 4:
103+
chunk_size = 3
104+
virtual_paras = []
105+
for i in range(0, len(sentences), chunk_size):
106+
group = sentences[i:i + chunk_size]
107+
virtual_paras.append(' '.join(group))
108+
paragraphs = virtual_paras
109+
110+
return sentences, paragraphs, text

0 commit comments

Comments
 (0)