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.
@@ -0,0 +1,3 @@
1
+ """GraphSearch: a GraphQL API for Retrieval-Augmented Generation over your documents."""
2
+
3
+ __version__ = "0.2.1"
@@ -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
+ [![CI](https://github.com/mohithgowdak/graphsearch/actions/workflows/ci.yml/badge.svg)](https://github.com/mohithgowdak/graphsearch/actions/workflows/ci.yml)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
39
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](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
+ ![GraphSearch demo: asking "How do I get my money back?" in GraphiQL and getting the returns policy with ranked sources](docs/demo.gif)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ graphsearch = graphsearch.main:run
3
+ graphsearch-ingest = graphsearch.ingest:main
@@ -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