imtrt004 commited on
Commit
deea70e
·
1 Parent(s): 98e3f05

feat: self-hosted Qwen2.5-1.5B-Instruct via transformers — no external API, no compilation

Browse files
Files changed (6) hide show
  1. .env.example +5 -6
  2. Dockerfile +6 -5
  3. app.py +10 -9
  4. generation/llm.py +36 -12
  5. generation/quiz.py +20 -8
  6. model/loader.py +47 -21
.env.example CHANGED
@@ -1,12 +1,11 @@
1
  # hf-backend HuggingFace Space environment variables
2
- # Set these in your HF Space settings -> Variables and Secrets
3
 
4
  SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
5
  # New projects (Nov 2025+): use your Secret key -> sb_secret_...
6
  SUPABASE_KEY=sb_secret_your_secret_key_here
7
 
8
- # HF_TOKEN is automatically injected by HF Spaces -- no manual setup needed.
9
- # The app uses the free HF Serverless Inference API (InferenceClient).
10
- # Default LLM: mistralai/Mistral-7B-Instruct-v0.3
11
- # To use a different model, add this variable:
12
- # HF_LLM_MODEL=HuggingFaceH4/zephyr-7b-beta
 
1
  # hf-backend HuggingFace Space environment variables
2
+ # Set these in HF Space -> Settings -> Variables and Secrets
3
 
4
  SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
5
  # New projects (Nov 2025+): use your Secret key -> sb_secret_...
6
  SUPABASE_KEY=sb_secret_your_secret_key_here
7
 
8
+ # Optional: override the default self-hosted LLM model
9
+ # Default: Qwen/Qwen2.5-1.5B-Instruct (~3 GB, ~5-10 tok/s on 2 vCPUs)
10
+ # Faster/smaller: Qwen/Qwen2.5-0.5B-Instruct (~1 GB, ~20 tok/s)
11
+ # LLM_MODEL=Qwen/Qwen2.5-1.5B-Instruct
 
Dockerfile CHANGED
@@ -2,17 +2,18 @@ FROM python:3.12-slim
2
 
3
  WORKDIR /app
4
 
5
- # git is needed for huggingface_hub operations.
6
- # No build-essential/cmake needed -- no local LLM compilation.
7
- # Inference runs on HF's free serverless API via InferenceClient.
8
  RUN apt-get update && apt-get install -y git \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
  COPY requirements.txt .
12
 
13
  # -- Step 1: CPU-only PyTorch ------------------------------------------------
14
- # sentence-transformers depends on torch. Pre-installing the CPU wheel (~190 MB)
15
- # prevents pip from resolving the default CUDA bundle (~3.5 GB).
 
16
  RUN pip install torch --index-url https://download.pytorch.org/whl/cpu \
17
  --no-cache-dir
18
 
 
2
 
3
  WORKDIR /app
4
 
5
+ # git is needed for huggingface_hub model downloads.
6
+ # No cmake/build-essential needed -- no C++ compilation.
7
+ # LLM runs locally via transformers (Qwen2.5-1.5B-Instruct, ~3 GB bfloat16).
8
  RUN apt-get update && apt-get install -y git \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
  COPY requirements.txt .
12
 
13
  # -- Step 1: CPU-only PyTorch ------------------------------------------------
14
+ # sentence-transformers + transformers both need torch.
15
+ # Pre-installing the CPU wheel (~190 MB) prevents pip from resolving the
16
+ # default CUDA bundle (~3.5 GB) which would blow the build disk quota.
17
  RUN pip install torch --index-url https://download.pytorch.org/whl/cpu \
18
  --no-cache-dir
19
 
app.py CHANGED
@@ -7,7 +7,7 @@ from supabase import create_client
7
  import uuid
8
  import os
9
 
10
- from model.loader import get_client, get_model_name, is_llm_ready
11
  from retrieval.embedder import get_model, embed_chunks, embed_query
12
  from retrieval.vectorstore import store_chunks, similarity_search
13
  from ingestion.parser import parse_file
@@ -40,16 +40,17 @@ def _supa():
40
 
41
  @asynccontextmanager
42
  async def lifespan(app: FastAPI):
43
- print("🚀 Starting up...")
44
- # Warm up the embedding model (~2 s)
45
- get_model()
46
- print(" Embedding model ready", flush=True)
47
- # Initialise HF Inference client (free, uses auto-injected HF_TOKEN)
 
48
  try:
49
- get_client()
50
- print(f" HF Inference LLM ready ({get_model_name()})", flush=True)
51
  except Exception as exc:
52
- print(f" HF Inference init warning: {exc}", flush=True)
53
  print("✅ Ready", flush=True)
54
  yield
55
 
 
7
  import uuid
8
  import os
9
 
10
+ from model.loader import get_llm, get_model_name, is_llm_ready
11
  from retrieval.embedder import get_model, embed_chunks, embed_query
12
  from retrieval.vectorstore import store_chunks, similarity_search
13
  from ingestion.parser import parse_file
 
40
 
41
  @asynccontextmanager
42
  async def lifespan(app: FastAPI):
43
+ import asyncio
44
+ print("\U0001f680 Starting up...", flush=True)
45
+ get_model() # BGE-small embedding model (~2s)
46
+ print(" \u2714 Embedding model ready", flush=True)
47
+ # Load the LLM in a thread so the event loop stays responsive
48
+ loop = asyncio.get_event_loop()
49
  try:
50
+ await loop.run_in_executor(None, get_llm)
51
+ print(f" \u2714 LLM ready ({get_model_name()})", flush=True)
52
  except Exception as exc:
53
+ print(f" \u26a0 LLM load failed: {exc}", flush=True)
54
  print("✅ Ready", flush=True)
55
  yield
56
 
generation/llm.py CHANGED
@@ -1,4 +1,7 @@
1
- from model.loader import get_client
 
 
 
2
  from typing import Generator
3
 
4
  SYSTEM_PROMPT = """You are a precise document study assistant by Md Tusar Akon.
@@ -12,7 +15,8 @@ def stream_answer(
12
  context_chunks: list[str],
13
  thinking_mode: bool = False,
14
  ) -> Generator[str, None, None]:
15
- client = get_client()
 
16
  context = "\n\n---\n\n".join(context_chunks)
17
 
18
  messages = [
@@ -20,15 +24,35 @@ def stream_answer(
20
  {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {query}"},
21
  ]
22
 
23
- stream = client.chat_completion(
24
- messages=messages,
25
- max_tokens=600,
26
- temperature=0.2,
27
- top_p=0.95,
28
- stream=True,
29
  )
30
 
31
- for chunk in stream:
32
- delta = chunk.choices[0].delta.content or ""
33
- if delta:
34
- yield delta
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from model.loader import get_tokenizer, get_llm
3
+ from transformers import TextIteratorStreamer
4
+ from threading import Thread
5
  from typing import Generator
6
 
7
  SYSTEM_PROMPT = """You are a precise document study assistant by Md Tusar Akon.
 
15
  context_chunks: list[str],
16
  thinking_mode: bool = False,
17
  ) -> Generator[str, None, None]:
18
+ tokenizer = get_tokenizer()
19
+ model = get_llm()
20
  context = "\n\n---\n\n".join(context_chunks)
21
 
22
  messages = [
 
24
  {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {query}"},
25
  ]
26
 
27
+ input_ids = tokenizer.apply_chat_template(
28
+ messages,
29
+ add_generation_prompt=True,
30
+ return_tensors="pt",
 
 
31
  )
32
 
33
+ streamer = TextIteratorStreamer(
34
+ tokenizer,
35
+ skip_prompt=True,
36
+ skip_special_tokens=True,
37
+ timeout=120.0,
38
+ )
39
+
40
+ thread = Thread(
41
+ target=model.generate,
42
+ kwargs=dict(
43
+ input_ids=input_ids,
44
+ streamer=streamer,
45
+ max_new_tokens=512,
46
+ temperature=0.2,
47
+ do_sample=True,
48
+ top_p=0.95,
49
+ pad_token_id=tokenizer.eos_token_id,
50
+ ),
51
+ daemon=True,
52
+ )
53
+ thread.start()
54
+
55
+ for token in streamer:
56
+ yield token
57
+
58
+ thread.join(timeout=120)
generation/quiz.py CHANGED
@@ -1,6 +1,7 @@
1
- from model.loader import get_client
2
  import json
3
  import re
 
4
 
5
  QUIZ_PROMPT = """Based on the context below, generate exactly 3 multiple-choice quiz questions.
6
  Each question must test understanding of the content, not trivia.
@@ -21,17 +22,28 @@ Respond ONLY with a JSON array, no markdown, no explanation:
21
 
22
 
23
  def generate_quiz(context_chunks: list[str]) -> list[dict]:
24
- client = get_client()
 
25
  context = "\n\n".join(context_chunks[:3])
26
 
27
- result = client.chat_completion(
28
- messages=[{"role": "user", "content": QUIZ_PROMPT.format(context=context)}],
29
- max_tokens=800,
30
- temperature=0.4,
31
- stream=False,
32
  )
33
 
34
- raw = result.choices[0].message.content or ""
 
 
 
 
 
 
 
 
 
 
35
  raw = re.sub(r"```json|```", "", raw).strip()
36
 
37
  try:
 
1
+ import torch
2
  import json
3
  import re
4
+ from model.loader import get_tokenizer, get_llm
5
 
6
  QUIZ_PROMPT = """Based on the context below, generate exactly 3 multiple-choice quiz questions.
7
  Each question must test understanding of the content, not trivia.
 
22
 
23
 
24
  def generate_quiz(context_chunks: list[str]) -> list[dict]:
25
+ tokenizer = get_tokenizer()
26
+ model = get_llm()
27
  context = "\n\n".join(context_chunks[:3])
28
 
29
+ messages = [{"role": "user", "content": QUIZ_PROMPT.format(context=context)}]
30
+ input_ids = tokenizer.apply_chat_template(
31
+ messages,
32
+ add_generation_prompt=True,
33
+ return_tensors="pt",
34
  )
35
 
36
+ with torch.no_grad():
37
+ output_ids = model.generate(
38
+ input_ids,
39
+ max_new_tokens=800,
40
+ temperature=0.4,
41
+ do_sample=True,
42
+ pad_token_id=tokenizer.eos_token_id,
43
+ )
44
+
45
+ new_tokens = output_ids[0][input_ids.shape[-1]:]
46
+ raw = tokenizer.decode(new_tokens, skip_special_tokens=True)
47
  raw = re.sub(r"```json|```", "", raw).strip()
48
 
49
  try:
model/loader.py CHANGED
@@ -1,35 +1,61 @@
1
  """
2
- HuggingFace Inference API client -- 100% free, zero compilation.
3
-
4
- Uses huggingface_hub.InferenceClient which calls HF's hosted inference servers.
5
- HF_TOKEN is automatically injected by HF Spaces (no manual setup needed).
6
- To enable: Space Settings -> "Grant repository access to this Space" is ON by default.
7
-
8
- Optional env var: HF_LLM_MODEL (default: mistralai/Mistral-7B-Instruct-v0.3)
9
  """
10
 
11
  import os
12
- from huggingface_hub import InferenceClient
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- _client: InferenceClient | None = None
15
 
16
- DEFAULT_MODEL = "mistralai/Mistral-7B-Instruct-v0.3"
 
 
17
 
18
 
19
- def get_client() -> InferenceClient:
20
- global _client
21
- if _client is None:
22
- token = os.environ.get("HF_TOKEN", "")
23
- model = get_model_name()
24
- _client = InferenceClient(model=model, token=token or None)
25
- print(f" Inference client ready -- model: {model}", flush=True)
26
- return _client
27
 
28
 
29
  def get_model_name() -> str:
30
- return os.environ.get("HF_LLM_MODEL", DEFAULT_MODEL)
31
 
32
 
33
  def is_llm_ready() -> bool:
34
- """Always ready -- HF Inference API needs no local warmup."""
35
- return True
 
1
  """
2
+ Self-hosted LLM using transformers zero external API, no C++ compilation.
3
+ Model: Qwen/Qwen2.5-1.5B-Instruct (1.5B params, ~3 GB bfloat16, fits 16 GB RAM)
4
+ Downloads ~3 GB on first boot then caches to disk for subsequent starts.
5
+ Speed on 2 vCPUs: ~5-10 tok/s ? 20-60 s per RAG answer.
6
+ Override: set LLM_MODEL env var (e.g. Qwen/Qwen2.5-0.5B-Instruct for faster inference).
 
 
7
  """
8
 
9
  import os
10
+ import time
11
+ import torch
12
+ from transformers import AutoTokenizer, AutoModelForCausalLM
13
+
14
+ MODEL_ID = os.environ.get("LLM_MODEL", "Qwen/Qwen2.5-1.5B-Instruct")
15
+
16
+ _tokenizer: AutoTokenizer | None = None
17
+ _llm: AutoModelForCausalLM | None = None
18
+ _llm_ready: bool = False
19
+
20
+
21
+ def _load() -> None:
22
+ global _tokenizer, _llm, _llm_ready
23
+ if _llm is not None:
24
+ return
25
+
26
+ t0 = time.time()
27
+ print(f"\n{'-'*60}", flush=True)
28
+ print(f" Loading {MODEL_ID}", flush=True)
29
+ print(f" First boot downloads ~3 GB then caches to disk.", flush=True)
30
+ print(f"{'-'*60}\n", flush=True)
31
+
32
+ _tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
33
+ print(" Tokenizer loaded.", flush=True)
34
+
35
+ _llm = AutoModelForCausalLM.from_pretrained(
36
+ MODEL_ID,
37
+ torch_dtype=torch.bfloat16, # half RAM vs float32, safe on modern CPUs
38
+ )
39
+ _llm.eval()
40
+ _llm_ready = True
41
+ print(f"\n{'-'*60}", flush=True)
42
+ print(f" {MODEL_ID} ready in {time.time()-t0:.1f}s", flush=True)
43
+ print(f"{'-'*60}\n", flush=True)
44
 
 
45
 
46
+ def get_tokenizer() -> AutoTokenizer:
47
+ _load()
48
+ return _tokenizer # type: ignore
49
 
50
 
51
+ def get_llm() -> AutoModelForCausalLM:
52
+ _load()
53
+ return _llm # type: ignore
 
 
 
 
 
54
 
55
 
56
  def get_model_name() -> str:
57
+ return MODEL_ID
58
 
59
 
60
  def is_llm_ready() -> bool:
61
+ return _llm_ready