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.
- spectrag/__init__.py +22 -0
- spectrag/_version.py +7 -0
- spectrag/cli.py +115 -0
- spectrag/config.py +96 -0
- spectrag/errors.py +40 -0
- spectrag/ingestion.py +141 -0
- spectrag/log.py +30 -0
- spectrag/retrieval.py +154 -0
- spectrag/utils/__init__.py +1 -0
- spectrag/utils/bm25.py +53 -0
- spectrag/utils/chunker.py +112 -0
- spectrag/utils/clusters.py +63 -0
- spectrag/utils/diffusion.py +104 -0
- spectrag/utils/edge_list.py +18 -0
- spectrag/utils/edges.py +38 -0
- spectrag/utils/edges_content.py +110 -0
- spectrag/utils/edges_structure.py +86 -0
- spectrag/utils/embedder.py +89 -0
- spectrag/utils/entities.py +72 -0
- spectrag/utils/evaluation.py +111 -0
- spectrag/utils/expansion.py +68 -0
- spectrag/utils/graph.py +90 -0
- spectrag/utils/graph_build.py +27 -0
- spectrag/utils/graph_template.py +105 -0
- spectrag/utils/graph_view.py +126 -0
- spectrag/utils/llm.py +61 -0
- spectrag/utils/loader.py +59 -0
- spectrag/utils/metrics.py +61 -0
- spectrag/utils/prompt.py +62 -0
- spectrag/utils/questions.py +40 -0
- spectrag/utils/references.py +43 -0
- spectrag/utils/sections.py +98 -0
- spectrag/utils/seeding.py +106 -0
- spectrag/utils/selection.py +93 -0
- spectrag/utils/selectors.py +103 -0
- spectrag/utils/semantic_split.py +54 -0
- spectrag/utils/spectral.py +72 -0
- spectrag/utils/store.py +94 -0
- spectrag/utils/tokens.py +64 -0
- spectrag/utils/tuning.py +78 -0
- spectrag/utils/units.py +106 -0
- spectrag/utils/vector_index.py +122 -0
- spectrag/validation.py +84 -0
- spectrag-0.1.0.dist-info/METADATA +550 -0
- spectrag-0.1.0.dist-info/RECORD +49 -0
- spectrag-0.1.0.dist-info/WHEEL +5 -0
- spectrag-0.1.0.dist-info/entry_points.txt +2 -0
- spectrag-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|