graphsearch-rag 0.2.1__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.
- graphsearch/__init__.py +3 -0
- graphsearch/chunking.py +46 -0
- graphsearch/config.py +54 -0
- graphsearch/db.py +139 -0
- graphsearch/embeddings.py +122 -0
- graphsearch/ingest.py +52 -0
- graphsearch/llm.py +121 -0
- graphsearch/main.py +49 -0
- graphsearch/rag.py +74 -0
- graphsearch/schema.py +114 -0
- graphsearch/vectorstore.py +84 -0
- graphsearch_rag-0.2.1.dist-info/METADATA +227 -0
- graphsearch_rag-0.2.1.dist-info/RECORD +17 -0
- graphsearch_rag-0.2.1.dist-info/WHEEL +5 -0
- graphsearch_rag-0.2.1.dist-info/entry_points.txt +3 -0
- graphsearch_rag-0.2.1.dist-info/licenses/LICENSE +21 -0
- graphsearch_rag-0.2.1.dist-info/top_level.txt +1 -0
graphsearch/__init__.py
ADDED
graphsearch/chunking.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Split documents into overlapping chunks suitable for embedding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def chunk_text(text: str, chunk_size: int = 800, overlap: int = 100) -> list[str]:
|
|
7
|
+
"""Split ``text`` into chunks of at most ``chunk_size`` characters.
|
|
8
|
+
|
|
9
|
+
Prefers paragraph boundaries, then falls back to a sliding window with
|
|
10
|
+
``overlap`` characters of context carried between adjacent chunks.
|
|
11
|
+
"""
|
|
12
|
+
if chunk_size <= 0:
|
|
13
|
+
raise ValueError("chunk_size must be positive")
|
|
14
|
+
if overlap >= chunk_size:
|
|
15
|
+
raise ValueError("overlap must be smaller than chunk_size")
|
|
16
|
+
|
|
17
|
+
text = text.strip()
|
|
18
|
+
if not text:
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
|
22
|
+
|
|
23
|
+
chunks: list[str] = []
|
|
24
|
+
current = ""
|
|
25
|
+
for para in paragraphs:
|
|
26
|
+
candidate = f"{current}\n\n{para}" if current else para
|
|
27
|
+
if len(candidate) <= chunk_size:
|
|
28
|
+
current = candidate
|
|
29
|
+
continue
|
|
30
|
+
if current:
|
|
31
|
+
chunks.append(current)
|
|
32
|
+
if len(para) <= chunk_size:
|
|
33
|
+
current = para
|
|
34
|
+
else:
|
|
35
|
+
# Paragraph alone exceeds chunk_size: sliding window.
|
|
36
|
+
step = chunk_size - overlap
|
|
37
|
+
for start in range(0, len(para), step):
|
|
38
|
+
piece = para[start : start + chunk_size]
|
|
39
|
+
if len(piece) < overlap and chunks:
|
|
40
|
+
# Tiny tail already covered by the previous window's overlap.
|
|
41
|
+
break
|
|
42
|
+
chunks.append(piece)
|
|
43
|
+
current = ""
|
|
44
|
+
if current:
|
|
45
|
+
chunks.append(current)
|
|
46
|
+
return chunks
|
graphsearch/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Application configuration, loaded from environment variables / .env file.
|
|
2
|
+
|
|
3
|
+
All GraphSearch-specific settings use the ``GRAPHSEARCH_`` prefix
|
|
4
|
+
(e.g. ``GRAPHSEARCH_LLM=openai``). Provider API keys use their
|
|
5
|
+
conventional names: ``OPENAI_API_KEY`` and ``ANTHROPIC_API_KEY``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Settings(BaseSettings):
|
|
17
|
+
model_config = SettingsConfigDict(
|
|
18
|
+
env_prefix="GRAPHSEARCH_", env_file=".env", extra="ignore"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Storage
|
|
22
|
+
database_path: str = "graphsearch.db"
|
|
23
|
+
|
|
24
|
+
# Chunking
|
|
25
|
+
chunk_size: int = 800
|
|
26
|
+
chunk_overlap: int = 100
|
|
27
|
+
|
|
28
|
+
# Retrieval
|
|
29
|
+
default_top_k: int = 4
|
|
30
|
+
|
|
31
|
+
# Backends: which implementation to use for each pipeline stage.
|
|
32
|
+
# embeddings: "hash" (offline, no key needed), "local" (sentence-transformers), or "openai"
|
|
33
|
+
# llm: "extractive" (offline), "openai", or "anthropic"
|
|
34
|
+
embeddings: str = "hash"
|
|
35
|
+
llm: str = "extractive"
|
|
36
|
+
|
|
37
|
+
# Model names (only used when the matching backend is selected)
|
|
38
|
+
local_embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
|
|
39
|
+
openai_embedding_model: str = "text-embedding-3-small"
|
|
40
|
+
openai_model: str = "gpt-4o-mini"
|
|
41
|
+
anthropic_model: str = "claude-sonnet-5"
|
|
42
|
+
|
|
43
|
+
# Provider keys (no prefix — conventional env var names)
|
|
44
|
+
openai_api_key: str = Field(default="", alias="OPENAI_API_KEY")
|
|
45
|
+
anthropic_api_key: str = Field(default="", alias="ANTHROPIC_API_KEY")
|
|
46
|
+
|
|
47
|
+
# Server
|
|
48
|
+
host: str = "0.0.0.0"
|
|
49
|
+
port: int = 8000
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@lru_cache
|
|
53
|
+
def get_settings() -> Settings:
|
|
54
|
+
return Settings()
|
graphsearch/db.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""SQLite persistence for documents, chunks, and their embeddings.
|
|
2
|
+
|
|
3
|
+
A single SQLite file stores everything, which keeps the default deployment
|
|
4
|
+
zero-dependency. Embeddings are stored as float32 blobs on the ``chunks``
|
|
5
|
+
table and searched in memory with numpy (see ``vectorstore.py``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
_SCHEMA = """
|
|
19
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
title TEXT,
|
|
22
|
+
source TEXT,
|
|
23
|
+
content TEXT NOT NULL,
|
|
24
|
+
created_at TEXT NOT NULL
|
|
25
|
+
);
|
|
26
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
29
|
+
idx INTEGER NOT NULL,
|
|
30
|
+
text TEXT NOT NULL,
|
|
31
|
+
embedding BLOB NOT NULL
|
|
32
|
+
);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_document ON chunks(document_id);
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class DocumentRecord:
|
|
39
|
+
id: str
|
|
40
|
+
title: str | None
|
|
41
|
+
source: str | None
|
|
42
|
+
content: str
|
|
43
|
+
created_at: str
|
|
44
|
+
chunk_count: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ChunkRecord:
|
|
49
|
+
id: str
|
|
50
|
+
document_id: str
|
|
51
|
+
idx: int
|
|
52
|
+
text: str
|
|
53
|
+
document_title: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Database:
|
|
57
|
+
"""Thread-safe wrapper around a SQLite connection."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, path: str) -> None:
|
|
60
|
+
self._conn = sqlite3.connect(path, check_same_thread=False)
|
|
61
|
+
self._conn.execute("PRAGMA foreign_keys = ON")
|
|
62
|
+
self._conn.executescript(_SCHEMA)
|
|
63
|
+
self._lock = threading.Lock()
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
self._conn.close()
|
|
67
|
+
|
|
68
|
+
# -- documents ---------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def insert_document(
|
|
71
|
+
self,
|
|
72
|
+
content: str,
|
|
73
|
+
title: str | None,
|
|
74
|
+
source: str | None,
|
|
75
|
+
chunks: list[str],
|
|
76
|
+
embeddings: np.ndarray,
|
|
77
|
+
) -> DocumentRecord:
|
|
78
|
+
if len(chunks) != len(embeddings):
|
|
79
|
+
raise ValueError("chunks and embeddings must have the same length")
|
|
80
|
+
doc_id = uuid.uuid4().hex
|
|
81
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
82
|
+
with self._lock, self._conn:
|
|
83
|
+
self._conn.execute(
|
|
84
|
+
"INSERT INTO documents (id, title, source, content, created_at)"
|
|
85
|
+
" VALUES (?, ?, ?, ?, ?)",
|
|
86
|
+
(doc_id, title, source, content, created_at),
|
|
87
|
+
)
|
|
88
|
+
self._conn.executemany(
|
|
89
|
+
"INSERT INTO chunks (id, document_id, idx, text, embedding)"
|
|
90
|
+
" VALUES (?, ?, ?, ?, ?)",
|
|
91
|
+
[
|
|
92
|
+
(
|
|
93
|
+
uuid.uuid4().hex,
|
|
94
|
+
doc_id,
|
|
95
|
+
i,
|
|
96
|
+
chunk,
|
|
97
|
+
np.asarray(emb, dtype=np.float32).tobytes(),
|
|
98
|
+
)
|
|
99
|
+
for i, (chunk, emb) in enumerate(zip(chunks, embeddings, strict=True))
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
return DocumentRecord(doc_id, title, source, content, created_at, len(chunks))
|
|
103
|
+
|
|
104
|
+
def get_document(self, doc_id: str) -> DocumentRecord | None:
|
|
105
|
+
row = self._conn.execute(
|
|
106
|
+
"SELECT d.id, d.title, d.source, d.content, d.created_at,"
|
|
107
|
+
" (SELECT COUNT(*) FROM chunks c WHERE c.document_id = d.id)"
|
|
108
|
+
" FROM documents d WHERE d.id = ?",
|
|
109
|
+
(doc_id,),
|
|
110
|
+
).fetchone()
|
|
111
|
+
return DocumentRecord(*row) if row else None
|
|
112
|
+
|
|
113
|
+
def list_documents(self, limit: int = 20, offset: int = 0) -> list[DocumentRecord]:
|
|
114
|
+
rows = self._conn.execute(
|
|
115
|
+
"SELECT d.id, d.title, d.source, d.content, d.created_at,"
|
|
116
|
+
" (SELECT COUNT(*) FROM chunks c WHERE c.document_id = d.id)"
|
|
117
|
+
" FROM documents d ORDER BY d.created_at DESC LIMIT ? OFFSET ?",
|
|
118
|
+
(limit, offset),
|
|
119
|
+
).fetchall()
|
|
120
|
+
return [DocumentRecord(*row) for row in rows]
|
|
121
|
+
|
|
122
|
+
def delete_document(self, doc_id: str) -> bool:
|
|
123
|
+
with self._lock, self._conn:
|
|
124
|
+
cursor = self._conn.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
|
|
125
|
+
return cursor.rowcount > 0
|
|
126
|
+
|
|
127
|
+
# -- chunks / embeddings -------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def all_embeddings(self) -> tuple[list[ChunkRecord], np.ndarray]:
|
|
130
|
+
"""Return every chunk with its embedding matrix (rows align with chunks)."""
|
|
131
|
+
rows = self._conn.execute(
|
|
132
|
+
"SELECT c.id, c.document_id, c.idx, c.text, d.title, c.embedding"
|
|
133
|
+
" FROM chunks c JOIN documents d ON d.id = c.document_id"
|
|
134
|
+
).fetchall()
|
|
135
|
+
if not rows:
|
|
136
|
+
return [], np.empty((0, 0), dtype=np.float32)
|
|
137
|
+
records = [ChunkRecord(r[0], r[1], r[2], r[3], r[4]) for r in rows]
|
|
138
|
+
matrix = np.vstack([np.frombuffer(r[5], dtype=np.float32) for r in rows])
|
|
139
|
+
return records, matrix
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Embedding backends.
|
|
2
|
+
|
|
3
|
+
Two implementations ship out of the box:
|
|
4
|
+
|
|
5
|
+
- ``HashEmbedder`` — deterministic character n-gram hashing. No API key or
|
|
6
|
+
network needed, so the demo, tests, and CI all run offline. Retrieval
|
|
7
|
+
quality is lexical rather than semantic, but it exercises the exact same
|
|
8
|
+
pipeline.
|
|
9
|
+
- ``LocalEmbedder`` — real semantic embeddings computed locally with
|
|
10
|
+
sentence-transformers. No API key, runs on CPU; the model (~80 MB for the
|
|
11
|
+
default all-MiniLM-L6-v2) is downloaded on first use.
|
|
12
|
+
- ``OpenAIEmbedder`` — semantic embeddings via the OpenAI API.
|
|
13
|
+
|
|
14
|
+
To add a new backend (e.g. Cohere, Voyage), subclass ``Embedder`` and
|
|
15
|
+
register it in ``create_embedder``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from .config import Settings
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Embedder(ABC):
|
|
29
|
+
"""Turns text into a fixed-size vector. Vectors must be L2-normalized."""
|
|
30
|
+
|
|
31
|
+
dimension: int
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def embed(self, texts: list[str]) -> np.ndarray:
|
|
35
|
+
"""Return an array of shape ``(len(texts), self.dimension)``."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HashEmbedder(Embedder):
|
|
39
|
+
"""Offline embedding via hashed character n-grams (a 'hashing trick' TF vector)."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, dimension: int = 512, ngram: int = 3) -> None:
|
|
42
|
+
self.dimension = dimension
|
|
43
|
+
self.ngram = ngram
|
|
44
|
+
|
|
45
|
+
def _embed_one(self, text: str) -> np.ndarray:
|
|
46
|
+
vec = np.zeros(self.dimension, dtype=np.float32)
|
|
47
|
+
normalized = " ".join(text.lower().split())
|
|
48
|
+
for i in range(max(len(normalized) - self.ngram + 1, 1)):
|
|
49
|
+
gram = normalized[i : i + self.ngram]
|
|
50
|
+
digest = hashlib.md5(gram.encode("utf-8")).digest()
|
|
51
|
+
bucket = int.from_bytes(digest[:4], "little") % self.dimension
|
|
52
|
+
vec[bucket] += 1.0
|
|
53
|
+
norm = np.linalg.norm(vec)
|
|
54
|
+
return vec / norm if norm > 0 else vec
|
|
55
|
+
|
|
56
|
+
def embed(self, texts: list[str]) -> np.ndarray:
|
|
57
|
+
if not texts:
|
|
58
|
+
return np.empty((0, self.dimension), dtype=np.float32)
|
|
59
|
+
return np.vstack([self._embed_one(t) for t in texts])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class LocalEmbedder(Embedder):
|
|
63
|
+
"""Semantic embeddings computed locally with sentence-transformers (no API key)."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, model: str = "sentence-transformers/all-MiniLM-L6-v2") -> None:
|
|
66
|
+
try:
|
|
67
|
+
from sentence_transformers import SentenceTransformer
|
|
68
|
+
except ImportError as exc: # pragma: no cover
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
"The 'sentence-transformers' package is required for "
|
|
71
|
+
"GRAPHSEARCH_EMBEDDINGS=local. "
|
|
72
|
+
"Install it with: pip install graphsearch-rag[local]"
|
|
73
|
+
) from exc
|
|
74
|
+
self._model = SentenceTransformer(model)
|
|
75
|
+
# Renamed in newer sentence-transformers releases; support both.
|
|
76
|
+
get_dim = getattr(
|
|
77
|
+
self._model, "get_embedding_dimension", None
|
|
78
|
+
) or self._model.get_sentence_embedding_dimension
|
|
79
|
+
self.dimension = get_dim()
|
|
80
|
+
|
|
81
|
+
def embed(self, texts: list[str]) -> np.ndarray:
|
|
82
|
+
if not texts:
|
|
83
|
+
return np.empty((0, self.dimension), dtype=np.float32)
|
|
84
|
+
matrix = self._model.encode(texts, normalize_embeddings=True, show_progress_bar=False)
|
|
85
|
+
return np.asarray(matrix, dtype=np.float32)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class OpenAIEmbedder(Embedder):
|
|
89
|
+
"""Semantic embeddings via the OpenAI embeddings API."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, api_key: str, model: str = "text-embedding-3-small") -> None:
|
|
92
|
+
try:
|
|
93
|
+
from openai import OpenAI
|
|
94
|
+
except ImportError as exc: # pragma: no cover
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
"The 'openai' package is required for GRAPHSEARCH_EMBEDDINGS=openai. "
|
|
97
|
+
"Install it with: pip install graphsearch-rag[openai]"
|
|
98
|
+
) from exc
|
|
99
|
+
if not api_key:
|
|
100
|
+
raise RuntimeError("OPENAI_API_KEY must be set for GRAPHSEARCH_EMBEDDINGS=openai")
|
|
101
|
+
self._client = OpenAI(api_key=api_key)
|
|
102
|
+
self._model = model
|
|
103
|
+
self.dimension = 1536 if "small" in model or "ada" in model else 3072
|
|
104
|
+
|
|
105
|
+
def embed(self, texts: list[str]) -> np.ndarray:
|
|
106
|
+
if not texts:
|
|
107
|
+
return np.empty((0, self.dimension), dtype=np.float32)
|
|
108
|
+
response = self._client.embeddings.create(model=self._model, input=texts)
|
|
109
|
+
matrix = np.asarray([item.embedding for item in response.data], dtype=np.float32)
|
|
110
|
+
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
|
|
111
|
+
norms[norms == 0] = 1.0
|
|
112
|
+
return matrix / norms
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def create_embedder(settings: Settings) -> Embedder:
|
|
116
|
+
if settings.embeddings == "hash":
|
|
117
|
+
return HashEmbedder()
|
|
118
|
+
if settings.embeddings == "local":
|
|
119
|
+
return LocalEmbedder(settings.local_embedding_model)
|
|
120
|
+
if settings.embeddings == "openai":
|
|
121
|
+
return OpenAIEmbedder(settings.openai_api_key, settings.openai_embedding_model)
|
|
122
|
+
raise ValueError(f"Unknown embeddings backend: {settings.embeddings!r}")
|
graphsearch/ingest.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""CLI for bulk-ingesting documents from a directory.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
graphsearch-ingest data/example_docs
|
|
5
|
+
python -m graphsearch.ingest data/example_docs
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .config import get_settings
|
|
15
|
+
from .rag import RagService
|
|
16
|
+
|
|
17
|
+
SUPPORTED_SUFFIXES = {".txt", ".md", ".markdown", ".rst"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv: list[str] | None = None) -> int:
|
|
21
|
+
parser = argparse.ArgumentParser(description="Ingest text/markdown files into GraphSearch")
|
|
22
|
+
parser.add_argument("path", type=Path, help="File or directory to ingest")
|
|
23
|
+
args = parser.parse_args(argv)
|
|
24
|
+
|
|
25
|
+
if not args.path.exists():
|
|
26
|
+
print(f"error: {args.path} does not exist", file=sys.stderr)
|
|
27
|
+
return 1
|
|
28
|
+
|
|
29
|
+
files = (
|
|
30
|
+
[args.path]
|
|
31
|
+
if args.path.is_file()
|
|
32
|
+
else sorted(
|
|
33
|
+
p for p in args.path.rglob("*") if p.suffix.lower() in SUPPORTED_SUFFIXES
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
if not files:
|
|
37
|
+
print(f"error: no ingestable files under {args.path}", file=sys.stderr)
|
|
38
|
+
return 1
|
|
39
|
+
|
|
40
|
+
service = RagService(get_settings())
|
|
41
|
+
try:
|
|
42
|
+
for path in files:
|
|
43
|
+
content = path.read_text(encoding="utf-8")
|
|
44
|
+
record = service.ingest(content, title=path.stem, source=str(path))
|
|
45
|
+
print(f"ingested {path} -> {record.id} ({record.chunk_count} chunks)")
|
|
46
|
+
finally:
|
|
47
|
+
service.close()
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
raise SystemExit(main())
|
graphsearch/llm.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""LLM backends for answer generation.
|
|
2
|
+
|
|
3
|
+
Three implementations ship out of the box:
|
|
4
|
+
|
|
5
|
+
- ``ExtractiveLLM`` — no API key needed. Returns the most relevant retrieved
|
|
6
|
+
passages verbatim. Useful for demos, tests, and CI.
|
|
7
|
+
- ``OpenAILLM`` — chat completion via the OpenAI API.
|
|
8
|
+
- ``AnthropicLLM`` — messages via the Anthropic API (Claude).
|
|
9
|
+
|
|
10
|
+
Retrieved passages are numbered in the prompt and the model is instructed to
|
|
11
|
+
cite them as ``[1]``, ``[2]``, … — the numbers correspond 1:1 to the order of
|
|
12
|
+
``Answer.sources`` returned by the GraphQL API.
|
|
13
|
+
|
|
14
|
+
To add another provider, subclass ``LLM`` and register it in ``create_llm``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
|
|
21
|
+
from .config import Settings
|
|
22
|
+
from .vectorstore import SearchHit
|
|
23
|
+
|
|
24
|
+
PROMPT_TEMPLATE = """\
|
|
25
|
+
Answer the question using ONLY the numbered context passages below. If the
|
|
26
|
+
context does not contain the answer, say you don't know rather than guessing.
|
|
27
|
+
Cite the passages you used with bracketed numbers, e.g. [1] or [2][3].
|
|
28
|
+
|
|
29
|
+
Context:
|
|
30
|
+
{context}
|
|
31
|
+
|
|
32
|
+
Question: {question}
|
|
33
|
+
|
|
34
|
+
Answer:"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_passage(number: int, hit: SearchHit) -> str:
|
|
38
|
+
title = f" ({hit.document_title})" if hit.document_title else ""
|
|
39
|
+
return f"[{number}]{title}\n{hit.text}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_prompt(question: str, sources: list[SearchHit]) -> str:
|
|
43
|
+
if sources:
|
|
44
|
+
context = "\n\n".join(format_passage(i + 1, hit) for i, hit in enumerate(sources))
|
|
45
|
+
else:
|
|
46
|
+
context = "(no context found)"
|
|
47
|
+
return PROMPT_TEMPLATE.format(context=context, question=question)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LLM(ABC):
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def generate(self, question: str, sources: list[SearchHit]) -> str:
|
|
53
|
+
"""Produce an answer to ``question`` grounded in the retrieved ``sources``."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ExtractiveLLM(LLM):
|
|
57
|
+
"""Key-free fallback: returns the top retrieved passages as the answer."""
|
|
58
|
+
|
|
59
|
+
def generate(self, question: str, sources: list[SearchHit]) -> str:
|
|
60
|
+
if not sources:
|
|
61
|
+
return "No relevant documents found for this question."
|
|
62
|
+
passages = "\n\n".join(format_passage(i + 1, hit) for i, hit in enumerate(sources[:2]))
|
|
63
|
+
return (
|
|
64
|
+
"[extractive mode — configure GRAPHSEARCH_LLM=openai or anthropic "
|
|
65
|
+
"for generated answers]\n\nMost relevant passages:\n\n" + passages
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OpenAILLM(LLM):
|
|
70
|
+
def __init__(self, api_key: str, model: str = "gpt-4o-mini") -> None:
|
|
71
|
+
try:
|
|
72
|
+
from openai import OpenAI
|
|
73
|
+
except ImportError as exc: # pragma: no cover
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"The 'openai' package is required for GRAPHSEARCH_LLM=openai. "
|
|
76
|
+
"Install it with: pip install graphsearch-rag[openai]"
|
|
77
|
+
) from exc
|
|
78
|
+
if not api_key:
|
|
79
|
+
raise RuntimeError("OPENAI_API_KEY must be set for GRAPHSEARCH_LLM=openai")
|
|
80
|
+
self._client = OpenAI(api_key=api_key)
|
|
81
|
+
self._model = model
|
|
82
|
+
|
|
83
|
+
def generate(self, question: str, sources: list[SearchHit]) -> str:
|
|
84
|
+
response = self._client.chat.completions.create(
|
|
85
|
+
model=self._model,
|
|
86
|
+
messages=[{"role": "user", "content": build_prompt(question, sources)}],
|
|
87
|
+
)
|
|
88
|
+
return response.choices[0].message.content or ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AnthropicLLM(LLM):
|
|
92
|
+
def __init__(self, api_key: str, model: str = "claude-sonnet-5") -> None:
|
|
93
|
+
try:
|
|
94
|
+
from anthropic import Anthropic
|
|
95
|
+
except ImportError as exc: # pragma: no cover
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
"The 'anthropic' package is required for GRAPHSEARCH_LLM=anthropic. "
|
|
98
|
+
"Install it with: pip install graphsearch-rag[anthropic]"
|
|
99
|
+
) from exc
|
|
100
|
+
if not api_key:
|
|
101
|
+
raise RuntimeError("ANTHROPIC_API_KEY must be set for GRAPHSEARCH_LLM=anthropic")
|
|
102
|
+
self._client = Anthropic(api_key=api_key)
|
|
103
|
+
self._model = model
|
|
104
|
+
|
|
105
|
+
def generate(self, question: str, sources: list[SearchHit]) -> str:
|
|
106
|
+
response = self._client.messages.create(
|
|
107
|
+
model=self._model,
|
|
108
|
+
max_tokens=1024,
|
|
109
|
+
messages=[{"role": "user", "content": build_prompt(question, sources)}],
|
|
110
|
+
)
|
|
111
|
+
return "".join(block.text for block in response.content if block.type == "text")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def create_llm(settings: Settings) -> LLM:
|
|
115
|
+
if settings.llm == "extractive":
|
|
116
|
+
return ExtractiveLLM()
|
|
117
|
+
if settings.llm == "openai":
|
|
118
|
+
return OpenAILLM(settings.openai_api_key, settings.openai_model)
|
|
119
|
+
if settings.llm == "anthropic":
|
|
120
|
+
return AnthropicLLM(settings.anthropic_api_key, settings.anthropic_model)
|
|
121
|
+
raise ValueError(f"Unknown LLM backend: {settings.llm!r}")
|
graphsearch/main.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""FastAPI application entrypoint. GraphQL is served at /graphql with GraphiQL enabled."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from strawberry.fastapi import GraphQLRouter
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .config import Settings, get_settings
|
|
12
|
+
from .rag import RagService
|
|
13
|
+
from .schema import schema
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_app(settings: Settings | None = None, rag_service: RagService | None = None) -> FastAPI:
|
|
17
|
+
settings = settings or get_settings()
|
|
18
|
+
service = rag_service or RagService(settings)
|
|
19
|
+
|
|
20
|
+
@asynccontextmanager
|
|
21
|
+
async def lifespan(app: FastAPI):
|
|
22
|
+
yield
|
|
23
|
+
service.close()
|
|
24
|
+
|
|
25
|
+
app = FastAPI(title="GraphSearch", version=__version__, lifespan=lifespan)
|
|
26
|
+
|
|
27
|
+
async def get_context() -> dict:
|
|
28
|
+
return {"rag_service": service}
|
|
29
|
+
|
|
30
|
+
graphql_router: GraphQLRouter = GraphQLRouter(schema, context_getter=get_context)
|
|
31
|
+
app.include_router(graphql_router, prefix="/graphql")
|
|
32
|
+
|
|
33
|
+
@app.get("/health")
|
|
34
|
+
async def health() -> dict:
|
|
35
|
+
return {"status": "ok", "version": __version__}
|
|
36
|
+
|
|
37
|
+
return app
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run() -> None:
|
|
41
|
+
"""Console entrypoint: ``graphsearch``."""
|
|
42
|
+
import uvicorn
|
|
43
|
+
|
|
44
|
+
settings = get_settings()
|
|
45
|
+
uvicorn.run(create_app(settings), host=settings.host, port=settings.port)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
run()
|
graphsearch/rag.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""The RAG pipeline: ingest documents, retrieve relevant chunks, generate answers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .chunking import chunk_text
|
|
8
|
+
from .config import Settings
|
|
9
|
+
from .db import Database, DocumentRecord
|
|
10
|
+
from .embeddings import Embedder, create_embedder
|
|
11
|
+
from .llm import LLM, create_llm
|
|
12
|
+
from .vectorstore import SearchHit, SqliteVectorStore, VectorStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class AnswerResult:
|
|
17
|
+
text: str
|
|
18
|
+
sources: list[SearchHit]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RagService:
|
|
22
|
+
"""Ties together the database, embedder, vector store, and LLM."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
settings: Settings,
|
|
27
|
+
db: Database | None = None,
|
|
28
|
+
embedder: Embedder | None = None,
|
|
29
|
+
vector_store: VectorStore | None = None,
|
|
30
|
+
llm: LLM | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.settings = settings
|
|
33
|
+
self.db = db or Database(settings.database_path)
|
|
34
|
+
self.embedder = embedder or create_embedder(settings)
|
|
35
|
+
self.vector_store = vector_store or SqliteVectorStore(self.db)
|
|
36
|
+
self.llm = llm or create_llm(settings)
|
|
37
|
+
|
|
38
|
+
def ingest(
|
|
39
|
+
self, content: str, title: str | None = None, source: str | None = None
|
|
40
|
+
) -> DocumentRecord:
|
|
41
|
+
"""Chunk, embed, and store a document."""
|
|
42
|
+
chunks = chunk_text(content, self.settings.chunk_size, self.settings.chunk_overlap)
|
|
43
|
+
if not chunks:
|
|
44
|
+
raise ValueError("Document content is empty")
|
|
45
|
+
embeddings = self.embedder.embed(chunks)
|
|
46
|
+
record = self.db.insert_document(content, title, source, chunks, embeddings)
|
|
47
|
+
self.vector_store.invalidate()
|
|
48
|
+
return record
|
|
49
|
+
|
|
50
|
+
def delete_document(self, doc_id: str) -> bool:
|
|
51
|
+
"""Delete a document and drop its chunks from the search index."""
|
|
52
|
+
deleted = self.db.delete_document(doc_id)
|
|
53
|
+
if deleted:
|
|
54
|
+
self.vector_store.invalidate()
|
|
55
|
+
return deleted
|
|
56
|
+
|
|
57
|
+
def search(self, query: str, top_k: int | None = None) -> list[SearchHit]:
|
|
58
|
+
"""Embed the query and return the most similar chunks."""
|
|
59
|
+
top_k = top_k or self.settings.default_top_k
|
|
60
|
+
query_vector = self.embedder.embed([query])[0]
|
|
61
|
+
return self.vector_store.search(query_vector, top_k)
|
|
62
|
+
|
|
63
|
+
def answer(self, question: str, top_k: int | None = None) -> AnswerResult:
|
|
64
|
+
"""Retrieve relevant chunks and generate a grounded answer.
|
|
65
|
+
|
|
66
|
+
Citation numbers in the answer text ([1], [2], …) refer to positions
|
|
67
|
+
in ``sources`` (1-indexed).
|
|
68
|
+
"""
|
|
69
|
+
hits = self.search(question, top_k)
|
|
70
|
+
text = self.llm.generate(question, hits)
|
|
71
|
+
return AnswerResult(text=text, sources=hits)
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
self.db.close()
|
graphsearch/schema.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Strawberry GraphQL schema: types, queries, and mutations.
|
|
2
|
+
|
|
3
|
+
Field names are camelCased by Strawberry, so the public API matches the
|
|
4
|
+
project spec: ``answer(question:)``, ``uploadDocument(content:)``, etc.
|
|
5
|
+
Resolvers delegate to ``RagService`` via ``asyncio.to_thread`` so blocking
|
|
6
|
+
I/O (SQLite, provider APIs) never stalls the event loop.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
import strawberry
|
|
14
|
+
from strawberry.types import Info
|
|
15
|
+
|
|
16
|
+
from .db import DocumentRecord
|
|
17
|
+
from .rag import RagService
|
|
18
|
+
from .vectorstore import SearchHit
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@strawberry.type
|
|
22
|
+
class Document:
|
|
23
|
+
id: strawberry.ID
|
|
24
|
+
title: str | None
|
|
25
|
+
source: str | None
|
|
26
|
+
content: str
|
|
27
|
+
created_at: str
|
|
28
|
+
chunk_count: int
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def from_record(record: DocumentRecord) -> Document:
|
|
32
|
+
return Document(
|
|
33
|
+
id=strawberry.ID(record.id),
|
|
34
|
+
title=record.title,
|
|
35
|
+
source=record.source,
|
|
36
|
+
content=record.content,
|
|
37
|
+
created_at=record.created_at,
|
|
38
|
+
chunk_count=record.chunk_count,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@strawberry.type
|
|
43
|
+
class Chunk:
|
|
44
|
+
id: strawberry.ID
|
|
45
|
+
document_id: strawberry.ID
|
|
46
|
+
document_title: str | None
|
|
47
|
+
text: str
|
|
48
|
+
score: float
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def from_hit(hit: SearchHit) -> Chunk:
|
|
52
|
+
return Chunk(
|
|
53
|
+
id=strawberry.ID(hit.chunk_id),
|
|
54
|
+
document_id=strawberry.ID(hit.document_id),
|
|
55
|
+
document_title=hit.document_title,
|
|
56
|
+
text=hit.text,
|
|
57
|
+
score=hit.score,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@strawberry.type
|
|
62
|
+
class Answer:
|
|
63
|
+
text: str
|
|
64
|
+
sources: list[Chunk] = strawberry.field(
|
|
65
|
+
description="Retrieved passages; citations like [1] in `text` are 1-indexed into this list."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _service(info: Info) -> RagService:
|
|
70
|
+
return info.context["rag_service"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@strawberry.type
|
|
74
|
+
class Query:
|
|
75
|
+
@strawberry.field(description="Ask a question and get an answer grounded in your documents.")
|
|
76
|
+
async def answer(self, info: Info, question: str, top_k: int | None = None) -> Answer:
|
|
77
|
+
result = await asyncio.to_thread(_service(info).answer, question, top_k)
|
|
78
|
+
return Answer(text=result.text, sources=[Chunk.from_hit(h) for h in result.sources])
|
|
79
|
+
|
|
80
|
+
@strawberry.field(description="Semantic search: return the most relevant chunks for a query.")
|
|
81
|
+
async def search(self, info: Info, query: str, top_k: int | None = None) -> list[Chunk]:
|
|
82
|
+
hits = await asyncio.to_thread(_service(info).search, query, top_k)
|
|
83
|
+
return [Chunk.from_hit(h) for h in hits]
|
|
84
|
+
|
|
85
|
+
@strawberry.field(description="List ingested documents, newest first.")
|
|
86
|
+
async def documents(self, info: Info, limit: int = 20, offset: int = 0) -> list[Document]:
|
|
87
|
+
records = await asyncio.to_thread(_service(info).db.list_documents, limit, offset)
|
|
88
|
+
return [Document.from_record(r) for r in records]
|
|
89
|
+
|
|
90
|
+
@strawberry.field(description="Fetch a single document by ID.")
|
|
91
|
+
async def document(self, info: Info, id: strawberry.ID) -> Document | None:
|
|
92
|
+
record = await asyncio.to_thread(_service(info).db.get_document, str(id))
|
|
93
|
+
return Document.from_record(record) if record else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@strawberry.type
|
|
97
|
+
class Mutation:
|
|
98
|
+
@strawberry.mutation(description="Ingest a document: it is chunked, embedded, and indexed.")
|
|
99
|
+
async def upload_document(
|
|
100
|
+
self,
|
|
101
|
+
info: Info,
|
|
102
|
+
content: str,
|
|
103
|
+
title: str | None = None,
|
|
104
|
+
source: str | None = None,
|
|
105
|
+
) -> Document:
|
|
106
|
+
record = await asyncio.to_thread(_service(info).ingest, content, title, source)
|
|
107
|
+
return Document.from_record(record)
|
|
108
|
+
|
|
109
|
+
@strawberry.mutation(description="Delete a document and its indexed chunks.")
|
|
110
|
+
async def delete_document(self, info: Info, id: strawberry.ID) -> bool:
|
|
111
|
+
return await asyncio.to_thread(_service(info).delete_document, str(id))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
schema = strawberry.Schema(query=Query, mutation=Mutation)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Vector search over stored chunk embeddings.
|
|
2
|
+
|
|
3
|
+
The default store keeps an in-memory copy of the embedding matrix (loaded
|
|
4
|
+
from SQLite on first search, refreshed after writes), which is plenty for
|
|
5
|
+
tens of thousands of chunks with zero extra infrastructure. The
|
|
6
|
+
``VectorStore`` interface exists so alternative backends (Qdrant, Weaviate,
|
|
7
|
+
Redis, pgvector, FAISS) can be added — see the "good first issue" list in
|
|
8
|
+
the README.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from .db import ChunkRecord, Database
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SearchHit:
|
|
24
|
+
chunk_id: str
|
|
25
|
+
document_id: str
|
|
26
|
+
document_title: str | None
|
|
27
|
+
text: str
|
|
28
|
+
score: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class VectorStore(ABC):
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def search(self, query_vector: np.ndarray, top_k: int) -> list[SearchHit]:
|
|
34
|
+
"""Return the ``top_k`` most similar chunks by cosine similarity."""
|
|
35
|
+
|
|
36
|
+
def invalidate(self) -> None: # noqa: B027 — intentional no-op default
|
|
37
|
+
"""Called after documents are added or removed. Override if the store caches."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SqliteVectorStore(VectorStore):
|
|
41
|
+
"""Cosine search over embeddings stored in the SQLite database.
|
|
42
|
+
|
|
43
|
+
The chunk list and embedding matrix are cached in memory so queries are a
|
|
44
|
+
single numpy matrix-vector product; ``invalidate()`` (called by
|
|
45
|
+
``RagService`` after ingest/delete) forces a reload on the next search.
|
|
46
|
+
Embeddings are L2-normalized at write time, so cosine similarity reduces
|
|
47
|
+
to a dot product.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, db: Database) -> None:
|
|
51
|
+
self._db = db
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
self._chunks: list[ChunkRecord] | None = None
|
|
54
|
+
self._matrix: np.ndarray | None = None
|
|
55
|
+
|
|
56
|
+
def invalidate(self) -> None:
|
|
57
|
+
with self._lock:
|
|
58
|
+
self._chunks = None
|
|
59
|
+
self._matrix = None
|
|
60
|
+
|
|
61
|
+
def _load(self) -> tuple[list[ChunkRecord], np.ndarray]:
|
|
62
|
+
with self._lock:
|
|
63
|
+
if self._chunks is None or self._matrix is None:
|
|
64
|
+
self._chunks, self._matrix = self._db.all_embeddings()
|
|
65
|
+
return self._chunks, self._matrix
|
|
66
|
+
|
|
67
|
+
def search(self, query_vector: np.ndarray, top_k: int) -> list[SearchHit]:
|
|
68
|
+
chunks, matrix = self._load()
|
|
69
|
+
if not chunks:
|
|
70
|
+
return []
|
|
71
|
+
query = np.asarray(query_vector, dtype=np.float32).reshape(-1)
|
|
72
|
+
scores = matrix @ query
|
|
73
|
+
top_k = min(top_k, len(chunks))
|
|
74
|
+
top_indices = np.argsort(scores)[::-1][:top_k]
|
|
75
|
+
return [
|
|
76
|
+
SearchHit(
|
|
77
|
+
chunk_id=chunks[i].id,
|
|
78
|
+
document_id=chunks[i].document_id,
|
|
79
|
+
document_title=chunks[i].document_title,
|
|
80
|
+
text=chunks[i].text,
|
|
81
|
+
score=float(scores[i]),
|
|
82
|
+
)
|
|
83
|
+
for i in top_indices
|
|
84
|
+
]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: graphsearch-rag
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: GraphSearch: a GraphQL API server for Retrieval-Augmented Generation (RAG) over your documents
|
|
5
|
+
Author: GraphSearch contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mohithgowdak/graphsearch
|
|
8
|
+
Project-URL: Issues, https://github.com/mohithgowdak/graphsearch/issues
|
|
9
|
+
Keywords: graphql,rag,semantic-search,llm,retrieval-augmented-generation
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: fastapi>=0.110
|
|
19
|
+
Requires-Dist: strawberry-graphql[fastapi]>=0.220
|
|
20
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
21
|
+
Requires-Dist: pydantic-settings>=2.2
|
|
22
|
+
Requires-Dist: numpy>=1.26
|
|
23
|
+
Provides-Extra: local
|
|
24
|
+
Requires-Dist: sentence-transformers>=2.7; extra == "local"
|
|
25
|
+
Provides-Extra: openai
|
|
26
|
+
Requires-Dist: openai>=1.30; extra == "openai"
|
|
27
|
+
Provides-Extra: anthropic
|
|
28
|
+
Requires-Dist: anthropic>=0.30; extra == "anthropic"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
32
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# GraphSearch
|
|
36
|
+
|
|
37
|
+
[](https://github.com/mohithgowdak/graphsearch/actions/workflows/ci.yml)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
[](pyproject.toml)
|
|
40
|
+
|
|
41
|
+
**A GraphQL API server for Retrieval-Augmented Generation (RAG) over your documents.**
|
|
42
|
+
|
|
43
|
+
Upload documents through a GraphQL mutation, then ask questions through a GraphQL query.
|
|
44
|
+
GraphSearch chunks and embeds your documents, retrieves the most relevant passages with
|
|
45
|
+
vector similarity search, and feeds them to an LLM to generate a grounded answer — all
|
|
46
|
+
behind a single, typed, introspectable GraphQL endpoint.
|
|
47
|
+
|
|
48
|
+
```graphql
|
|
49
|
+
query {
|
|
50
|
+
answer(question: "How do I reset my password?") {
|
|
51
|
+
text # cites sources as [1], [2], ...
|
|
52
|
+
sources { documentTitle text score }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+

|
|
58
|
+
|
|
59
|
+
*Semantic retrieval with the offline `local` backend: "How do I get my money back?" finds the returns policy — no shared keywords needed.*
|
|
60
|
+
|
|
61
|
+
## Why GraphQL for RAG?
|
|
62
|
+
|
|
63
|
+
- **One endpoint, typed schema** — clients ask for exactly the fields they need
|
|
64
|
+
(answer text, source chunks, scores) instead of juggling REST routes.
|
|
65
|
+
- **Introspection & tooling for free** — GraphiQL, codegen'd TypeScript clients,
|
|
66
|
+
and schema docs come with the ecosystem.
|
|
67
|
+
- **Composable** — `answer`, `search`, and document management live in one schema
|
|
68
|
+
and can be combined in a single request.
|
|
69
|
+
|
|
70
|
+
## Quickstart
|
|
71
|
+
|
|
72
|
+
### Install
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install graphsearch-rag # from PyPI (imports as `graphsearch`)
|
|
76
|
+
# or run the prebuilt image:
|
|
77
|
+
docker run -p 8000:8000 ghcr.io/mohithgowdak/graphsearch:latest
|
|
78
|
+
# or from source:
|
|
79
|
+
git clone https://github.com/mohithgowdak/graphsearch && cd graphsearch
|
|
80
|
+
pip install -e ".[dev]"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Run locally (no API keys needed)
|
|
84
|
+
|
|
85
|
+
The default configuration is fully offline: a hashing-trick embedder plus an
|
|
86
|
+
extractive answer mode. It exercises the entire pipeline and is perfect for
|
|
87
|
+
kicking the tires, tests, and CI.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# Ingest some documents (bundled examples shown; any .md/.txt works)
|
|
91
|
+
graphsearch-ingest data/example_docs
|
|
92
|
+
|
|
93
|
+
# Start the server
|
|
94
|
+
graphsearch
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Open **http://localhost:8000/graphql** for the GraphiQL playground and try:
|
|
98
|
+
|
|
99
|
+
```graphql
|
|
100
|
+
query {
|
|
101
|
+
answer(question: "What is the return policy?") {
|
|
102
|
+
text
|
|
103
|
+
sources { text score }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Run with Docker
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
docker compose up --build
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Semantic search without any API key
|
|
115
|
+
|
|
116
|
+
The `local` backend runs [sentence-transformers](https://www.sbert.net/)
|
|
117
|
+
on your CPU — real semantic retrieval ("How do I get my money back?" finds
|
|
118
|
+
the refund policy), still zero keys and zero external services:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pip install -e ".[local]"
|
|
122
|
+
export GRAPHSEARCH_EMBEDDINGS=local # $env:GRAPHSEARCH_EMBEDDINGS='local' on Windows
|
|
123
|
+
graphsearch-ingest data/example_docs # re-ingest: embeddings are created at ingest time
|
|
124
|
+
graphsearch
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The default model (`all-MiniLM-L6-v2`, ~80 MB) downloads on first use.
|
|
128
|
+
|
|
129
|
+
### Generated answers with citations
|
|
130
|
+
|
|
131
|
+
Point the LLM stage at OpenAI or Anthropic and answers become generated
|
|
132
|
+
text that cites its sources — `[1]`, `[2]`, … map 1:1 to the `sources`
|
|
133
|
+
list returned alongside the answer:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
export GRAPHSEARCH_LLM=anthropic # or openai
|
|
137
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
138
|
+
pip install -e ".[anthropic]"
|
|
139
|
+
graphsearch
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| Setting | Options | Default |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `GRAPHSEARCH_EMBEDDINGS` | `hash` (offline), `local` (offline, semantic), `openai` | `hash` |
|
|
145
|
+
| `GRAPHSEARCH_LLM` | `extractive` (offline), `openai`, `anthropic` | `extractive` |
|
|
146
|
+
|
|
147
|
+
> **Note:** documents are embedded at ingest time, so re-ingest after switching
|
|
148
|
+
> embedding backends.
|
|
149
|
+
|
|
150
|
+
## GraphQL API
|
|
151
|
+
|
|
152
|
+
### Queries
|
|
153
|
+
|
|
154
|
+
```graphql
|
|
155
|
+
answer(question: String!, topK: Int): Answer! # RAG: retrieve + generate
|
|
156
|
+
search(query: String!, topK: Int): [Chunk!]! # raw semantic search
|
|
157
|
+
documents(limit: Int = 20, offset: Int = 0): [Document!]!
|
|
158
|
+
document(id: ID!): Document
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Mutations
|
|
162
|
+
|
|
163
|
+
```graphql
|
|
164
|
+
uploadDocument(content: String!, title: String, source: String): Document!
|
|
165
|
+
deleteDocument(id: ID!): Boolean!
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Example: ingest and ask in one session
|
|
169
|
+
|
|
170
|
+
```graphql
|
|
171
|
+
mutation {
|
|
172
|
+
uploadDocument(
|
|
173
|
+
content: "Support hours are 9am-5pm PST, Monday through Friday."
|
|
174
|
+
title: "support-hours"
|
|
175
|
+
) { id chunkCount }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
query {
|
|
179
|
+
answer(question: "When is support available?") {
|
|
180
|
+
text
|
|
181
|
+
sources { documentId score }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Architecture
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
Client ── GraphQL (FastAPI + Strawberry) ── RagService
|
|
190
|
+
├─ chunking (paragraph-aware splitter)
|
|
191
|
+
├─ Embedder (hash | sentence-transformers | OpenAI)
|
|
192
|
+
├─ VectorStore (SQLite-backed, in-memory cosine search)
|
|
193
|
+
├─ Database (SQLite: documents, chunks, vectors)
|
|
194
|
+
└─ LLM (extractive | OpenAI | Anthropic)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Every pipeline stage is an abstract interface (`Embedder`, `VectorStore`, `LLM`),
|
|
198
|
+
so new backends are drop-in additions — see [Contributing](#contributing).
|
|
199
|
+
|
|
200
|
+
## Development
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
pip install -e ".[dev]"
|
|
204
|
+
ruff check . # lint
|
|
205
|
+
pytest -v # tests run fully offline
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Roadmap / good first issues
|
|
209
|
+
|
|
210
|
+
- [ ] Additional vector store backends: Qdrant, Weaviate, Redis/Valkey, pgvector, FAISS
|
|
211
|
+
- [ ] Additional embedding backends: Cohere, Voyage
|
|
212
|
+
- [ ] Streaming answers via GraphQL subscriptions
|
|
213
|
+
- [ ] Metadata filters on `search` (tags, date ranges) and hybrid keyword+vector search
|
|
214
|
+
- [ ] Auth (API keys / JWT) and rate limiting
|
|
215
|
+
- [ ] Query/embedding caching
|
|
216
|
+
- [ ] Auto-generated TypeScript client (GraphQL Code Generator)
|
|
217
|
+
- [ ] Evaluation harness with known Q&A pairs; Prometheus metrics
|
|
218
|
+
- [ ] Advanced RAG: query rewriting, multi-hop retrieval, citation spans
|
|
219
|
+
|
|
220
|
+
## Contributing
|
|
221
|
+
|
|
222
|
+
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for setup,
|
|
223
|
+
style, and PR guidelines, and the issue tracker for `good first issue` labels.
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
graphsearch/__init__.py,sha256=g8QeIYITtHF4RhtHnpe0BjYe2TD2W9pUUF1SdeqpnYo,112
|
|
2
|
+
graphsearch/chunking.py,sha256=OSD6mHrjVFGBsnIntc6vvlgu0r5vsT2ZJdzIYO2D6UY,1592
|
|
3
|
+
graphsearch/config.py,sha256=5MxJpSbQJYLoyh--KuzuuHh3f6p7ss0-2O-CSmQGgjg,1659
|
|
4
|
+
graphsearch/db.py,sha256=7xbb8H7Ryv4iAaztrXLierVSX4agl8DeIDZH02nLHHo,4742
|
|
5
|
+
graphsearch/embeddings.py,sha256=VPpChmTE5cvcGpUKMC5Z3juzQGomEuuLsZLJ19cTtYc,4890
|
|
6
|
+
graphsearch/ingest.py,sha256=9INaajUjjC_U5zmeQOC66l6YXTAyUf26vivBhbPeH8g,1454
|
|
7
|
+
graphsearch/llm.py,sha256=UtW5qCK_CiW4hFoRqGr57JBGEHaa136WDesmcHfCpe4,4589
|
|
8
|
+
graphsearch/main.py,sha256=fDABDF2lOq6uRMSSRC_paTtZecrPyQy5p5w3rD6sq2U,1333
|
|
9
|
+
graphsearch/rag.py,sha256=HvlgEZlUAw81drY2gBb_BDz2edL2fRJpymfBxFY_xCY,2678
|
|
10
|
+
graphsearch/schema.py,sha256=RkwOTFnyxd9R62sAnMCvVrsSDRWeyXcCrbIlwP4Js5E,3834
|
|
11
|
+
graphsearch/vectorstore.py,sha256=Wa-Wz277xYjUmdUvoPpzcSVd249ooXWJtKrK2D5OPng,2829
|
|
12
|
+
graphsearch_rag-0.2.1.dist-info/licenses/LICENSE,sha256=QOyvrKmYIhmfNPbKczjmkXyguf7mgl4TWPCZSt5DbHI,1081
|
|
13
|
+
graphsearch_rag-0.2.1.dist-info/METADATA,sha256=9TxAUw6BzyU-krRSouXZhDKjaMPF-LxMWp1gqrVdo4E,7656
|
|
14
|
+
graphsearch_rag-0.2.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
15
|
+
graphsearch_rag-0.2.1.dist-info/entry_points.txt,sha256=OT1M6yCLQgY93F4UVv4CbQeK6PVQhAuaq8fE2VHIqj0,98
|
|
16
|
+
graphsearch_rag-0.2.1.dist-info/top_level.txt,sha256=m758MWbbz5dv3xwVp9_ge3ppA-JF3my8Xb4O3I4DRbc,12
|
|
17
|
+
graphsearch_rag-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GraphSearch contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
graphsearch
|