- added three iwm articles as test data
- added simple webview and FastAPI server
This commit is contained in:
parent
b1405f3b84
commit
3fd6b8dd0d
16
README.md
16
README.md
@ -4,12 +4,16 @@ Minimal RAG implementation with LangChain, Ollama, and FAISS.
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
Only 5 packages:
|
|
||||||
- `langchain` - Core framework
|
- `langchain` - Core framework
|
||||||
|
- `langchain-community` - Community integrations (loaders, vectorstores)
|
||||||
- `langchain-ollama` - Ollama integration
|
- `langchain-ollama` - Ollama integration
|
||||||
|
- `langchain-text-splitters` - Text splitting utilities
|
||||||
|
- `langchain-huggingface` - HuggingFace embeddings
|
||||||
- `faiss-cpu` - Vector search
|
- `faiss-cpu` - Vector search
|
||||||
- `sentence-transformers` - Embeddings
|
- `sentence-transformers` - Embeddings
|
||||||
- `pypdf` - PDF loading
|
- `pypdf` - PDF loading
|
||||||
|
- `fastapi` - Web server and API
|
||||||
|
- `uvicorn` - ASGI server
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@ -54,6 +58,16 @@ Run:
|
|||||||
python local_rag.py
|
python local_rag.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Chat GUI (FastAPI)
|
||||||
|
|
||||||
|
A simple web chat interface is included. Start the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn server:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open [http://localhost:8000](http://localhost:8000) in your browser. The chat view uses the same RAG system: your messages are answered using the vector store and Ollama. Ensure your vector store is populated (e.g. by running the document-add steps in `local_rag.py` once) and that Ollama is running.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. **Load documents** - PDFs or text files
|
1. **Load documents** - PDFs or text files
|
||||||
|
|||||||
BIN
data/dok1.pdf
Normal file
BIN
data/dok1.pdf
Normal file
Binary file not shown.
70594
data/dok2.PDF
Normal file
70594
data/dok2.PDF
Normal file
File diff suppressed because one or more lines are too long
5153
data/dok3.pdf
Normal file
5153
data/dok3.pdf
Normal file
File diff suppressed because one or more lines are too long
66
local_rag.py
66
local_rag.py
@ -5,14 +5,14 @@ Minimal dependencies, simple code
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from langchain_community.document_loaders import PyPDFLoader, TextLoader
|
from langchain_community.document_loaders import PyPDFLoader, TextLoader
|
||||||
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||||
from langchain_community.embeddings import HuggingFaceEmbeddings
|
from langchain_huggingface import HuggingFaceEmbeddings
|
||||||
from langchain_community.vectorstores import FAISS
|
from langchain_community.vectorstores import FAISS
|
||||||
from langchain_ollama import ChatOllama
|
from langchain_ollama import ChatOllama
|
||||||
|
|
||||||
|
|
||||||
class LocalRAG:
|
class LocalRAG:
|
||||||
def __init__(self, vectorstore_path="./vectorstore", ollama_model="llama2"):
|
def __init__(self, vectorstore_path="./vectorstore", ollama_model="mistral:7b"):
|
||||||
"""Initialize local RAG system"""
|
"""Initialize local RAG system"""
|
||||||
self.vectorstore_path = vectorstore_path
|
self.vectorstore_path = vectorstore_path
|
||||||
self.ollama_model = ollama_model
|
self.ollama_model = ollama_model
|
||||||
@ -97,6 +97,42 @@ class LocalRAG:
|
|||||||
self.vectorstore.save_local(self.vectorstore_path)
|
self.vectorstore.save_local(self.vectorstore_path)
|
||||||
print(f"Vector store saved to {self.vectorstore_path}")
|
print(f"Vector store saved to {self.vectorstore_path}")
|
||||||
|
|
||||||
|
def list_documents(self):
|
||||||
|
"""List all documents in the vector store"""
|
||||||
|
if self.vectorstore is None:
|
||||||
|
print("No documents in vector store.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get all documents from the vector store
|
||||||
|
# We'll retrieve a large number to get all documents
|
||||||
|
all_docs = self.vectorstore.similarity_search("", k=10000) # Large k to get all
|
||||||
|
|
||||||
|
# Extract unique document sources from metadata
|
||||||
|
documents = {}
|
||||||
|
for doc in all_docs:
|
||||||
|
source = doc.metadata.get('source', 'Unknown')
|
||||||
|
if source not in documents:
|
||||||
|
documents[source] = {
|
||||||
|
'source': source,
|
||||||
|
'chunks': 0,
|
||||||
|
'page': doc.metadata.get('page', None)
|
||||||
|
}
|
||||||
|
documents[source]['chunks'] += 1
|
||||||
|
|
||||||
|
# Convert to list and sort
|
||||||
|
doc_list = list(documents.values())
|
||||||
|
doc_list.sort(key=lambda x: x['source'])
|
||||||
|
|
||||||
|
print(f"\nDocuments in vector store ({len(doc_list)} unique documents):")
|
||||||
|
print("-" * 60)
|
||||||
|
for doc_info in doc_list:
|
||||||
|
print(f" - {doc_info['source']}")
|
||||||
|
print(f" Chunks: {doc_info['chunks']}")
|
||||||
|
if doc_info['page'] is not None:
|
||||||
|
print(f" Page: {doc_info['page']}")
|
||||||
|
|
||||||
|
return doc_list
|
||||||
|
|
||||||
def query(self, question, k=4):
|
def query(self, question, k=4):
|
||||||
"""Query the RAG system"""
|
"""Query the RAG system"""
|
||||||
if self.vectorstore is None:
|
if self.vectorstore is None:
|
||||||
@ -134,21 +170,25 @@ def main():
|
|||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
# Initialize
|
# Initialize
|
||||||
rag = LocalRAG(ollama_model="llama2")
|
rag = LocalRAG(ollama_model="mistral:7b")
|
||||||
|
|
||||||
# Add documents (uncomment and add your file paths)
|
# Add documents (uncomment and add your file paths)
|
||||||
rag.add_documents([
|
# rag.add_documents([
|
||||||
"diverses/local_rag/test1.pdf",
|
# "data/dok1.pdf",
|
||||||
"diverses/local_rag/test2.txt"
|
# "data/dok2.pdf",
|
||||||
])
|
# "data/dok3.pdf"
|
||||||
|
# ])
|
||||||
|
|
||||||
|
# List documents
|
||||||
|
rag.list_documents()
|
||||||
|
|
||||||
# Query
|
# Query
|
||||||
# question = "What is this document about?"
|
question = "What do the documents say about modality for perceived message perception?"
|
||||||
# answer = rag.query(question)
|
answer = rag.query(question)
|
||||||
# print(f"\nQuestion: {question}")
|
print(f"\nQuestion: {question}")
|
||||||
# print(f"Answer: {answer}")
|
print(f"Answer: {answer}")
|
||||||
|
|
||||||
print("\nSetup complete! Uncomment the code above to add documents and query.")
|
# print("\nSetup complete! Uncomment the code above to add documents and query.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
langchain
|
langchain
|
||||||
|
langchain-community
|
||||||
langchain-ollama
|
langchain-ollama
|
||||||
|
langchain-text-splitters
|
||||||
|
langchain-huggingface
|
||||||
faiss-cpu
|
faiss-cpu
|
||||||
sentence-transformers
|
sentence-transformers
|
||||||
pypdf
|
pypdf
|
||||||
|
numpy<2.0
|
||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
|||||||
60
server.py
Normal file
60
server.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
FastAPI server for Local RAG with chat GUI.
|
||||||
|
Run with: uvicorn server:app --reload
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from local_rag import LocalRAG
|
||||||
|
|
||||||
|
# Initialize RAG once at startup
|
||||||
|
VECTORSTORE_PATH = "./vectorstore"
|
||||||
|
OLLAMA_MODEL = "mistral:7b"
|
||||||
|
rag = LocalRAG(vectorstore_path=VECTORSTORE_PATH, ollama_model=OLLAMA_MODEL)
|
||||||
|
|
||||||
|
app = FastAPI(title="Local RAG Chat", version="1.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatResponse(BaseModel):
|
||||||
|
answer: str
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def chat_view():
|
||||||
|
"""Serve the chat GUI."""
|
||||||
|
html_path = Path(__file__).parent / "templates" / "chat.html"
|
||||||
|
if not html_path.exists():
|
||||||
|
raise HTTPException(status_code=500, detail="Chat template not found")
|
||||||
|
return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/chat", response_model=ChatResponse)
|
||||||
|
def chat(request: ChatRequest):
|
||||||
|
"""Handle a chat message and return the RAG answer."""
|
||||||
|
if not request.message or not request.message.strip():
|
||||||
|
return ChatResponse(answer="", error="Message cannot be empty")
|
||||||
|
try:
|
||||||
|
answer = rag.query(request.message.strip())
|
||||||
|
return ChatResponse(answer=answer)
|
||||||
|
except Exception as e:
|
||||||
|
return ChatResponse(answer="", error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
"""Health check and vector store status."""
|
||||||
|
has_docs = rag.vectorstore is not None
|
||||||
|
return {"status": "ok", "vectorstore_loaded": has_docs}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
209
templates/chat.html
Normal file
209
templates/chat.html
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Local RAG Chat</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: "SF Mono", "Consolas", "Monaco", monospace;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #0f0f12;
|
||||||
|
color: #e4e4e7;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #27272a;
|
||||||
|
background: #18181b;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
header p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #71717a;
|
||||||
|
}
|
||||||
|
#messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
max-width: 85%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.msg.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: #3f3f46;
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
.msg.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: #27272a;
|
||||||
|
border: 1px solid #3f3f46;
|
||||||
|
}
|
||||||
|
.msg.error {
|
||||||
|
background: #451a1a;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
.msg .label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #71717a;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#input-area {
|
||||||
|
padding: 1rem 1.5rem 1.5rem;
|
||||||
|
border-top: 1px solid #27272a;
|
||||||
|
background: #18181b;
|
||||||
|
}
|
||||||
|
#input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
#input {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 160px;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #e4e4e7;
|
||||||
|
background: #27272a;
|
||||||
|
border: 1px solid #3f3f46;
|
||||||
|
border-radius: 8px;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
#input::placeholder { color: #71717a; }
|
||||||
|
#input:focus { border-color: #52525b; }
|
||||||
|
#send {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #0f0f12;
|
||||||
|
background: #e4e4e7;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#send:hover { background: #fafafa; }
|
||||||
|
#send:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #71717a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Local RAG Chat</h1>
|
||||||
|
<p>Ask questions about your documents. Answers are generated from the vector store + Ollama.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="messages"></div>
|
||||||
|
|
||||||
|
<div id="input-area">
|
||||||
|
<div id="input-row">
|
||||||
|
<textarea id="input" rows="1" placeholder="Ask a question…" autofocus></textarea>
|
||||||
|
<button type="button" id="send">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const messagesEl = document.getElementById('messages');
|
||||||
|
const inputEl = document.getElementById('input');
|
||||||
|
const sendBtn = document.getElementById('send');
|
||||||
|
|
||||||
|
function appendMessage(role, text, isError = false) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'msg ' + (isError ? 'error' : role);
|
||||||
|
const label = role === 'user' ? 'You' : 'RAG';
|
||||||
|
div.innerHTML = '<span class="label">' + label + '</span>' + escapeHtml(text);
|
||||||
|
messagesEl.appendChild(div);
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = s;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(show) {
|
||||||
|
if (show) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'loading';
|
||||||
|
el.id = 'loading-dot';
|
||||||
|
el.textContent = 'Thinking…';
|
||||||
|
messagesEl.appendChild(el);
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
} else {
|
||||||
|
const el = document.getElementById('loading-dot');
|
||||||
|
if (el) el.remove();
|
||||||
|
}
|
||||||
|
sendBtn.disabled = show;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const text = inputEl.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
inputEl.value = '';
|
||||||
|
appendMessage('user', text);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: text })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setLoading(false);
|
||||||
|
if (data.error) {
|
||||||
|
appendMessage('assistant', data.error, true);
|
||||||
|
} else {
|
||||||
|
appendMessage('assistant', (data.answer || '(No response)').trim());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setLoading(false);
|
||||||
|
appendMessage('assistant', 'Request failed: ' + err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', send);
|
||||||
|
inputEl.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user