spectrag 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 (49) hide show
  1. spectrag/__init__.py +22 -0
  2. spectrag/_version.py +7 -0
  3. spectrag/cli.py +115 -0
  4. spectrag/config.py +96 -0
  5. spectrag/errors.py +40 -0
  6. spectrag/ingestion.py +141 -0
  7. spectrag/log.py +30 -0
  8. spectrag/retrieval.py +154 -0
  9. spectrag/utils/__init__.py +1 -0
  10. spectrag/utils/bm25.py +53 -0
  11. spectrag/utils/chunker.py +112 -0
  12. spectrag/utils/clusters.py +63 -0
  13. spectrag/utils/diffusion.py +104 -0
  14. spectrag/utils/edge_list.py +18 -0
  15. spectrag/utils/edges.py +38 -0
  16. spectrag/utils/edges_content.py +110 -0
  17. spectrag/utils/edges_structure.py +86 -0
  18. spectrag/utils/embedder.py +89 -0
  19. spectrag/utils/entities.py +72 -0
  20. spectrag/utils/evaluation.py +111 -0
  21. spectrag/utils/expansion.py +68 -0
  22. spectrag/utils/graph.py +90 -0
  23. spectrag/utils/graph_build.py +27 -0
  24. spectrag/utils/graph_template.py +105 -0
  25. spectrag/utils/graph_view.py +126 -0
  26. spectrag/utils/llm.py +61 -0
  27. spectrag/utils/loader.py +59 -0
  28. spectrag/utils/metrics.py +61 -0
  29. spectrag/utils/prompt.py +62 -0
  30. spectrag/utils/questions.py +40 -0
  31. spectrag/utils/references.py +43 -0
  32. spectrag/utils/sections.py +98 -0
  33. spectrag/utils/seeding.py +106 -0
  34. spectrag/utils/selection.py +93 -0
  35. spectrag/utils/selectors.py +103 -0
  36. spectrag/utils/semantic_split.py +54 -0
  37. spectrag/utils/spectral.py +72 -0
  38. spectrag/utils/store.py +94 -0
  39. spectrag/utils/tokens.py +64 -0
  40. spectrag/utils/tuning.py +78 -0
  41. spectrag/utils/units.py +106 -0
  42. spectrag/utils/vector_index.py +122 -0
  43. spectrag/validation.py +84 -0
  44. spectrag-0.1.0.dist-info/METADATA +550 -0
  45. spectrag-0.1.0.dist-info/RECORD +49 -0
  46. spectrag-0.1.0.dist-info/WHEEL +5 -0
  47. spectrag-0.1.0.dist-info/entry_points.txt +2 -0
  48. spectrag-0.1.0.dist-info/licenses/LICENSE +21 -0
  49. spectrag-0.1.0.dist-info/top_level.txt +1 -0
spectrag/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """SpectRAG: spectral graph-augmented retrieval for RAG (arXiv:2610.36991).
2
+
3
+ SpectRAGIngestion builds the index (offline, once per document set).
4
+ SpectRAGRetrieval finds the context for a question and can answer it (online, per question).
5
+
6
+ from spectrag import SpectRAGIngestion, SpectRAGRetrieval
7
+ SpectRAGIngestion("my_index").ingest("docs/")
8
+ print(SpectRAGRetrieval("my_index").search("my question")["context"])
9
+ """
10
+
11
+ from spectrag._version import INDEX_FORMAT, __version__
12
+ from spectrag.config import DEFAULTS, make_settings
13
+ from spectrag.errors import (DocumentError, EmbedderError, IndexError_, NoDocumentsError,
14
+ SettingsError, SpectRAGError, UnknownSettingError)
15
+ from spectrag.ingestion import SpectRAGIngestion
16
+ from spectrag.log import enable_logging
17
+ from spectrag.retrieval import SpectRAGRetrieval
18
+
19
+ __all__ = ["SpectRAGIngestion", "SpectRAGRetrieval", "make_settings", "DEFAULTS",
20
+ "enable_logging", "SpectRAGError", "SettingsError", "UnknownSettingError",
21
+ "IndexError_", "EmbedderError", "DocumentError", "NoDocumentsError",
22
+ "__version__", "INDEX_FORMAT"]
spectrag/_version.py ADDED
@@ -0,0 +1,7 @@
1
+ """The package version (read by pyproject.toml) and the index format version."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ # Bump INDEX_FORMAT when the files of an index change in a way older code cannot read.
6
+ # 1 = JSON edge lists (before the Phase 1 audit), 2 = binary .npy/.npz edges and signals.
7
+ INDEX_FORMAT = 2
spectrag/cli.py ADDED
@@ -0,0 +1,115 @@
1
+ """The `spectrag` command.
2
+
3
+ spectrag ingest docs/ --index my_index [--set chunk_tokens=256 --set embedder=ollama]
4
+ spectrag search "my question" --index my_index [--budget 4000] [--json]
5
+ spectrag answer "my question" --index my_index --model gemma3:4b
6
+ spectrag info --index my_index
7
+ spectrag graph --index my_index --out graph.html [--question "my question"]
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+
14
+ from spectrag._version import __version__
15
+ from spectrag.errors import SpectRAGError
16
+ from spectrag.log import enable_logging
17
+
18
+
19
+ def parse_sets(pairs):
20
+ """["alpha=0.5", "selector=mmr"] -> {"alpha": 0.5, "selector": "mmr"} (values read as JSON)."""
21
+ settings = {}
22
+ for pair in pairs or []:
23
+ if "=" not in pair:
24
+ raise SystemExit("--set needs key=value, got %r" % pair)
25
+ key, value = pair.split("=", 1)
26
+ try:
27
+ settings[key] = json.loads(value)
28
+ except ValueError:
29
+ settings[key] = value # plain text, e.g. selector=mmr
30
+ return settings
31
+
32
+
33
+ def command_ingest(args):
34
+ from spectrag import SpectRAGIngestion
35
+ info = SpectRAGIngestion(args.index, parse_sets(args.set)).ingest(args.path)
36
+ print(json.dumps(info, indent=1))
37
+
38
+
39
+ def command_search(args):
40
+ from spectrag import SpectRAGRetrieval
41
+ result = SpectRAGRetrieval(args.index, parse_sets(args.set)).search(args.question, args.budget)
42
+ if args.json:
43
+ print(json.dumps({key: result[key] for key in ("question", "tokens", "chunks", "context")}, indent=1))
44
+ return
45
+ for chunk in result["chunks"]:
46
+ print("%2d. %-30s score %.3f %d tokens (%s)" % (chunk["rank"], chunk["doc_id"], chunk["score"],
47
+ chunk["tokens"], chunk["reason"]))
48
+ print("\n" + result["context"])
49
+
50
+
51
+ def command_answer(args):
52
+ from spectrag import SpectRAGRetrieval
53
+ from spectrag.utils.llm import ollama_llm
54
+ search = SpectRAGRetrieval(args.index, parse_sets(args.set))
55
+ result = search.answer(args.question, ollama_llm(args.model), args.budget)
56
+ print(result["answer"])
57
+ print("\nSources: " + ", ".join(sorted({c["doc_id"] for c in result["chunks"]})))
58
+
59
+
60
+ def command_info(args):
61
+ from spectrag.utils.store import check_index_folder, load_index
62
+ from pathlib import Path
63
+ check_index_folder(Path(args.index))
64
+ print(json.dumps(load_index(args.index, only=("info",))["info"], indent=1))
65
+
66
+
67
+ def command_graph(args):
68
+ from spectrag.utils.graph_view import save_graph_view
69
+ path = save_graph_view(args.index, args.out, args.question, parse_sets(args.set), args.max_nodes)
70
+ print("Graph view written to %s (open it in a browser)" % path)
71
+
72
+
73
+ def build_parser():
74
+ parser = argparse.ArgumentParser(prog="spectrag", description="SpectRAG: spectral graph RAG")
75
+ parser.add_argument("--version", action="version", version="spectrag " + __version__)
76
+ parser.add_argument("-v", "--verbose", action="store_true", help="print progress messages")
77
+ commands = parser.add_subparsers(dest="command", required=True)
78
+ ingest = commands.add_parser("ingest", help="build an index from a file or folder")
79
+ ingest.add_argument("path")
80
+ for name, helptext in [("search", "find the context for a question"), ("answer", "answer with an LLM")]:
81
+ sub = commands.add_parser(name, help=helptext)
82
+ sub.add_argument("question")
83
+ sub.add_argument("--budget", type=int, default=None, help="token budget (default 12000)")
84
+ if name == "search":
85
+ sub.add_argument("--json", action="store_true", help="print JSON")
86
+ else:
87
+ sub.add_argument("--model", default="gemma3:4b", help="Ollama model")
88
+ info = commands.add_parser("info", help="show what an index contains")
89
+ graph = commands.add_parser("graph", help="write an interactive graph view (HTML)")
90
+ graph.add_argument("--out", default="graph.html", help="HTML file to write")
91
+ graph.add_argument("--question", default=None, help="show this question's retrieval path")
92
+ graph.add_argument("--max-nodes", type=int, default=1500, dest="max_nodes")
93
+ for sub in (ingest, commands.choices["search"], commands.choices["answer"], info, graph):
94
+ sub.add_argument("--index", required=True, help="index folder")
95
+ for sub in (ingest, commands.choices["search"], commands.choices["answer"], graph):
96
+ sub.add_argument("--set", action="append", metavar="KEY=VALUE", help="change a setting")
97
+ return parser
98
+
99
+
100
+ def main(argv=None):
101
+ args = build_parser().parse_args(argv)
102
+ if args.verbose:
103
+ enable_logging()
104
+ handlers = {"ingest": command_ingest, "search": command_search, "answer": command_answer,
105
+ "info": command_info, "graph": command_graph}
106
+ try:
107
+ handlers[args.command](args)
108
+ except SpectRAGError as error:
109
+ print("spectrag: error: %s" % error, file=sys.stderr)
110
+ return 1
111
+ return 0
112
+
113
+
114
+ if __name__ == "__main__":
115
+ sys.exit(main())
spectrag/config.py ADDED
@@ -0,0 +1,96 @@
1
+ """Default settings for SpectRAG.
2
+
3
+ Values marked "paper" come from the SpectRAG paper (Tables 1-3).
4
+ Values marked "assumed" are not given in the paper, so we picked a sensible value.
5
+ """
6
+
7
+ import copy
8
+ import difflib
9
+
10
+ from spectrag.errors import UnknownSettingError
11
+ from spectrag.validation import validate_settings
12
+
13
+ # Edge-signal weights from the paper's "signal configuration 1" (Table 9).
14
+ EDGE_WEIGHTS_CONFIG_1 = {
15
+ "semantic": 0.300,
16
+ "section": 0.250,
17
+ "entity": 0.200,
18
+ "adjacency": 0.084,
19
+ "utility": 0.083,
20
+ "citation": 0.083,
21
+ }
22
+
23
+ # Edge-signal weights from the paper's "signal configuration 2" (Table 10).
24
+ EDGE_WEIGHTS_CONFIG_2 = {
25
+ "semantic": 0.150,
26
+ "section": 0.200,
27
+ "entity": 0.250,
28
+ "adjacency": 0.100,
29
+ "utility": 0.280,
30
+ "citation": 0.020,
31
+ }
32
+
33
+ DEFAULTS = {
34
+ # --- Chunking (paper) ---
35
+ "chunk_tokens": 1200,
36
+ "overlap_tokens": 100,
37
+ "min_chunk_tokens": 32,
38
+ "semantic_percentile": 90,
39
+ "semantic_embedder": None, # embedder for semantic-chunking breaks; None = "embedder" (paper)
40
+ # --- Embedding ---
41
+ "embedder": "hash", # "hash" (offline), "ollama" (nomic-embed-text), or your own function
42
+ "embed_dims": 768, # paper: nomic-embed-text is 768-d (used by the hash embedder)
43
+ "ollama_model": "nomic-embed-text",
44
+ "ollama_url": "http://localhost:11434",
45
+ # --- Vector search (Section 6.2) ---
46
+ "ann_backend": "auto", # "exact", "faiss", "hnsw", or "auto" (exact up to 20k chunks)
47
+ "hnsw_m": 32, # HNSW links per node
48
+ "hnsw_ef_construction": 200,
49
+ "hnsw_ef_search": 128,
50
+ "dense_top_n": 1000, # with an approximate backend: dense scores for the top n only
51
+ # --- Graph (weights: paper, the rest: assumed) ---
52
+ "edge_weights": EDGE_WEIGHTS_CONFIG_1,
53
+ "semantic_k": 10, # assumed
54
+ "edge_threshold": 0.05, # assumed
55
+ "graph_k": 10, # assumed: each chunk keeps at most its 10 strongest edges
56
+ # --- Spectral (paper) ---
57
+ "spectral_dims": 32,
58
+ "n_clusters": None, # assumed: None means about sqrt(n / 2) clusters
59
+ # --- Retrieval (paper) ---
60
+ "seed_count": 20,
61
+ "hops": 2,
62
+ "max_candidates": 200,
63
+ "token_budget": 12000,
64
+ # --- Retrieval (assumed) ---
65
+ "rrf_k": 60, # the usual RRF constant
66
+ "bm25_k1": 1.5, # standard BM25 values (Robertson and Zaragoza)
67
+ "bm25_b": 0.75,
68
+ "bm25_weight": 0.5, # hybrid relevance = 0.5 * dense + 0.5 * BM25
69
+ "alpha": 0.3,
70
+ "ppr_beta": 0.15,
71
+ "ppr_steps": 30,
72
+ "diffusion_t": 1.0, # heat-kernel scale t for spectral closeness (Eq. 17)
73
+ "proximity_mode": "hybrid", # "hybrid" (paper), "ppr", "spectral", "heat", "none"
74
+ "selector": "spectrag", # "spectrag", "mmr", "protected_mmr", "anchor_expand_ce", "topk"
75
+ "mmr_lambda": 0.7, # MMR: weight of relevance vs. novelty
76
+ "core_size": 3,
77
+ "precision_floor": 0.4,
78
+ "select_weights": {"a": 1.0, "b": 0.3, "c": 0.2, "d": 0.1, "lambda": 0.5},
79
+ "length_power": 0.5,
80
+ }
81
+
82
+
83
+ def make_settings(overrides=None):
84
+ """Return a fresh copy of DEFAULTS with the user's overrides applied and checked.
85
+
86
+ Raises UnknownSettingError (a KeyError) for a misspelled name, and SettingsError
87
+ (a ValueError) for a value of the wrong type or out of range.
88
+ """
89
+ settings = copy.deepcopy(DEFAULTS)
90
+ for key, value in (overrides or {}).items():
91
+ if key not in settings:
92
+ close = difflib.get_close_matches(key, DEFAULTS, n=1)
93
+ hint = " (did you mean %r?)" % close[0] if close else ""
94
+ raise UnknownSettingError("Unknown setting: %r%s" % (key, hint))
95
+ settings[key] = copy.deepcopy(value)
96
+ return validate_settings(settings)
spectrag/errors.py ADDED
@@ -0,0 +1,40 @@
1
+ """SpectRAG's exceptions. Each one also inherits the matching built-in exception, so code that
2
+ catches ValueError, KeyError, FileNotFoundError or ConnectionError keeps working.
3
+
4
+ try:
5
+ search = SpectRAGRetrieval("my_index")
6
+ except spectrag.SpectRAGError as error:
7
+ print(error)
8
+ """
9
+
10
+
11
+ class SpectRAGError(Exception):
12
+ """Base class for every error raised on purpose by SpectRAG."""
13
+
14
+
15
+ class SettingsError(SpectRAGError, ValueError):
16
+ """A setting has the wrong type, is out of range, or is not one of the allowed choices."""
17
+
18
+
19
+ class UnknownSettingError(SettingsError, KeyError):
20
+ """A setting name that SpectRAG does not know (often a typo)."""
21
+
22
+ def __str__(self): # KeyError would otherwise print the message with quotes around it
23
+ return str(self.args[0]) if self.args else ""
24
+
25
+
26
+ class IndexError_(SpectRAGError, FileNotFoundError):
27
+ """The index folder is missing, incomplete, or was made by an incompatible version.
28
+ (The trailing underscore avoids hiding Python's built-in IndexError.)"""
29
+
30
+
31
+ class EmbedderError(SpectRAGError, ConnectionError):
32
+ """The embedder cannot be reached, or its vectors do not match the index."""
33
+
34
+
35
+ class DocumentError(SpectRAGError, ValueError):
36
+ """Documents given to ingest_documents() are not in the expected form."""
37
+
38
+
39
+ class NoDocumentsError(DocumentError):
40
+ """There was no document with text to ingest."""
spectrag/ingestion.py ADDED
@@ -0,0 +1,141 @@
1
+ """SpectRAGIngestion: the offline pipeline (paper Table 1). Run it once per document set.
2
+
3
+ ingest = SpectRAGIngestion("my_index")
4
+ info = ingest.ingest("examples/docs")
5
+ """
6
+
7
+ import shutil
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+
13
+ from spectrag._version import INDEX_FORMAT, __version__
14
+ from spectrag.config import make_settings
15
+ from spectrag.errors import DocumentError, IndexError_, NoDocumentsError
16
+ from spectrag.log import log_step, logger
17
+ from spectrag.utils.chunker import chunk_documents
18
+ from spectrag.utils.edges import build_edge_signals, count_edges
19
+ from spectrag.utils.embedder import make_embedder
20
+ from spectrag.utils.entities import add_entities
21
+ from spectrag.utils.graph_build import build_graph, build_spectral
22
+ from spectrag.utils.loader import load_docs
23
+ from spectrag.utils.questions import add_utility_questions
24
+ from spectrag.utils.references import add_references
25
+ from spectrag.utils.store import edges_to_array, save_index
26
+ from spectrag.utils.vector_index import build_vector_index, save_vector_index
27
+
28
+
29
+ class SpectRAGIngestion:
30
+ """Load -> chunk -> embed -> edge signals -> weighted graph -> spectral -> save."""
31
+
32
+ def __init__(self, index_dir, settings=None, entity_fn=None, question_fn=None):
33
+ self.index_dir = index_dir
34
+ self.settings = make_settings(settings)
35
+ self.embed = make_embedder(self.settings)
36
+ # Semantic chunking can use a faster embedder for sentence distances (None = the same).
37
+ split_choice = self.settings["semantic_embedder"]
38
+ self.split_embed = self.embed if split_choice is None else \
39
+ make_embedder(dict(self.settings, embedder=split_choice))
40
+ self.entity_fn = entity_fn # optional: text -> list of entities
41
+ self.question_fn = question_fn # optional: text -> list of utility questions
42
+
43
+ def make_chunks(self, docs):
44
+ """Split documents into chunks with entities, references and utility questions."""
45
+ chunks = chunk_documents(docs, self.settings, embed_fn=self.split_embed)
46
+ add_entities(chunks, self.entity_fn)
47
+ add_references(chunks)
48
+ add_utility_questions(chunks, self.question_fn)
49
+ return chunks
50
+
51
+ def make_graph(self, signals):
52
+ """Fuse the signals (Eq. 10), drop weak edges, keep each chunk's strongest graph_k."""
53
+ return build_graph(signals, self.settings)
54
+
55
+ def make_spectral(self, edges, node_count):
56
+ """Laplacian (Eq. 11) -> spectral coordinates (Eq. 12-13) -> clusters."""
57
+ return build_spectral(edges, node_count, self.settings)
58
+
59
+ def ingest(self, path):
60
+ """Ingest a file or a folder (.txt, .md, .pdf), save the index, return a summary."""
61
+ docs = load_docs(path)
62
+ if not docs:
63
+ raise NoDocumentsError("No .txt, .md or .pdf file with text found in " + str(path))
64
+ return self.ingest_documents(docs)
65
+
66
+ def ingest_documents(self, docs):
67
+ """Ingest documents already in memory: [{"doc_id": ..., "text": ...}, ...]."""
68
+ check_documents(docs)
69
+ start = begin = time.time()
70
+ chunks = self.make_chunks(docs)
71
+ start = log_step("chunking", start, documents=len(docs), chunks=len(chunks))
72
+ embeddings = self.embed([chunk["text"] for chunk in chunks], kind="document")
73
+ vector_index = build_vector_index(embeddings, self.settings) # exact, FAISS or HNSW
74
+ start = log_step("embedding", start, backend=vector_index["backend"])
75
+ signals, question_vectors, owners = build_edge_signals(
76
+ chunks, embeddings, self.embed, self.settings, vector_index)
77
+ start = log_step("edge signals", start, **count_edges(signals))
78
+ edges, weights = self.make_graph(signals)
79
+ coords, eigenvalues, clusters = self.make_spectral(edges, len(chunks))
80
+ start = log_step("graph and spectral", start, edges=len(edges), dims=coords.shape[1])
81
+ for chunk, cluster in zip(chunks, clusters):
82
+ chunk["cluster"] = int(cluster)
83
+ info = {"documents": len({c["doc_id"] for c in chunks}), "chunks": len(chunks),
84
+ "signal_edges": count_edges(signals), "graph_edges": len(edges),
85
+ "weights_used": {name: round(w, 4) for name, w in weights.items()},
86
+ "spectral_dims": coords.shape[1], "ann_backend": vector_index["backend"],
87
+ "clusters": len(set(clusters.tolist())),
88
+ "format_version": INDEX_FORMAT, "spectrag_version": __version__,
89
+ "seconds": round(time.time() - begin, 2)}
90
+ partial = prepare_folder(self.index_dir)
91
+ save_index(partial, {
92
+ "chunks": chunks, "embeddings": embeddings, "edges": edges_to_array(edges),
93
+ "signals": {name: edges_to_array(found) for name, found in signals.items()},
94
+ "spectral": coords, "eigenvalues": eigenvalues,
95
+ "question_vectors": question_vectors,
96
+ "question_owners": np.asarray(owners, dtype=np.int64),
97
+ "settings": self.settings, "info": info,
98
+ })
99
+ save_vector_index(vector_index, partial)
100
+ replace_folder(partial, self.index_dir)
101
+ log_step("saved index", start, folder=self.index_dir)
102
+ return info
103
+
104
+
105
+ def check_documents(docs):
106
+ """Every document needs a text and a unique doc_id."""
107
+ if not docs:
108
+ raise NoDocumentsError("No documents with text to ingest")
109
+ seen = set()
110
+ for number, doc in enumerate(docs):
111
+ if not isinstance(doc, dict) or not isinstance(doc.get("text"), str) or "doc_id" not in doc:
112
+ raise DocumentError("Document %d must be a dict with 'doc_id' and 'text' (a string)" % number)
113
+ if doc["doc_id"] in seen:
114
+ raise DocumentError("Two documents have the same doc_id: %r" % (doc["doc_id"],))
115
+ seen.add(doc["doc_id"])
116
+ if not any(doc["text"].strip() for doc in docs):
117
+ raise NoDocumentsError("Every document is empty")
118
+
119
+
120
+ def is_index(folder):
121
+ return (folder / "info.json").is_file() and (folder / "chunks.json").is_file()
122
+
123
+
124
+ def prepare_folder(index_dir):
125
+ """A fresh '<index_dir>.partial' folder to write into, so a crash never leaves half an index."""
126
+ target = Path(index_dir)
127
+ if target.exists() and (not target.is_dir() or (any(target.iterdir()) and not is_index(target))):
128
+ raise IndexError_("%s already exists and is not a SpectRAG index; choose another folder" % target)
129
+ partial = target.with_name(target.name + ".partial")
130
+ if partial.exists():
131
+ shutil.rmtree(partial)
132
+ return partial
133
+
134
+
135
+ def replace_folder(partial, index_dir):
136
+ """Move the finished index into place (replacing an older SpectRAG index)."""
137
+ target = Path(index_dir)
138
+ if target.exists():
139
+ logger.info("replacing the existing index in %s", target)
140
+ shutil.rmtree(target)
141
+ partial.rename(target)
spectrag/log.py ADDED
@@ -0,0 +1,30 @@
1
+ """Logging. SpectRAG is silent by default (like any library); switch messages on with:
2
+
3
+ import spectrag
4
+ spectrag.enable_logging() # INFO: one line per pipeline step, with its time
5
+ spectrag.enable_logging("DEBUG") # DEBUG: also one line per query
6
+ """
7
+
8
+ import logging
9
+ import time
10
+
11
+ logger = logging.getLogger("spectrag")
12
+ logger.addHandler(logging.NullHandler())
13
+
14
+
15
+ def enable_logging(level="INFO"):
16
+ """Print SpectRAG's log messages to the terminal (stderr)."""
17
+ if not any(getattr(h, "spectrag", False) for h in logger.handlers):
18
+ handler = logging.StreamHandler()
19
+ handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
20
+ handler.spectrag = True
21
+ logger.addHandler(handler)
22
+ logger.setLevel(level)
23
+ return logger
24
+
25
+
26
+ def log_step(name, start, **details):
27
+ """Log one finished step: 'chunking: 1.23 s (chunks=8)'. Returns the time now."""
28
+ extra = ", ".join("%s=%s" % item for item in details.items())
29
+ logger.info("%s: %.2f s%s", name, time.time() - start, " (%s)" % extra if extra else "")
30
+ return time.time()
spectrag/retrieval.py ADDED
@@ -0,0 +1,154 @@
1
+ """SpectRAGRetrieval: the online pipeline (paper Table 2, Algorithm 1). Run it per question.
2
+
3
+ search = SpectRAGRetrieval("my_index")
4
+ result = search.search("Which company did the co-founder of Aurora Labs join?")
5
+ print(result["context"])
6
+ """
7
+
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from spectrag._version import INDEX_FORMAT
12
+ from spectrag.config import make_settings
13
+ from spectrag.errors import EmbedderError, IndexError_
14
+ from spectrag.log import logger
15
+ from spectrag.utils.bm25 import build_bm25
16
+ from spectrag.utils.diffusion import score_candidates
17
+ from spectrag.utils.embedder import make_embedder
18
+ from spectrag.utils.graph import degrees, neighbors
19
+ from spectrag.utils.graph_build import build_graph, build_spectral
20
+ from spectrag.utils.prompt import build_prompt, format_context
21
+ from spectrag.utils.seeding import find_seeds
22
+ from spectrag.utils.selectors import select
23
+ from spectrag.utils.store import array_to_edges, check_index_folder, load_index
24
+ from spectrag.utils.vector_index import load_vector_index
25
+
26
+ # These change the graph itself, so they need rebuild_graph() instead of a per-call override.
27
+ GRAPH_SETTINGS = ("edge_weights", "edge_threshold", "graph_k", "spectral_dims", "n_clusters")
28
+
29
+
30
+ class SpectRAGRetrieval:
31
+ """Seeds -> graph expansion -> proximity re-scoring -> token-budget selection -> context."""
32
+
33
+ def __init__(self, index_dir, settings=None, embed_fn=None, rerank_fn=None):
34
+ """Load an index made by SpectRAGIngestion.
35
+
36
+ settings: settings to change, e.g. {"token_budget": 4000, "selector": "mmr"}.
37
+ Graph settings (edge_threshold, graph_k, ...) rebuild the graph from the
38
+ saved signals, without embedding anything again.
39
+ embed_fn: pass the same function again if the index was built with your own embedder.
40
+ rerank_fn: optional (question, texts) -> scores, e.g. a cross-encoder, used by the
41
+ "anchor_expand_ce" selector.
42
+ """
43
+ self.index_dir = Path(index_dir)
44
+ check_index_folder(self.index_dir)
45
+ self.index = load_index(index_dir, skip=("signals",)) # raw signals are loaded on demand
46
+ made_with = self.index["info"].get("format_version", 1)
47
+ if made_with > INDEX_FORMAT:
48
+ raise IndexError_("This index has format %d; this SpectRAG version reads up to %d. "
49
+ "Upgrade spectrag." % (made_with, INDEX_FORMAT))
50
+ self.index["edges"] = array_to_edges(self.index["edges"])
51
+ self.index["question_owners"] = [int(owner) for owner in self.index["question_owners"]]
52
+ built_with = make_settings(self.index["settings"])
53
+ self.settings = make_settings(dict(built_with, **(settings or {})))
54
+ if embed_fn is not None:
55
+ self.settings["embedder"] = embed_fn
56
+ elif str(self.settings["embedder"]).startswith("<function"):
57
+ raise ValueError("This index was built with your own embed function. "
58
+ "Pass it again: SpectRAGRetrieval(index_dir, embed_fn=...)")
59
+ self.embed = make_embedder(self.settings)
60
+ self.rerank_fn = rerank_fn
61
+ self.bm25 = build_bm25([chunk["text"] for chunk in self.index["chunks"]])
62
+ self.vector_index = load_vector_index(self.index_dir, self.index["embeddings"], self.settings)
63
+ if any(self.settings[key] != built_with[key] for key in GRAPH_SETTINGS):
64
+ self.rebuild_graph()
65
+ self.refresh_graph()
66
+
67
+ def refresh_graph(self):
68
+ """Neighbour table (CSR) and degrees for the current edges."""
69
+ count = len(self.index["chunks"])
70
+ self.table = neighbors(self.index["edges"], count)
71
+ self.index["degree"] = degrees(self.index["edges"], count)
72
+
73
+ def rebuild_graph(self, overrides=None):
74
+ """Change graph settings without re-embedding: fuse the saved signals again (Eq. 10)
75
+ and recompute spectral coordinates and clusters (Eq. 11-13). In memory only."""
76
+ self.settings = make_settings(dict(self.settings, **(overrides or {})))
77
+ saved = load_index(self.index_dir, only=("signals",))["signals"]
78
+ signals = {name: array_to_edges(edges) for name, edges in saved.items()}
79
+ edges, _ = build_graph(signals, self.settings)
80
+ coords, eigenvalues, clusters = build_spectral(edges, len(self.index["chunks"]), self.settings)
81
+ self.index.update({"edges": edges, "spectral": coords, "eigenvalues": eigenvalues})
82
+ for chunk, cluster in zip(self.index["chunks"], clusters):
83
+ chunk["cluster"] = int(cluster)
84
+ self.refresh_graph()
85
+
86
+ def settings_for(self, overrides):
87
+ """The settings for one call: the current settings plus per-call overrides."""
88
+ if not overrides:
89
+ return self.settings
90
+ changed = [key for key in GRAPH_SETTINGS
91
+ if key in overrides and overrides[key] != self.settings[key]]
92
+ if changed:
93
+ raise ValueError("Use rebuild_graph() to change graph settings: " + ", ".join(changed))
94
+ return make_settings(dict(self.settings, **overrides))
95
+
96
+ def retrieve(self, question, settings=None, query_vector=None):
97
+ """Stages 1-4. Returns (seed result, scored candidates, picks)."""
98
+ settings = self.settings_for(settings)
99
+ start = time.time()
100
+ if query_vector is None:
101
+ query_vector = self.embed([question], kind="query")[0]
102
+ if len(query_vector) != self.index["embeddings"].shape[1]:
103
+ raise EmbedderError("The query vector has %d dimensions but the index has %d: search "
104
+ "with the same embedder the index was built with."
105
+ % (len(query_vector), self.index["embeddings"].shape[1]))
106
+ seeds = find_seeds(question, query_vector, self.index, self.bm25, settings,
107
+ self.vector_index)
108
+ scored = score_candidates(seeds["seeds"], seeds["relevance"], self.index,
109
+ self.table, settings)
110
+ rerank = None
111
+ if settings["selector"] == "anchor_expand_ce" and self.rerank_fn is not None:
112
+ texts = [self.index["chunks"][c]["text"] for c in scored["candidates"]]
113
+ rerank = self.rerank_fn(question, texts)
114
+ picks = select(scored, self.index, settings, self.table, rerank)
115
+ logger.debug("query %r: %d seeds, %d candidates, %d picked in %.1f ms", question[:60],
116
+ len(seeds["seeds"]), len(scored["candidates"]), len(picks),
117
+ 1000 * (time.time() - start))
118
+ return seeds, scored, picks
119
+
120
+ def describe(self, k, reason, rank, scored):
121
+ """One selected chunk with all its scores (the 'ranked source metadata')."""
122
+ chunk = self.index["chunks"][scored["candidates"][k]]
123
+ info = {key: chunk[key] for key in ["id", "doc_id", "position", "section", "tokens", "text"]}
124
+ info.update({"rank": rank, "reason": reason, "hop": scored["hop"][k],
125
+ "score": float(scored["scores"][k]),
126
+ "relevance": float(scored["relevance"][k]),
127
+ "proximity": float(scored["proximity"][k])})
128
+ return info
129
+
130
+ def search(self, question, token_budget=None, settings=None, query_vector=None):
131
+ """Find the context for a question. Returns a dict with 'context', 'chunks', ...
132
+
133
+ settings: per-call overrides, e.g. {"selector": "mmr", "alpha": 0.5}.
134
+ """
135
+ overrides = dict(settings or {})
136
+ if token_budget is not None:
137
+ overrides["token_budget"] = token_budget
138
+ seeds, scored, picks = self.retrieve(question, overrides, query_vector)
139
+ chunks = [self.describe(k, reason, rank, scored)
140
+ for rank, (k, reason) in enumerate(picks, start=1)]
141
+ context = format_context(chunks)
142
+ return {"question": question, "context": context, "chunks": chunks,
143
+ "prompt": build_prompt(question, context),
144
+ "tokens": sum(chunk["tokens"] for chunk in chunks),
145
+ "seeds": seeds["seeds"], "candidates": scored["candidates"]}
146
+
147
+ def answer(self, question, llm_fn, token_budget=None, settings=None):
148
+ """search() + generation (Algorithm 1 line 13). llm_fn is any function: prompt -> text.
149
+
150
+ Returns the search result with an extra "answer" key; result["chunks"] are the sources.
151
+ """
152
+ result = self.search(question, token_budget, settings)
153
+ result["answer"] = llm_fn(result["prompt"]).strip()
154
+ return result
@@ -0,0 +1 @@
1
+ """Small helper functions used by SpectRAGIngestion and SpectRAGRetrieval."""
spectrag/utils/bm25.py ADDED
@@ -0,0 +1,53 @@
1
+ """BM25 keyword scoring (paper Table 2, "BM25 lexical retrieval"; Robertson and Zaragoza [11]).
2
+
3
+ score(chunk, query) = sum over query words of
4
+ idf(word) * count * (k1 + 1) / (count + k1 * (1 - b + b * length / average_length))
5
+ """
6
+
7
+ import math
8
+ import re
9
+ from collections import Counter
10
+
11
+ import numpy as np
12
+
13
+
14
+ def tokenize(text):
15
+ """Lowercase words: 'Helios Energy!' -> ['helios', 'energy']."""
16
+ return re.findall(r"\w+", text.lower())
17
+
18
+
19
+ def build_bm25(texts):
20
+ """Build the keyword index once: word -> (chunk numbers, counts) as numpy arrays."""
21
+ lists = {}
22
+ lengths = []
23
+ for chunk_id, text in enumerate(texts):
24
+ counts = Counter(tokenize(text))
25
+ lengths.append(sum(counts.values()))
26
+ for word, count in counts.items():
27
+ lists.setdefault(word, ([], []))
28
+ lists[word][0].append(chunk_id)
29
+ lists[word][1].append(count)
30
+ postings = {word: (np.array(ids), np.array(counts, dtype=float))
31
+ for word, (ids, counts) in lists.items()}
32
+ average = sum(lengths) / len(lengths) if lengths else 0.0
33
+ return {"postings": postings, "lengths": np.array(lengths, dtype=float),
34
+ "average_length": average or 1.0}
35
+
36
+
37
+ def idf(doc_freq, doc_count):
38
+ """Rare words count more. This form (as in Lucene) is never negative."""
39
+ return math.log(1 + (doc_count - doc_freq + 0.5) / (doc_freq + 0.5))
40
+
41
+
42
+ def bm25_scores(bm25, query, k1=1.5, b=0.75):
43
+ """BM25 score of every chunk for the query (0 for chunks that share no word)."""
44
+ lengths = bm25["lengths"]
45
+ scores = np.zeros(len(lengths))
46
+ length_norm = k1 * (1 - b + b * lengths / bm25["average_length"]) # one value per chunk
47
+ for word in set(tokenize(query)):
48
+ if word not in bm25["postings"]:
49
+ continue
50
+ ids, counts = bm25["postings"][word] # only chunks that contain the word
51
+ weight = idf(len(ids), len(lengths))
52
+ scores[ids] += weight * counts * (k1 + 1) / (counts + length_norm[ids])
53
+ return scores