Skip to content

Commit 5b6637a

Browse files
profotoce59loiclu7romainVanhee
authored
fastapi (#14)
* fastapi * remove unused files * merge concurrent code * petits correctifs --------- Co-authored-by: loiclu7 <loic.lu7@gmail.com> Co-authored-by: Romain Vanhee <romain.vanhee@yrycom.com>
1 parent 90f7241 commit 5b6637a

9 files changed

Lines changed: 259 additions & 94 deletions

File tree

Dockerfile.api

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ ENV PYTHONUNBUFFERED=1
88
COPY requirements.txt /app/requirements.txt
99
RUN pip install --no-cache-dir -r /app/requirements.txt
1010

11-
COPY app /app/app
11+
COPY app /app/app
1212
COPY vector-store /app/vector-store
1313

1414
EXPOSE 8000

RAG.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import os
2+
import time
3+
import uuid
4+
from pathlib import Path
5+
from typing import List, Optional
6+
from typing_extensions import TypedDict
7+
8+
from dotenv import load_dotenv
9+
from fastapi import FastAPI
10+
from pydantic import BaseModel
11+
from tqdm import tqdm
12+
13+
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
14+
from langchain_chroma import Chroma
15+
from langchain_community.document_loaders import PyMuPDFLoader, Docx2txtLoader, TextLoader
16+
from langchain_text_splitters import RecursiveCharacterTextSplitter
17+
from langchain_core.prompts import ChatPromptTemplate
18+
from langchain_core.documents import Document
19+
from langgraph.graph import START, StateGraph
20+
21+
load_dotenv()
22+
LLM_MODEL = os.getenv("MODEL_LLM")
23+
EMB_MODEL = os.getenv("MODEL_EMBEDDING")
24+
DOCS_DIR = 'exports'
25+
PERSIST_DIR = 'vector-store'
26+
27+
class State(TypedDict):
28+
question: str
29+
context: List[Document]
30+
answer: str
31+
32+
class RAGCore:
33+
def __init__(self):
34+
self.embeddings = OpenAIEmbeddings(model=EMB_MODEL)
35+
self.llm = ChatOpenAI(model=LLM_MODEL, temperature=0.2)
36+
self.vector_store = Chroma(
37+
collection_name="collection_test",
38+
embedding_function=self.embeddings,
39+
persist_directory=PERSIST_DIR,
40+
)
41+
self.graph = self._build_graph()
42+
43+
def _build_graph(self):
44+
prompt = ChatPromptTemplate.from_template(
45+
"You are an assistant for question-answering tasks. Use the following context to answer.\n"
46+
"Context: {context}\nQuestion: {question}\nAnswer:"
47+
)
48+
49+
def retrieve(state: State):
50+
# Optimized search: fetch top 5 relevant chunks
51+
docs = self.vector_store.similarity_search(state["question"], k=5)
52+
return {"context": docs}
53+
54+
def generate(state: State):
55+
docs_content = "\n\n".join(doc.page_content for doc in state["context"])
56+
sources = list(set([os.path.basename(doc.metadata.get('source', 'Unknown')) for doc in state["context"]]))
57+
messages = prompt.invoke({"question": state["question"], "context": docs_content})
58+
response = self.llm.invoke(messages)
59+
source_text = "\n\n**Sources:**\n- " + "\n- ".join(sources)
60+
final_answer = response.content + source_text
61+
return {"answer": final_answer}
62+
63+
workflow = StateGraph(State)
64+
workflow.add_node("retrieve", retrieve)
65+
workflow.add_node("generate", generate)
66+
workflow.add_edge(START, "retrieve")
67+
workflow.add_edge("retrieve", "generate")
68+
return workflow.compile()
69+
70+
def query(self, text: str):
71+
return self.graph.invoke({"question": text})
72+
73+
74+
app = FastAPI(title="AnythingLLM Custom Bridge")
75+
engine = RAGCore()
76+
77+
class ChatMessage(BaseModel):
78+
role: str
79+
content: str
80+
81+
class ChatCompletionRequest(BaseModel):
82+
messages: List[ChatMessage]
83+
model: Optional[str] = "custom-rag"
84+
85+
@app.post("/v1/chat/completions")
86+
async def chat_endpoint(request: ChatCompletionRequest):
87+
user_msg = request.messages[-1].content
88+
result = engine.query(user_msg)
89+
90+
return {
91+
"id": f"chatcmpl-{uuid.uuid4()}",
92+
"object": "chat.completion",
93+
"created": int(time.time()),
94+
"model": request.model,
95+
"choices": [{
96+
"message": {"role": "assistant", "content": result["answer"]},
97+
"finish_reason": "stop",
98+
"index": 0
99+
}]
100+
}
101+
102+
def run_ingestion():
103+
if not os.path.exists(DOCS_DIR): return
104+
105+
paths = list(Path(DOCS_DIR).rglob("*"))
106+
new_docs = []
107+
for p in tqdm(paths, desc="Processing files"):
108+
if p.suffix.lower() == ".pdf": new_docs.extend(PyMuPDFLoader(str(p)).load())
109+
elif p.suffix.lower() == ".docx": new_docs.extend(Docx2txtLoader(str(p)).load())
110+
elif p.suffix.lower() in {".txt", ".md"}: new_docs.extend(TextLoader(str(p)).load())
111+
112+
if new_docs:
113+
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=150)
114+
splits = splitter.split_documents(new_docs)
115+
engine.vector_store.add_documents(splits)
116+
print(f"Successfully indexed {len(splits)} chunks.")
117+
118+
if __name__ == "__main__":
119+
import uvicorn
120+
run_ingestion()
121+
uvicorn.run(app, host="0.0.0.0", port=8000)

app/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
from fastapi.responses import StreamingResponse
1010
from pydantic import BaseModel
1111

12+
from app.rag import RAGCore
13+
1214
BACKEND_MODEL_ID = os.getenv("BACKEND_MODEL_ID", "chatbot-rag")
15+
engine = RAGCore()
1316

1417

1518
class ChatMessage(BaseModel):
@@ -47,7 +50,8 @@ def chat_completions(request: ChatCompletionRequest):
4750
if not request.messages:
4851
raise HTTPException(status_code=400, detail="messages is required")
4952

50-
answer = f"Reponse bidon API."
53+
user_msg = request.messages[-1].content
54+
answer = engine.query(user_msg)
5155
created = int(time.time())
5256
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
5357
model_id = request.model or BACKEND_MODEL_ID

app/rag.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import os
2+
from typing import List
3+
from typing_extensions import TypedDict
4+
5+
from dotenv import load_dotenv
6+
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
7+
from langchain_chroma import Chroma
8+
from langchain_core.prompts import ChatPromptTemplate
9+
from langchain_core.documents import Document
10+
from langgraph.graph import START, StateGraph
11+
12+
load_dotenv()
13+
LLM_MODEL = os.getenv("MODEL_LLM")
14+
EMB_MODEL = os.getenv("MODEL_EMBEDDING")
15+
PERSIST_DIR = os.getenv("VECTOR_STORE_DIR", "vector-store")
16+
COLLECTION_NAME = os.getenv("COLLECTION_NAME", "collection_test")
17+
18+
19+
class State(TypedDict):
20+
question: str
21+
context: List[Document]
22+
answer: str
23+
24+
25+
class RAGCore:
26+
def __init__(self):
27+
self.embeddings = OpenAIEmbeddings(model=EMB_MODEL)
28+
self.llm = ChatOpenAI(model=LLM_MODEL, temperature=0.2)
29+
self.vector_store = Chroma(
30+
collection_name=COLLECTION_NAME,
31+
embedding_function=self.embeddings,
32+
persist_directory=PERSIST_DIR,
33+
)
34+
self.graph = self._build_graph()
35+
36+
def _build_graph(self):
37+
prompt = ChatPromptTemplate.from_template(
38+
"You are an assistant for question-answering tasks. Use the following context to answer.\n"
39+
"Context: {context}\nQuestion: {question}\nAnswer:"
40+
)
41+
42+
def retrieve(state: State):
43+
docs = self.vector_store.similarity_search(state["question"], k=5)
44+
return {"context": docs}
45+
46+
def generate(state: State):
47+
docs_content = "\n\n".join(doc.page_content for doc in state["context"])
48+
sources = list(set([os.path.basename(doc.metadata.get("source", "Unknown")) for doc in state["context"]]))
49+
messages = prompt.invoke({"question": state["question"], "context": docs_content})
50+
response = self.llm.invoke(messages)
51+
source_text = "\n\n**Sources:**\n- " + "\n- ".join(sources)
52+
return {"answer": response.content + source_text}
53+
54+
workflow = StateGraph(State)
55+
workflow.add_node("retrieve", retrieve)
56+
workflow.add_node("generate", generate)
57+
workflow.add_edge(START, "retrieve")
58+
workflow.add_edge("retrieve", "generate")
59+
return workflow.compile()
60+
61+
def query(self, text: str) -> str:
62+
result = self.graph.invoke({"question": text})
63+
return result["answer"]

reload_job.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@ docker compose -f "$COMPOSE_FILE" run --rm --no-deps --build \
1919
-v "$PROJECT_DIR:/work" \
2020
-w /work \
2121
chatbot-api \
22-
sh -lc "python export_pages.py && python reload_vector_store.py"
22+
sh -lc "python export_pages.py && python vector_store.py"
2323
echo "[reindex] done"

reload_vector_store.py

Lines changed: 0 additions & 89 deletions
This file was deleted.

requirements.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
python-dotenv>=1.0.0
2-
fastapi>=0.115.0
3-
uvicorn[standard]>=0.30.0
42
langchain-openai
53
langchain_chroma
64
langchain-text-splitters
@@ -9,3 +7,7 @@ langgraph
97
pathlib
108
tqdm
119
pypdf
10+
docx2txt
11+
pymupdf
12+
fastapi
13+
uvicorn

vector-store/.gitkeep

Whitespace-only changes.

vector_store.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
2+
from dotenv import load_dotenv
3+
import os
4+
from pathlib import Path
5+
# LangChain & LangGraph Imports
6+
from langchain_openai import OpenAIEmbeddings
7+
from langchain_chroma import Chroma
8+
from langchain_community.document_loaders import PyMuPDFLoader, Docx2txtLoader, TextLoader
9+
from langchain_text_splitters import RecursiveCharacterTextSplitter
10+
11+
# Configuration & Environment
12+
load_dotenv()
13+
# LLM_MODEL = os.getenv("MODEL_LLM")
14+
EMB_MODEL = os.getenv("MODEL_EMBEDDING")
15+
DOCS_DIR = 'exports'
16+
PERSIST_DIR = 'vector-store'
17+
18+
19+
20+
def ingest():
21+
if not os.path.exists(DOCS_DIR):
22+
print(f"Error: {DOCS_DIR} folder not found.")
23+
return
24+
25+
# Document Loading Logic
26+
print("--- Loading Documents ---")
27+
paths = list(Path(DOCS_DIR).rglob("*"))
28+
raw_docs = []
29+
30+
for p in paths:
31+
try:
32+
if p.suffix.lower() == ".pdf":
33+
loader = PyMuPDFLoader(str(p))
34+
raw_docs.extend(loader.load())
35+
elif p.suffix.lower() == ".docx":
36+
raw_docs.extend(Docx2txtLoader(str(p)).load())
37+
elif p.suffix.lower() in {".txt", ".md"}:
38+
raw_docs.extend(TextLoader(str(p), encoding="utf-8").load())
39+
except Exception as e:
40+
print(f"Skipping {p.name} due to error: {e}")
41+
42+
if not raw_docs:
43+
print("No documents found to index.")
44+
return
45+
46+
print(f"Splitting {len(raw_docs)} documents...")
47+
splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=120)
48+
all_splits = splitter.split_documents(raw_docs)
49+
50+
print(f"Generating embeddings and saving to {PERSIST_DIR}...")
51+
embeddings = OpenAIEmbeddings(model=EMB_MODEL)
52+
53+
# Create the vector store on disk
54+
Chroma.from_documents(
55+
documents=all_splits,
56+
embedding=embeddings,
57+
persist_directory=PERSIST_DIR,
58+
collection_name="collection_test"
59+
)
60+
print("Indexing complete!")
61+
62+
if __name__ == "__main__":
63+
ingest()
64+

0 commit comments

Comments
 (0)