malaporte commited on
Commit
b095cd4
Β·
verified Β·
1 Parent(s): 72a78be

Upload folder using huggingface_hub

Browse files
Files changed (8) hide show
  1. .gitattributes +1 -0
  2. README.md +75 -7
  3. app.py +222 -0
  4. faiss_index/index.faiss +3 -0
  5. faiss_index/index.pkl +3 -0
  6. ingest.py +103 -0
  7. metadata.json +14 -0
  8. requirements.txt +17 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ faiss_index/index.faiss filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,81 @@
1
  ---
2
- title: Fecb Rag
3
- emoji: πŸƒ
4
- colorFrom: blue
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.14.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: FECB RAG Search
3
+ emoji: πŸ“š
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: "6.14.0"
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
  ---
12
 
13
+ # FECB RAG Search App
14
+
15
+ A RAG (Retrieval-Augmented Generation) application that lets you search and query a collection of PDF documents using semantic AI search, powered by Claude (Anthropic).
16
+
17
+ ## Project Structure
18
+
19
+ ```
20
+ FECB/
21
+ β”œβ”€β”€ app.py # Gradio web interface
22
+ β”œβ”€β”€ ingest.py # PDF ingestion & FAISS index builder
23
+ β”œβ”€β”€ requirements.txt
24
+ β”œβ”€β”€ pdfs/ # Drop your PDF files here
25
+ β”œβ”€β”€ faiss_index/ # Generated by ingest.py (do not edit)
26
+ └── metadata.json # Generated by ingest.py
27
+ ```
28
+
29
+ ## Setup
30
+
31
+ ### 1. Install dependencies
32
+
33
+ ```bash
34
+ pip install -r requirements.txt
35
+ ```
36
+
37
+ ### 2. Add your PDFs
38
+
39
+ Copy your PDF files into the `pdfs/` folder (subdirectories are supported).
40
+
41
+ ### 3. Build the vector index
42
+
43
+ ```bash
44
+ python ingest.py
45
+ ```
46
+
47
+ Options:
48
+ ```
49
+ --pdf-dir Path to PDF folder (default: pdfs)
50
+ --index-dir Where to save the FAISS index (default: faiss_index)
51
+ --chunk-size Characters per chunk (default: 800)
52
+ --chunk-overlap Overlap between chunks (default: 100)
53
+ ```
54
+
55
+ ### 4. Set your Anthropic API key
56
+
57
+ ```bash
58
+ export ANTHROPIC_API_KEY=sk-ant-...
59
+ ```
60
+
61
+ ### 5. Run the app
62
+
63
+ ```bash
64
+ python app.py
65
+ ```
66
+
67
+ Open http://localhost:7860 in your browser.
68
+
69
+ ## Configuration
70
+
71
+ All settings can be overridden with environment variables:
72
+
73
+ | Variable | Default | Description |
74
+ |--------------------|--------------------------|----------------------------------|
75
+ | `ANTHROPIC_API_KEY`| β€” | **Required.** Your Anthropic key |
76
+ | `CLAUDE_MODEL` | `claude-sonnet-4-6` | Claude model to use |
77
+ | `EMBED_MODEL` | `BAAI/bge-small-en-v1.5` | HuggingFace embedding model |
78
+ | `TOP_K` | `5` | Number of documents to retrieve |
79
+ | `INDEX_DIR` | `faiss_index` | FAISS index directory |
80
+ | `META_FILE` | `metadata.json` | Metadata file path |
81
+ | `PDF_DIR` | `pdfs` | PDF source directory (ingest) |
app.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py β€” FECB RAG Search Application
3
+
4
+ Loads a pre-built FAISS index (produced by ingest.py) and provides a
5
+ Gradio interface for semantic search and AI-assisted Q&A over your PDF documents.
6
+
7
+ Environment variables:
8
+ ANTHROPIC_API_KEY β€” Anthropic API key (required)
9
+ CLAUDE_MODEL β€” Claude model ID (default: claude-sonnet-4-6)
10
+ EMBED_MODEL β€” Embedding model (default: BAAI/bge-small-en-v1.5)
11
+ TOP_K β€” Max documents to retrieve (default: 5)
12
+ INDEX_DIR β€” Path to FAISS index (default: faiss_index)
13
+ META_FILE β€” Path to metadata JSON (default: metadata.json)
14
+
15
+ Run:
16
+ python app.py
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ from pathlib import Path
23
+
24
+ import anthropic
25
+ import gradio as gr
26
+ from langchain_community.vectorstores import FAISS
27
+ from langchain_huggingface import HuggingFaceEmbeddings
28
+
29
+ # ── Config ────────────────────────────────────────────────────────────────────
30
+ EMBED_MODEL = os.getenv("EMBED_MODEL", "BAAI/bge-small-en-v1.5")
31
+ CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6")
32
+ API_KEY = os.getenv("ANTHROPIC_API_KEY")
33
+ TOP_K = int(os.getenv("TOP_K", "5"))
34
+ INDEX_DIR = Path(os.getenv("INDEX_DIR", "faiss_index"))
35
+ META_FILE = Path(os.getenv("META_FILE", "metadata.json"))
36
+
37
+ SYSTEM_PROMPT = (
38
+ "You are a knowledgeable research assistant. You help users find relevant "
39
+ "information from a document collection and synthesize key findings. "
40
+ "When answering, cite the specific document(s) by their bracketed number [N]. "
41
+ "Be concise and precise. If the context doesn't contain enough information "
42
+ "to answer fully, say so clearly."
43
+ )
44
+
45
+ # ── Load resources ────────────────────────────────────────────────────────────
46
+ print(f"Loading embedding model: {EMBED_MODEL}")
47
+ _embeddings = HuggingFaceEmbeddings(
48
+ model_name=EMBED_MODEL,
49
+ model_kwargs={"device": "cpu"},
50
+ encode_kwargs={"normalize_embeddings": True},
51
+ )
52
+
53
+ if not INDEX_DIR.exists():
54
+ raise FileNotFoundError(
55
+ f"FAISS index not found at '{INDEX_DIR}'. "
56
+ "Run 'python ingest.py' first to build the index from your PDFs."
57
+ )
58
+
59
+ print(f"Loading FAISS index from: {INDEX_DIR}")
60
+ _vectorstore = FAISS.load_local(
61
+ str(INDEX_DIR), _embeddings, allow_dangerous_deserialization=True
62
+ )
63
+
64
+ _metadata: dict[str, dict] = {}
65
+ if META_FILE.exists():
66
+ print(f"Loading metadata from: {META_FILE}")
67
+ with open(META_FILE, encoding="utf-8") as f:
68
+ for record in json.load(f):
69
+ _metadata[record["doc_id"]] = record
70
+ print(f" Loaded metadata for {len(_metadata)} documents")
71
+ else:
72
+ print(f" [WARN] {META_FILE} not found β€” document names will be inferred from IDs")
73
+
74
+ if not API_KEY:
75
+ raise EnvironmentError("ANTHROPIC_API_KEY is not set. Export it before running.")
76
+
77
+ _client = anthropic.Anthropic(api_key=API_KEY)
78
+ print(f"Claude model: {CLAUDE_MODEL}")
79
+ print("Ready.\n")
80
+
81
+
82
+ # ── RAG helpers ───────────────────────────────────────────────────────────────
83
+
84
+ def retrieve(query: str, n_docs: int) -> list[tuple]:
85
+ """Return up to n_docs unique (doc, score) pairs, deduplicated by doc_id."""
86
+ raw = _vectorstore.similarity_search_with_score(query, k=n_docs * 4)
87
+ seen: dict[str, tuple] = {}
88
+ for doc, score in raw:
89
+ doc_id = doc.metadata.get("doc_id", "")
90
+ if doc_id not in seen:
91
+ seen[doc_id] = (doc, score)
92
+ if len(seen) >= n_docs:
93
+ break
94
+ return sorted(seen.values(), key=lambda x: x[1])
95
+
96
+
97
+ def build_context(hits: list[tuple]) -> str:
98
+ parts = []
99
+ for i, (doc, _) in enumerate(hits, 1):
100
+ doc_id = doc.metadata.get("doc_id", f"doc_{i}")
101
+ filename = doc.metadata.get("filename", f"{doc_id}.pdf")
102
+ excerpt = doc.page_content.replace("\n", " ").strip()[:600]
103
+ parts.append(f"[{i}] {filename} (ID: {doc_id})\n{excerpt}")
104
+ return "\n\n---\n\n".join(parts)
105
+
106
+
107
+ def ask_claude(query: str, context: str) -> str:
108
+ user_content = (
109
+ f"Using the document excerpts below, answer the following question. "
110
+ f"Cite documents by their bracketed number.\n\n"
111
+ f"Question: {query}\n\nContext:\n{context}"
112
+ )
113
+ try:
114
+ message = _client.messages.create(
115
+ model=CLAUDE_MODEL,
116
+ max_tokens=800,
117
+ system=SYSTEM_PROMPT,
118
+ messages=[{"role": "user", "content": user_content}],
119
+ )
120
+ return message.content[0].text.strip()
121
+ except anthropic.APIError as exc:
122
+ return (
123
+ f"Could not reach Claude ({exc}).\n\n"
124
+ "Check that **ANTHROPIC_API_KEY** is set and valid."
125
+ )
126
+
127
+
128
+ def cosine_to_pct(score: float) -> str:
129
+ """Convert FAISS L2 distance (normalised embeddings) to 0–100% relevance."""
130
+ pct = (1.0 - min(max(score, 0.0), 2.0) / 2.0) * 100
131
+ return f"{pct:.1f}%"
132
+
133
+
134
+ # ── Main search function ──────────────────────────────────────────────────────
135
+
136
+ def rag_search(query: str, n_docs: int) -> tuple[str, str]:
137
+ query = query.strip()
138
+ if not query:
139
+ return "Please enter a question or keyword.", ""
140
+
141
+ hits = retrieve(query, n_docs)
142
+ if not hits:
143
+ return "No relevant documents found. Try different keywords.", ""
144
+
145
+ context = build_context(hits)
146
+ answer = ask_claude(query, context)
147
+
148
+ cards = []
149
+ for i, (doc, score) in enumerate(hits, 1):
150
+ doc_id = doc.metadata.get("doc_id", f"doc_{i}")
151
+ filename = doc.metadata.get("filename", f"{doc_id}.pdf")
152
+ rel = cosine_to_pct(score)
153
+ snippet = doc.page_content.replace("\n", " ").strip()[:350]
154
+
155
+ cards.append(
156
+ f"### [{i}] {filename}\n"
157
+ f"**Relevance:** {rel} \n"
158
+ f"**ID:** {doc_id} \n"
159
+ f"> {snippet}…"
160
+ )
161
+
162
+ return answer, "\n\n---\n\n".join(cards)
163
+
164
+
165
+ # ── Gradio UI ─────────────────────────────────────────────────────────────────
166
+
167
+ with gr.Blocks(title="FECB Document Search") as demo:
168
+
169
+ gr.Markdown(
170
+ """
171
+ # FECB Document Search β€” AI-Powered RAG
172
+
173
+ Search your document collection using semantic AI search.
174
+ Ask a question or enter keywords; the app retrieves the most relevant
175
+ documents and generates a synthesised answer with citations.
176
+
177
+ > **Powered by** `BAAI/bge-small-en-v1.5` embeddings Β· Claude via Anthropic API
178
+ """
179
+ )
180
+
181
+ with gr.Row():
182
+ with gr.Column(scale=5):
183
+ query_box = gr.Textbox(
184
+ label="Question or keywords",
185
+ placeholder="e.g. 'What are the main findings about X?'",
186
+ lines=2,
187
+ elem_id="query-box",
188
+ )
189
+ with gr.Column(scale=1, min_width=160):
190
+ n_slider = gr.Slider(
191
+ minimum=3, maximum=10, value=TOP_K, step=1,
192
+ label="Documents to return",
193
+ )
194
+
195
+ search_btn = gr.Button("Search", variant="primary", size="lg")
196
+
197
+ gr.Markdown("---")
198
+
199
+ with gr.Row():
200
+ with gr.Column(scale=2):
201
+ gr.Markdown("### AI Answer")
202
+ answer_md = gr.Markdown(value="*Results will appear here after searching.*")
203
+
204
+ with gr.Column(scale=3):
205
+ gr.Markdown("### Relevant Documents")
206
+ papers_md = gr.Markdown(value="")
207
+
208
+ search_btn.click(rag_search, [query_box, n_slider], [answer_md, papers_md])
209
+ query_box.submit(rag_search, [query_box, n_slider], [answer_md, papers_md])
210
+
211
+
212
+ if __name__ == "__main__":
213
+ demo.launch(
214
+ server_name="0.0.0.0",
215
+ server_port=7860,
216
+ share=False,
217
+ theme=gr.themes.Soft(primary_hue="blue", font=gr.themes.GoogleFont("Inter")),
218
+ css="""
219
+ .gradio-container { max-width: 1100px; margin: auto; }
220
+ #query-box textarea { font-size: 16px; }
221
+ """,
222
+ )
faiss_index/index.faiss ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f844d7a2bbd7893e5ff263ff16e66485d29279d6917e9e0a67af82ffce17eed3
3
+ size 457773
faiss_index/index.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:94c160696b5d439ab03fdbb7872d37a1ed3a7a3ba53ad0a64ba582c6a3091a56
3
+ size 251399
ingest.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ingest.py β€” Build a FAISS vector index from a folder of PDFs.
3
+
4
+ Usage:
5
+ python ingest.py [--pdf-dir pdfs] [--index-dir faiss_index] [--chunk-size 800] [--chunk-overlap 100]
6
+
7
+ Environment variables:
8
+ EMBED_MODEL β€” Embedding model (default: BAAI/bge-small-en-v1.5)
9
+ PDF_DIR β€” Folder containing PDF files (default: pdfs)
10
+ INDEX_DIR β€” Where to save the FAISS index (default: faiss_index)
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+
18
+ import fitz # PyMuPDF
19
+ from langchain_community.vectorstores import FAISS
20
+ from langchain_huggingface import HuggingFaceEmbeddings
21
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
22
+ from langchain_core.documents import Document
23
+ from tqdm import tqdm
24
+
25
+ EMBED_MODEL = os.getenv("EMBED_MODEL", "BAAI/bge-small-en-v1.5")
26
+ PDF_DIR = Path(os.getenv("PDF_DIR", "pdfs"))
27
+ INDEX_DIR = Path(os.getenv("INDEX_DIR", "faiss_index"))
28
+ META_FILE = Path("metadata.json")
29
+
30
+
31
+ def extract_text(pdf_path: Path) -> str:
32
+ doc = fitz.open(str(pdf_path))
33
+ return "\n".join(page.get_text() for page in doc)
34
+
35
+
36
+ def load_pdfs(pdf_dir: Path) -> list[Document]:
37
+ pdfs = sorted(pdf_dir.glob("**/*.pdf"))
38
+ if not pdfs:
39
+ raise FileNotFoundError(f"No PDF files found in {pdf_dir}")
40
+ print(f"Found {len(pdfs)} PDF(s) in {pdf_dir}")
41
+
42
+ docs = []
43
+ metadata_records = []
44
+ for pdf_path in tqdm(pdfs, desc="Reading PDFs"):
45
+ text = extract_text(pdf_path)
46
+ if not text.strip():
47
+ print(f" [WARN] No text extracted from {pdf_path.name}, skipping")
48
+ continue
49
+ doc_id = pdf_path.stem
50
+ docs.append(Document(
51
+ page_content=text,
52
+ metadata={"doc_id": doc_id, "filename": pdf_path.name, "source": str(pdf_path)},
53
+ ))
54
+ metadata_records.append({"doc_id": doc_id, "filename": pdf_path.name})
55
+
56
+ with open(META_FILE, "w", encoding="utf-8") as f:
57
+ json.dump(metadata_records, f, ensure_ascii=False, indent=2)
58
+ print(f"Saved metadata for {len(metadata_records)} documents β†’ {META_FILE}")
59
+ return docs
60
+
61
+
62
+ def chunk_documents(docs: list[Document], chunk_size: int, chunk_overlap: int) -> list[Document]:
63
+ splitter = RecursiveCharacterTextSplitter(
64
+ chunk_size=chunk_size,
65
+ chunk_overlap=chunk_overlap,
66
+ separators=["\n\n", "\n", ". ", " ", ""],
67
+ )
68
+ chunks = splitter.split_documents(docs)
69
+ print(f"Split into {len(chunks)} chunks (chunk_size={chunk_size}, overlap={chunk_overlap})")
70
+ return chunks
71
+
72
+
73
+ def build_index(chunks: list[Document], embeddings: HuggingFaceEmbeddings, index_dir: Path) -> None:
74
+ print(f"Building FAISS index with {EMBED_MODEL}...")
75
+ vectorstore = FAISS.from_documents(chunks, embeddings)
76
+ index_dir.mkdir(parents=True, exist_ok=True)
77
+ vectorstore.save_local(str(index_dir))
78
+ print(f"FAISS index saved β†’ {index_dir}/")
79
+
80
+
81
+ def main():
82
+ parser = argparse.ArgumentParser(description="Ingest PDFs into a FAISS vector index")
83
+ parser.add_argument("--pdf-dir", type=Path, default=PDF_DIR)
84
+ parser.add_argument("--index-dir", type=Path, default=INDEX_DIR)
85
+ parser.add_argument("--chunk-size", type=int, default=800)
86
+ parser.add_argument("--chunk-overlap",type=int, default=100)
87
+ args = parser.parse_args()
88
+
89
+ print(f"Loading embedding model: {EMBED_MODEL}")
90
+ embeddings = HuggingFaceEmbeddings(
91
+ model_name=EMBED_MODEL,
92
+ model_kwargs={"device": "cpu"},
93
+ encode_kwargs={"normalize_embeddings": True},
94
+ )
95
+
96
+ docs = load_pdfs(args.pdf_dir)
97
+ chunks = chunk_documents(docs, args.chunk_size, args.chunk_overlap)
98
+ build_index(chunks, embeddings, args.index_dir)
99
+ print("\nDone! You can now run: python app.py")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
metadata.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "doc_id": "Bene et al 2026 UPF consumption in Vietnam",
4
+ "filename": "Bene et al 2026 UPF consumption in Vietnam.pdf"
5
+ },
6
+ {
7
+ "doc_id": "YoshiokaVargas_etal_2026",
8
+ "filename": "YoshiokaVargas_etal_2026.pdf"
9
+ },
10
+ {
11
+ "doc_id": "agriculture-16-01013-v2",
12
+ "filename": "agriculture-16-01013-v2.pdf"
13
+ }
14
+ ]
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core RAG dependencies
2
+ pymupdf>=1.24.0
3
+ langchain>=0.2.0
4
+ langchain-community>=0.2.0
5
+ langchain-huggingface>=0.0.3
6
+ langchain-text-splitters>=0.2.0
7
+ faiss-cpu>=1.8.0
8
+ sentence-transformers>=3.0.0
9
+
10
+ # LLM
11
+ anthropic>=0.30.0
12
+
13
+ # UI
14
+ gradio>=6.9.0
15
+
16
+ # Utilities
17
+ tqdm>=4.66.0