delcenjo commited on
Commit
b02371b
·
verified ·
1 Parent(s): b2a8057

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,13 +1,19 @@
1
  ---
2
- title: Rag Document Qa
3
- emoji: 🌖
4
- colorFrom: blue
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
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