Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- README.md +13 -7
- app.py +59 -0
- requirements.txt +3 -0
- src/ragassistant/__init__.py +3 -0
- src/ragassistant/cli.py +35 -0
- src/ragassistant/config.py +14 -0
- src/ragassistant/documents.py +30 -0
- src/ragassistant/embeddings.py +19 -0
- src/ragassistant/evaluate.py +31 -0
- src/ragassistant/generator.py +25 -0
- src/ragassistant/ingest.py +16 -0
- src/ragassistant/rag.py +12 -0
- src/ragassistant/retriever.py +12 -0
- src/ragassistant/vector_store.py +27 -0
- storage/chunks.json +52 -0
- storage/index.npz +3 -0
README.md
CHANGED
|
@@ -1,13 +1,19 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Document Question Answering (RAG)
|
| 3 |
+
emoji: 📚
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: pink
|
| 6 |
sdk: gradio
|
| 7 |
+
python_version: "3.11"
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
short_description: Retrieval-augmented question answering over a small doc set
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Document Question Answering (RAG) - live demo
|
| 15 |
+
|
| 16 |
+
Ask a question about a small documentation set. The system retrieves the most
|
| 17 |
+
relevant passages and answers from them, citing the source file for each fact.
|
| 18 |
+
|
| 19 |
+
- Code: https://github.com/delcenjo/rag-document-assistant
|
app.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
from ragassistant import generator
|
| 8 |
+
from ragassistant.rag import RAGPipeline
|
| 9 |
+
|
| 10 |
+
DEMO_MODEL = "claude-haiku-4-5-20251001"
|
| 11 |
+
MAX_QUESTION_CHARS = 300
|
| 12 |
+
|
| 13 |
+
_pipeline = None
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_pipeline():
|
| 17 |
+
global _pipeline
|
| 18 |
+
if _pipeline is None:
|
| 19 |
+
_pipeline = RAGPipeline()
|
| 20 |
+
return _pipeline
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def answer(question):
|
| 24 |
+
question = (question or "").strip()
|
| 25 |
+
if not question:
|
| 26 |
+
return "Ask a question about the product documentation."
|
| 27 |
+
if len(question) > MAX_QUESTION_CHARS:
|
| 28 |
+
return f"Please keep the question under {MAX_QUESTION_CHARS} characters."
|
| 29 |
+
if not os.getenv("ANTHROPIC_API_KEY"):
|
| 30 |
+
return "This demo is not configured yet (the API key is missing)."
|
| 31 |
+
try:
|
| 32 |
+
pipeline = get_pipeline()
|
| 33 |
+
contexts = pipeline.retriever.retrieve(question)
|
| 34 |
+
return generator.generate_answer(question, contexts, model=DEMO_MODEL)
|
| 35 |
+
except Exception:
|
| 36 |
+
return "The service is busy or unavailable right now. Please try again in a moment."
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
demo = gr.Interface(
|
| 40 |
+
fn=answer,
|
| 41 |
+
inputs=gr.Textbox(lines=2, label="Ask a question about the product documentation",
|
| 42 |
+
placeholder="What is the refund policy?"),
|
| 43 |
+
outputs=gr.Textbox(lines=8, label="Answer (with sources)"),
|
| 44 |
+
title="Document Question Answering (RAG)",
|
| 45 |
+
description=(
|
| 46 |
+
"Ask a question about a small documentation set (overview, pricing, API, "
|
| 47 |
+
"security, FAQ). The system retrieves the most relevant passages and answers "
|
| 48 |
+
"from them, citing the source file for each fact."
|
| 49 |
+
),
|
| 50 |
+
article="Code: https://github.com/delcenjo/rag-document-assistant",
|
| 51 |
+
examples=[
|
| 52 |
+
["What is the refund policy?"],
|
| 53 |
+
["How do I authenticate with the API?"],
|
| 54 |
+
["Where is the data stored?"],
|
| 55 |
+
],
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
if __name__ == "__main__":
|
| 59 |
+
demo.launch(ssr_mode=False)
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
sentence-transformers
|
| 2 |
+
anthropic
|
| 3 |
+
numpy
|
src/ragassistant/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Retrieval-augmented question answering over a document corpus."""
|
| 2 |
+
|
| 3 |
+
__version__ = "1.0.0"
|
src/ragassistant/cli.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
from .config import TOP_K
|
| 7 |
+
from .generator import generate_answer
|
| 8 |
+
from .retriever import Retriever
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def main():
|
| 12 |
+
load_dotenv()
|
| 13 |
+
parser = argparse.ArgumentParser(description="Ask questions about the indexed corpus")
|
| 14 |
+
parser.add_argument("question", nargs="?", help="question to ask")
|
| 15 |
+
parser.add_argument("--top-k", type=int, default=TOP_K)
|
| 16 |
+
parser.add_argument("--show-context", action="store_true", help="print retrieved chunks")
|
| 17 |
+
args = parser.parse_args()
|
| 18 |
+
|
| 19 |
+
question = args.question or input("Question: ")
|
| 20 |
+
contexts = Retriever().retrieve(question, args.top_k)
|
| 21 |
+
|
| 22 |
+
if args.show_context or not os.getenv("ANTHROPIC_API_KEY"):
|
| 23 |
+
print("\nRetrieved context:")
|
| 24 |
+
for chunk, score in contexts:
|
| 25 |
+
print(f" [{chunk['source']}] score={score:.3f}")
|
| 26 |
+
|
| 27 |
+
if os.getenv("ANTHROPIC_API_KEY"):
|
| 28 |
+
print("\nAnswer:")
|
| 29 |
+
print(generate_answer(question, contexts))
|
| 30 |
+
else:
|
| 31 |
+
print("\nSet ANTHROPIC_API_KEY (see .env.example) to generate a written answer.")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
if __name__ == "__main__":
|
| 35 |
+
main()
|
src/ragassistant/config.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
| 4 |
+
CORPUS_DIR = PROJECT_ROOT / "data" / "corpus"
|
| 5 |
+
STORAGE_DIR = PROJECT_ROOT / "storage"
|
| 6 |
+
INDEX_PATH = STORAGE_DIR / "index.npz"
|
| 7 |
+
META_PATH = STORAGE_DIR / "chunks.json"
|
| 8 |
+
|
| 9 |
+
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
| 10 |
+
CHAT_MODEL = "claude-sonnet-4-6"
|
| 11 |
+
|
| 12 |
+
CHUNK_SIZE = 600
|
| 13 |
+
CHUNK_OVERLAP = 100
|
| 14 |
+
TOP_K = 4
|
src/ragassistant/documents.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .config import CHUNK_OVERLAP, CHUNK_SIZE
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def load_documents(corpus_dir):
|
| 5 |
+
documents = []
|
| 6 |
+
for path in sorted(corpus_dir.glob("*.md")):
|
| 7 |
+
documents.append({"source": path.name, "text": path.read_text(encoding="utf-8")})
|
| 8 |
+
return documents
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def chunk_text(text, chunk_size=CHUNK_SIZE, overlap=CHUNK_OVERLAP):
|
| 12 |
+
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
| 13 |
+
chunks, current = [], ""
|
| 14 |
+
for paragraph in paragraphs:
|
| 15 |
+
if current and len(current) + len(paragraph) + 1 > chunk_size:
|
| 16 |
+
chunks.append(current)
|
| 17 |
+
current = (current[-overlap:] + " " + paragraph) if overlap else paragraph
|
| 18 |
+
else:
|
| 19 |
+
current = f"{current}\n{paragraph}".strip() if current else paragraph
|
| 20 |
+
if current:
|
| 21 |
+
chunks.append(current)
|
| 22 |
+
return chunks
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def build_chunks(documents, chunk_size=CHUNK_SIZE, overlap=CHUNK_OVERLAP):
|
| 26 |
+
chunks = []
|
| 27 |
+
for document in documents:
|
| 28 |
+
for i, text in enumerate(chunk_text(document["text"], chunk_size, overlap)):
|
| 29 |
+
chunks.append({"id": f"{document['source']}#{i}", "source": document["source"], "text": text})
|
| 30 |
+
return chunks
|
src/ragassistant/embeddings.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from .config import EMBEDDING_MODEL
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@lru_cache(maxsize=1)
|
| 9 |
+
def _model():
|
| 10 |
+
from sentence_transformers import SentenceTransformer
|
| 11 |
+
|
| 12 |
+
return SentenceTransformer(EMBEDDING_MODEL)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def embed(texts):
|
| 16 |
+
vectors = _model().encode(
|
| 17 |
+
list(texts), normalize_embeddings=True, convert_to_numpy=True
|
| 18 |
+
)
|
| 19 |
+
return np.asarray(vectors, dtype=np.float32)
|
src/ragassistant/evaluate.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .config import TOP_K
|
| 2 |
+
from .retriever import Retriever
|
| 3 |
+
|
| 4 |
+
EVAL_SET = [
|
| 5 |
+
{"question": "How much does the Pro plan cost?", "source": "pricing.md"},
|
| 6 |
+
{"question": "What encryption is used for data at rest?", "source": "security.md"},
|
| 7 |
+
{"question": "What is the API rate limit on the Free plan?", "source": "api.md"},
|
| 8 |
+
{"question": "How can I export my notes?", "source": "faq.md"},
|
| 9 |
+
{"question": "Does Nimbus Notes work offline?", "source": "faq.md"},
|
| 10 |
+
{"question": "Which platforms are supported?", "source": "overview.md"},
|
| 11 |
+
{"question": "How do I cancel my subscription?", "source": "pricing.md"},
|
| 12 |
+
{"question": "Is Nimbus Notes SOC 2 compliant?", "source": "security.md"},
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main():
|
| 17 |
+
retriever = Retriever()
|
| 18 |
+
hits = 0
|
| 19 |
+
for item in EVAL_SET:
|
| 20 |
+
results = retriever.retrieve(item["question"], TOP_K)
|
| 21 |
+
sources = [chunk["source"] for chunk, _ in results]
|
| 22 |
+
hit = item["source"] in sources
|
| 23 |
+
hits += hit
|
| 24 |
+
print(f"[{'ok ' if hit else 'MISS'}] {item['question']} -> {sources}")
|
| 25 |
+
recall = hits / len(EVAL_SET)
|
| 26 |
+
print(f"\nRetrieval recall@{TOP_K}: {recall:.2f} ({hits}/{len(EVAL_SET)})")
|
| 27 |
+
return recall
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
if __name__ == "__main__":
|
| 31 |
+
main()
|
src/ragassistant/generator.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .config import CHAT_MODEL
|
| 2 |
+
|
| 3 |
+
SYSTEM_PROMPT = (
|
| 4 |
+
"You answer questions using only the provided context. "
|
| 5 |
+
"If the answer is not contained in the context, say you don't know. "
|
| 6 |
+
"Cite the source filename in square brackets after each fact you use."
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def build_prompt(question, contexts):
|
| 11 |
+
context_block = "\n\n".join(f"[{chunk['source']}]\n{chunk['text']}" for chunk, _ in contexts)
|
| 12 |
+
return f"Context:\n{context_block}\n\nQuestion: {question}"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def generate_answer(question, contexts, model=CHAT_MODEL):
|
| 16 |
+
import anthropic
|
| 17 |
+
|
| 18 |
+
client = anthropic.Anthropic()
|
| 19 |
+
message = client.messages.create(
|
| 20 |
+
model=model,
|
| 21 |
+
max_tokens=512,
|
| 22 |
+
system=SYSTEM_PROMPT,
|
| 23 |
+
messages=[{"role": "user", "content": build_prompt(question, contexts)}],
|
| 24 |
+
)
|
| 25 |
+
return message.content[0].text
|
src/ragassistant/ingest.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .config import CORPUS_DIR, INDEX_PATH, META_PATH
|
| 2 |
+
from .documents import build_chunks, load_documents
|
| 3 |
+
from .embeddings import embed
|
| 4 |
+
from .vector_store import VectorStore
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def main():
|
| 8 |
+
documents = load_documents(CORPUS_DIR)
|
| 9 |
+
chunks = build_chunks(documents)
|
| 10 |
+
vectors = embed([chunk["text"] for chunk in chunks])
|
| 11 |
+
VectorStore(vectors, chunks).save(INDEX_PATH, META_PATH)
|
| 12 |
+
print(f"Indexed {len(chunks)} chunks from {len(documents)} documents -> {INDEX_PATH}")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
if __name__ == "__main__":
|
| 16 |
+
main()
|
src/ragassistant/rag.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .config import TOP_K
|
| 2 |
+
from .generator import generate_answer
|
| 3 |
+
from .retriever import Retriever
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class RAGPipeline:
|
| 7 |
+
def __init__(self):
|
| 8 |
+
self.retriever = Retriever()
|
| 9 |
+
|
| 10 |
+
def answer(self, question, top_k=TOP_K):
|
| 11 |
+
contexts = self.retriever.retrieve(question, top_k)
|
| 12 |
+
return generate_answer(question, contexts), contexts
|
src/ragassistant/retriever.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .config import INDEX_PATH, META_PATH, TOP_K
|
| 2 |
+
from .embeddings import embed
|
| 3 |
+
from .vector_store import VectorStore
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Retriever:
|
| 7 |
+
def __init__(self):
|
| 8 |
+
self.store = VectorStore.load(INDEX_PATH, META_PATH)
|
| 9 |
+
|
| 10 |
+
def retrieve(self, query, top_k=TOP_K):
|
| 11 |
+
query_vector = embed([query])[0]
|
| 12 |
+
return self.store.search(query_vector, top_k)
|
src/ragassistant/vector_store.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class VectorStore:
|
| 7 |
+
"""In-memory cosine-similarity store backed by normalized embeddings."""
|
| 8 |
+
|
| 9 |
+
def __init__(self, vectors=None, chunks=None):
|
| 10 |
+
self.vectors = vectors
|
| 11 |
+
self.chunks = chunks or []
|
| 12 |
+
|
| 13 |
+
def search(self, query_vector, top_k):
|
| 14 |
+
scores = self.vectors @ query_vector
|
| 15 |
+
order = np.argsort(scores)[::-1][:top_k]
|
| 16 |
+
return [(self.chunks[i], float(scores[i])) for i in order]
|
| 17 |
+
|
| 18 |
+
def save(self, index_path, meta_path):
|
| 19 |
+
index_path.parent.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
np.savez_compressed(index_path, vectors=self.vectors)
|
| 21 |
+
meta_path.write_text(json.dumps(self.chunks, indent=2))
|
| 22 |
+
|
| 23 |
+
@classmethod
|
| 24 |
+
def load(cls, index_path, meta_path):
|
| 25 |
+
vectors = np.load(index_path)["vectors"]
|
| 26 |
+
chunks = json.loads(meta_path.read_text())
|
| 27 |
+
return cls(vectors, chunks)
|
storage/chunks.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "api.md#0",
|
| 4 |
+
"source": "api.md",
|
| 5 |
+
"text": "# Nimbus Notes \u2014 Developer API\nNimbus Notes exposes a REST API for automating notes and notebooks.\n## Authentication\nAll requests are sent to `https://api.nimbusnotes.com/v1` and authenticated with\nan API key passed as a Bearer token in the `Authorization` header. API keys are\ncreated in Settings under Developer.\n## Endpoints\n- `/notes` \u2014 create, read, update and delete notes.\n- `/notebooks` \u2014 manage notebooks.\n- `/search` \u2014 full-text search across the workspace.\nResponses are JSON and lists use cursor-based pagination.\n## Rate limits"
|
| 6 |
+
},
|
| 7 |
+
{
|
| 8 |
+
"id": "api.md#1",
|
| 9 |
+
"source": "api.md",
|
| 10 |
+
"text": "earch across the workspace.\nResponses are JSON and lists use cursor-based pagination.\n## Rate limits - Free plan: 60 requests per minute.\n- Pro and Team plans: 600 requests per minute.\n## Webhooks and SDKs\nWebhooks can notify your server of events such as `note.created` and\n`note.updated`. Official SDKs are available for Python and JavaScript."
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"id": "faq.md#0",
|
| 14 |
+
"source": "faq.md",
|
| 15 |
+
"text": "# Nimbus Notes \u2014 Frequently Asked Questions\n## How do I export my notes?\nNotes can be exported from Settings > Export in Markdown, PDF or HTML format,\neither individually or per notebook.\n## Can I use Nimbus Notes offline?\nYes. The desktop and mobile apps cache your notes locally and synchronise\nautomatically the next time you are online. The web app requires a connection.\n## How do I cancel my subscription?\nGo to Settings > Billing > Cancel subscription. You keep access to paid features\nuntil the end of the current billing period, after which the account reverts to\nthe Free plan."
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"id": "faq.md#1",
|
| 19 |
+
"source": "faq.md",
|
| 20 |
+
"text": "tures\nuntil the end of the current billing period, after which the account reverts to\nthe Free plan. ## How do I delete my account?\nSettings > Account > Delete account. Your data is permanently purged 30 days\nafter deletion.\n## Which languages are supported?\nThe interface is available in 12 languages, including English, Spanish, French,\nGerman and Japanese."
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"id": "overview.md#0",
|
| 24 |
+
"source": "overview.md",
|
| 25 |
+
"text": "# Nimbus Notes \u2014 Product Overview\nNimbus Notes is a cloud-based note-taking application for individuals and teams.\nIt combines rich-text and Markdown editing with real-time collaboration, so\nseveral people can edit the same note at once.\n## Key features\n- Rich-text and Markdown editing with live preview.\n- Real-time collaboration and inline comments.\n- Offline mode on desktop and mobile, with automatic sync once back online.\n- Full-text search across all notebooks.\n- Organisation through notebooks, tags and nested pages.\n- Cross-platform clients for web, Windows, macOS, Linux, iOS and Android."
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"id": "overview.md#1",
|
| 29 |
+
"source": "overview.md",
|
| 30 |
+
"text": "ks, tags and nested pages.\n- Cross-platform clients for web, Windows, macOS, Linux, iOS and Android. ## Availability\nNimbus Notes launched in 2019 and is available in 12 interface languages. The\nservice is offered as a hosted SaaS product; an on-premise deployment is\navailable on the Enterprise plan."
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"id": "pricing.md#0",
|
| 34 |
+
"source": "pricing.md",
|
| 35 |
+
"text": "# Nimbus Notes \u2014 Pricing\nNimbus Notes offers four plans. All paid plans include a 14-day free trial of\nPro features, and annual billing saves roughly two months compared to monthly.\n## Free\n- Up to 3 notebooks and 1 GB of storage.\n- Core editing and search.\n- Community support only.\n## Pro \u2014 $8 per month (or $80 per year)\n- Unlimited notebooks and 50 GB of storage.\n- 30 days of version history.\n- Optional end-to-end encryption.\n- Priority email support.\n## Team \u2014 $15 per user per month"
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"id": "pricing.md#1",
|
| 39 |
+
"source": "pricing.md",
|
| 40 |
+
"text": "istory.\n- Optional end-to-end encryption.\n- Priority email support.\n## Team \u2014 $15 per user per month - Everything in Pro, plus shared workspaces and admin controls.\n- 200 GB of storage per user.\n- Single sign-on (SSO).\n## Enterprise \u2014 custom pricing\n- SAML SSO, audit logs and a dedicated account manager.\n- Optional on-premise deployment.\nSubscriptions can be cancelled at any time from Settings; access continues until\nthe end of the current billing period."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"id": "security.md#0",
|
| 44 |
+
"source": "security.md",
|
| 45 |
+
"text": "# Nimbus Notes \u2014 Security\nSecurity is handled in line with industry best practice and audited by external\nparties.\n## Encryption\n- Data is encrypted in transit with TLS 1.3.\n- Data is encrypted at rest with AES-256.\n- Optional end-to-end encryption is available on Pro and higher plans.\n## Compliance and access control\n- SOC 2 Type II certified and GDPR compliant.\n- Two-factor authentication via TOTP authenticator apps.\n- Data is stored in data centres located in the EU and the US, selectable per\n workspace.\n## Reliability"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"id": "security.md#1",
|
| 49 |
+
"source": "security.md",
|
| 50 |
+
"text": "a is stored in data centres located in the EU and the US, selectable per\n workspace.\n## Reliability - Encrypted backups run daily and are retained for 30 days.\n- The platform is penetration-tested annually by an independent firm."
|
| 51 |
+
}
|
| 52 |
+
]
|
storage/index.npz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9e9fb57d5990c2a91767e95da5ae1dba881245d202186e76cfea53a070cf51e3
|
| 3 |
+
size 14464
|