Spaces:
Build error
Build error
Upload 7 files
Browse files- Dockerfile +9 -6
- README.md +21 -9
- app.py +28 -10
- documents/sample.txt +5 -3
- index.html +2 -2
- requirements.txt +8 -5
Dockerfile
CHANGED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
# CPU + FREE-tier RAG Space (Docker SDK).
|
| 2 |
-
# Embeddings : BAAI/bge-small-en-v1.5
|
| 3 |
# Vector DB : FAISS (in-memory)
|
| 4 |
-
# LLM :
|
| 5 |
# API : OpenAI-compatible -> /v1/chat/completions (+ web UI at /)
|
| 6 |
#
|
|
|
|
|
|
|
|
|
|
| 7 |
# Everything is CPU-only. No GPU, no paid hardware required.
|
| 8 |
# Models are baked into the image so the Space cold-starts instantly.
|
| 9 |
|
|
@@ -36,10 +39,10 @@ ENV CMAKE_ARGS="-DGGML_NATIVE=OFF -DGGML_AVX=ON -DGGML_AVX2=ON -DGGML_FMA=ON -DG
|
|
| 36 |
RUN pip install --no-cache-dir --user --no-binary=llama-cpp-python -r requirements.txt
|
| 37 |
|
| 38 |
# --- Model config ------------------------------------------------------------
|
| 39 |
-
#
|
| 40 |
-
#
|
| 41 |
-
ENV LLM_REPO=
|
| 42 |
-
LLM_FILE=
|
| 43 |
MODEL_DIR=/home/user/models \
|
| 44 |
EMBED_MODEL=BAAI/bge-small-en-v1.5 \
|
| 45 |
FASTEMBED_CACHE=/home/user/.cache/fastembed \
|
|
|
|
| 1 |
# CPU + FREE-tier RAG Space (Docker SDK).
|
| 2 |
+
# Embeddings : BAAI/bge-small-en-v1.5 (fastembed / ONNX, no torch)
|
| 3 |
# Vector DB : FAISS (in-memory)
|
| 4 |
+
# LLM : Qwen3.5-0.8B (MoE) (llama.cpp GGUF; small + fast on CPU)
|
| 5 |
# API : OpenAI-compatible -> /v1/chat/completions (+ web UI at /)
|
| 6 |
#
|
| 7 |
+
# NOTE: Qwen3.5 uses the `qwen35` arch -> requires llama-cpp-python >= 0.3.32
|
| 8 |
+
# (llama.cpp >= b9616). See requirements.txt.
|
| 9 |
+
#
|
| 10 |
# Everything is CPU-only. No GPU, no paid hardware required.
|
| 11 |
# Models are baked into the image so the Space cold-starts instantly.
|
| 12 |
|
|
|
|
| 39 |
RUN pip install --no-cache-dir --user --no-binary=llama-cpp-python -r requirements.txt
|
| 40 |
|
| 41 |
# --- Model config ------------------------------------------------------------
|
| 42 |
+
# Qwen3.5-0.8B (MoE): tiny active-param count -> fast on CPU, punches above a
|
| 43 |
+
# dense 0.5B. Q4_K_M (~533 MB) is the CPU sweet spot. Text-only GGUF (no vision).
|
| 44 |
+
ENV LLM_REPO=unsloth/Qwen3.5-0.8B-GGUF \
|
| 45 |
+
LLM_FILE=Qwen3.5-0.8B-Q4_K_M.gguf \
|
| 46 |
MODEL_DIR=/home/user/models \
|
| 47 |
EMBED_MODEL=BAAI/bge-small-en-v1.5 \
|
| 48 |
FASTEMBED_CACHE=/home/user/.cache/fastembed \
|
README.md
CHANGED
|
@@ -9,7 +9,7 @@ pinned: false
|
|
| 9 |
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
-
# CPU RAG Space —
|
| 13 |
|
| 14 |
A self-contained Retrieval-Augmented Generation service that runs **entirely on
|
| 15 |
CPU** and fits the Hugging Face **free tier** (2 vCPU / 16 GB).
|
|
@@ -18,16 +18,20 @@ CPU** and fits the Hugging Face **free tier** (2 vCPU / 16 GB).
|
|
| 18 |
|-----------|------|-----|
|
| 19 |
| Embeddings | `BAAI/bge-small-en-v1.5` via `fastembed` (ONNX) | fast on CPU, no PyTorch, ~130 MB |
|
| 20 |
| Vector DB | **FAISS** (in-memory) | tiny, instant search |
|
| 21 |
-
| LLM | `
|
| 22 |
| API | OpenAI-compatible `/v1/chat/completions` + web UI | drop-in for any client |
|
| 23 |
|
| 24 |
-
Total footprint ≈ 0.
|
| 25 |
real latency.
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
**Tuned for CPU speed:** 2K context (small KV cache), flash-attention, short
|
| 28 |
-
256-token answers,
|
| 29 |
-
|
| 30 |
-
`.github/workflows/keepalive.yml` for the keep-warm ping.
|
| 31 |
|
| 32 |
## Deploy (drag & drop)
|
| 33 |
|
|
@@ -68,9 +72,17 @@ Extra endpoints: `POST /ingest` (upload a doc), `GET /stats`.
|
|
| 68 |
Change `LLM_REPO` / `LLM_FILE` in the `Dockerfile` (confirm exact filenames on
|
| 69 |
the repo's *Files* tab):
|
| 70 |
|
| 71 |
-
- Current (
|
| 72 |
-
-
|
| 73 |
-
- Best general
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
## Keep it warm (avoid cold starts)
|
| 76 |
|
|
|
|
| 9 |
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# CPU RAG Space — Qwen3.5-0.8B + FAISS (free tier, no GPU)
|
| 13 |
|
| 14 |
A self-contained Retrieval-Augmented Generation service that runs **entirely on
|
| 15 |
CPU** and fits the Hugging Face **free tier** (2 vCPU / 16 GB).
|
|
|
|
| 18 |
|-----------|------|-----|
|
| 19 |
| Embeddings | `BAAI/bge-small-en-v1.5` via `fastembed` (ONNX) | fast on CPU, no PyTorch, ~130 MB |
|
| 20 |
| Vector DB | **FAISS** (in-memory) | tiny, instant search |
|
| 21 |
+
| LLM | `Qwen3.5-0.8B` (MoE) GGUF Q4_K_M via llama.cpp | ~533 MB, small MoE — fast on 2 vCPU, smarter than a dense 0.5B |
|
| 22 |
| API | OpenAI-compatible `/v1/chat/completions` + web UI | drop-in for any client |
|
| 23 |
|
| 24 |
+
Total footprint ≈ 0.8–1.1 GB RAM. Retrieval adds ~20 ms; the LLM is the only
|
| 25 |
real latency.
|
| 26 |
|
| 27 |
+
> **Runtime requirement:** Qwen3.5 uses the `qwen35` architecture, so this needs
|
| 28 |
+
> **`llama-cpp-python >= 0.3.32`** (llama.cpp ≥ b9616). The Dockerfile builds it
|
| 29 |
+
> from source. Older pins fail with *"unknown architecture 'qwen35'"*.
|
| 30 |
+
|
| 31 |
**Tuned for CPU speed:** 2K context (small KV cache), flash-attention, short
|
| 32 |
+
256-token answers, relevance-gated RAG (greetings/off-topic skip retrieval), a
|
| 33 |
+
startup warm-up, and a **streaming** web UI so the first word appears in ~1 s.
|
| 34 |
+
See `.github/workflows/keepalive.yml` for the keep-warm ping.
|
| 35 |
|
| 36 |
## Deploy (drag & drop)
|
| 37 |
|
|
|
|
| 72 |
Change `LLM_REPO` / `LLM_FILE` in the `Dockerfile` (confirm exact filenames on
|
| 73 |
the repo's *Files* tab):
|
| 74 |
|
| 75 |
+
- Current (small MoE, fast on CPU): `unsloth/Qwen3.5-0.8B-GGUF` → `Qwen3.5-0.8B-Q4_K_M.gguf`
|
| 76 |
+
- Fastest tiny dense: `Qwen/Qwen2.5-Coder-0.5B-Instruct-GGUF`
|
| 77 |
+
- Best general quality (~½ speed): `HuggingFaceTB/SmolLM2-1.7B-Instruct-GGUF`
|
| 78 |
+
|
| 79 |
+
### MTP (Multi-Token Prediction) — later
|
| 80 |
+
|
| 81 |
+
`unsloth/Qwen3.5-0.8B-MTP-GGUF` adds self-speculative decoding for a ~1.4–1.7×
|
| 82 |
+
decode speedup. It's **not** a drop-in here: the MTP flags (`--spec-type
|
| 83 |
+
draft-mtp`) live in **`llama-server`**, not the `llama_cpp.Llama()` API this app
|
| 84 |
+
uses. To try it, run `llama-server` as the backend and proxy to it. Feasible on
|
| 85 |
+
CPU only because the model is tiny (an MTP 9B on 2 vCPU stays ~1.5–2.5 tok/s).
|
| 86 |
|
| 87 |
## Keep it warm (avoid cold starts)
|
| 88 |
|
app.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
CPU RAG Space — bge-small (fastembed) + FAISS +
|
| 3 |
served as an OpenAI-compatible API with a small web UI.
|
| 4 |
|
| 5 |
Everything runs on CPU and fits the Hugging Face free tier (2 vCPU / 16 GB).
|
| 6 |
-
Tuned for lowest CPU latency:
|
| 7 |
-
short answers, and a startup warm-up so the first real
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import glob
|
|
@@ -23,7 +24,7 @@ from pydantic import BaseModel
|
|
| 23 |
# Config (all overridable via Space "Variables")
|
| 24 |
# --------------------------------------------------------------------------- #
|
| 25 |
MODEL_DIR = os.environ.get("MODEL_DIR", "models")
|
| 26 |
-
LLM_FILE = os.environ.get("LLM_FILE", "
|
| 27 |
LLM_PATH = os.path.join(MODEL_DIR, LLM_FILE)
|
| 28 |
EMBED_MODEL = os.environ.get("EMBED_MODEL", "BAAI/bge-small-en-v1.5")
|
| 29 |
FASTEMBED_CACHE = os.environ.get("FASTEMBED_CACHE")
|
|
@@ -33,14 +34,23 @@ N_CTX = int(os.environ.get("N_CTX", "2048"))
|
|
| 33 |
N_THREADS = int(os.environ.get("N_THREADS", str(os.cpu_count() or 2)))
|
| 34 |
TOP_K = int(os.environ.get("TOP_K", "4"))
|
| 35 |
DOCS_DIR = os.environ.get("DOCS_DIR", "documents")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
CHUNK_SIZE = 800 # characters per chunk
|
| 38 |
CHUNK_OVERLAP = 120
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
RAG_SYSTEM = (
|
| 41 |
-
"You are a helpful assistant.
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
"Context:\n{context}"
|
| 45 |
)
|
| 46 |
|
|
@@ -178,12 +188,20 @@ def _augment(req: ChatRequest):
|
|
| 178 |
msgs = [m.model_dump() for m in req.messages]
|
| 179 |
users = [m for m in msgs if m["role"] == "user"]
|
| 180 |
query = users[-1]["content"] if users else ""
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
if ctxs:
|
| 183 |
context = "\n\n".join(f"[{c['source']}] {c['text']}" for c in ctxs)
|
| 184 |
system = {"role": "system", "content": RAG_SYSTEM.format(context=context)}
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
|
| 189 |
@app.post("/v1/chat/completions")
|
|
|
|
| 1 |
"""
|
| 2 |
+
CPU RAG Space — bge-small (fastembed) + FAISS + Qwen3.5-0.8B (llama.cpp),
|
| 3 |
served as an OpenAI-compatible API with a small web UI.
|
| 4 |
|
| 5 |
Everything runs on CPU and fits the Hugging Face free tier (2 vCPU / 16 GB).
|
| 6 |
+
Tuned for lowest CPU latency: small MoE model, 2K context, flash-attention,
|
| 7 |
+
short answers, relevance-gated RAG, and a startup warm-up so the first real
|
| 8 |
+
query is snappy. Needs llama-cpp-python >= 0.3.32 (qwen35 arch).
|
| 9 |
"""
|
| 10 |
|
| 11 |
import glob
|
|
|
|
| 24 |
# Config (all overridable via Space "Variables")
|
| 25 |
# --------------------------------------------------------------------------- #
|
| 26 |
MODEL_DIR = os.environ.get("MODEL_DIR", "models")
|
| 27 |
+
LLM_FILE = os.environ.get("LLM_FILE", "Qwen3.5-0.8B-Q4_K_M.gguf")
|
| 28 |
LLM_PATH = os.path.join(MODEL_DIR, LLM_FILE)
|
| 29 |
EMBED_MODEL = os.environ.get("EMBED_MODEL", "BAAI/bge-small-en-v1.5")
|
| 30 |
FASTEMBED_CACHE = os.environ.get("FASTEMBED_CACHE")
|
|
|
|
| 34 |
N_THREADS = int(os.environ.get("N_THREADS", str(os.cpu_count() or 2)))
|
| 35 |
TOP_K = int(os.environ.get("TOP_K", "4"))
|
| 36 |
DOCS_DIR = os.environ.get("DOCS_DIR", "documents")
|
| 37 |
+
# Only inject retrieved context when the best hit is actually relevant (cosine
|
| 38 |
+
# similarity). Below this, treat it as normal chat instead of forcing doc QA —
|
| 39 |
+
# stops greetings/off-topic messages from dredging up irrelevant chunks.
|
| 40 |
+
RAG_MIN_SCORE = float(os.environ.get("RAG_MIN_SCORE", "0.4"))
|
| 41 |
|
| 42 |
CHUNK_SIZE = 800 # characters per chunk
|
| 43 |
CHUNK_OVERLAP = 120
|
| 44 |
|
| 45 |
+
# Plain assistant persona used when nothing relevant is retrieved.
|
| 46 |
+
CHAT_SYSTEM = "You are a friendly, concise assistant."
|
| 47 |
+
|
| 48 |
+
# Used when we DO have relevant context. No literal "[filename]" example — a
|
| 49 |
+
# tiny model will just echo it. Sources are returned separately in the JSON.
|
| 50 |
RAG_SYSTEM = (
|
| 51 |
+
"You are a helpful assistant. Use the context below to answer the user's "
|
| 52 |
+
"question. If the context does not contain the answer, say so briefly and "
|
| 53 |
+
"answer from your own knowledge if you can. Keep answers concise.\n\n"
|
| 54 |
"Context:\n{context}"
|
| 55 |
)
|
| 56 |
|
|
|
|
| 188 |
msgs = [m.model_dump() for m in req.messages]
|
| 189 |
users = [m for m in msgs if m["role"] == "user"]
|
| 190 |
query = users[-1]["content"] if users else ""
|
| 191 |
+
|
| 192 |
+
# Retrieve, then keep only chunks that clear the relevance bar.
|
| 193 |
+
hits = retrieve(query) if req.use_rag else []
|
| 194 |
+
ctxs = [c for c in hits if c["score"] >= RAG_MIN_SCORE]
|
| 195 |
+
|
| 196 |
+
non_system = [m for m in msgs if m["role"] != "system"]
|
| 197 |
if ctxs:
|
| 198 |
context = "\n\n".join(f"[{c['source']}] {c['text']}" for c in ctxs)
|
| 199 |
system = {"role": "system", "content": RAG_SYSTEM.format(context=context)}
|
| 200 |
+
else:
|
| 201 |
+
# Nothing relevant -> behave like a normal chat assistant, no forced
|
| 202 |
+
# "answer only from context" (which made the model spit citations).
|
| 203 |
+
system = {"role": "system", "content": CHAT_SYSTEM}
|
| 204 |
+
return [system] + non_system, ctxs
|
| 205 |
|
| 206 |
|
| 207 |
@app.post("/v1/chat/completions")
|
documents/sample.txt
CHANGED
|
@@ -7,8 +7,9 @@ Architecture:
|
|
| 7 |
text into 384-dimensional vectors and needs no GPU or PyTorch.
|
| 8 |
- Vector store: FAISS (IndexFlatIP) holds the document vectors in memory and
|
| 9 |
returns the most similar chunks for a query using cosine similarity.
|
| 10 |
-
- Language model:
|
| 11 |
-
llama.cpp. It reads the retrieved chunks and writes a
|
|
|
|
| 12 |
|
| 13 |
How retrieval works:
|
| 14 |
1. Your question is embedded into a vector.
|
|
@@ -19,7 +20,8 @@ How retrieval works:
|
|
| 19 |
Why a small model is fine here:
|
| 20 |
RAG moves knowledge out of the model's weights and into the retriever, so the
|
| 21 |
model only needs to read and summarise the provided context rather than
|
| 22 |
-
memorise facts. That makes a fast
|
|
|
|
| 23 |
|
| 24 |
Replace this file with your own .txt or .md documents, or upload files at
|
| 25 |
runtime through the web UI, and the Space will answer questions about them.
|
|
|
|
| 7 |
text into 384-dimensional vectors and needs no GPU or PyTorch.
|
| 8 |
- Vector store: FAISS (IndexFlatIP) holds the document vectors in memory and
|
| 9 |
returns the most similar chunks for a query using cosine similarity.
|
| 10 |
+
- Language model: Qwen3.5-0.8B (a small Mixture-of-Experts model) in GGUF
|
| 11 |
+
Q4_K_M form, served by llama.cpp. It reads the retrieved chunks and writes a
|
| 12 |
+
grounded answer.
|
| 13 |
|
| 14 |
How retrieval works:
|
| 15 |
1. Your question is embedded into a vector.
|
|
|
|
| 20 |
Why a small model is fine here:
|
| 21 |
RAG moves knowledge out of the model's weights and into the retriever, so the
|
| 22 |
model only needs to read and summarise the provided context rather than
|
| 23 |
+
memorise facts. That makes a fast, small model like Qwen3.5-0.8B a good fit for
|
| 24 |
+
CPU serving.
|
| 25 |
|
| 26 |
Replace this file with your own .txt or .md documents, or upload files at
|
| 27 |
runtime through the web UI, and the Space will answer questions about them.
|
index.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
-
<title>CPU RAG ·
|
| 7 |
<style>
|
| 8 |
:root { color-scheme: light dark; --bg:#0f1220; --panel:#1a1e33; --acc:#7c8cff; --mut:#8b90a8; }
|
| 9 |
* { box-sizing: border-box; }
|
|
@@ -28,7 +28,7 @@
|
|
| 28 |
<body>
|
| 29 |
<header>
|
| 30 |
<h1>🦅 CPU RAG</h1>
|
| 31 |
-
<span class="tag">
|
| 32 |
<span class="tag" id="stat">…</span>
|
| 33 |
</header>
|
| 34 |
<main>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>CPU RAG · Qwen3.5-0.8B + FAISS</title>
|
| 7 |
<style>
|
| 8 |
:root { color-scheme: light dark; --bg:#0f1220; --panel:#1a1e33; --acc:#7c8cff; --mut:#8b90a8; }
|
| 9 |
* { box-sizing: border-box; }
|
|
|
|
| 28 |
<body>
|
| 29 |
<header>
|
| 30 |
<h1>🦅 CPU RAG</h1>
|
| 31 |
+
<span class="tag">Qwen3.5-0.8B · bge-small · FAISS</span>
|
| 32 |
<span class="tag" id="stat">…</span>
|
| 33 |
</header>
|
| 34 |
<main>
|
requirements.txt
CHANGED
|
@@ -1,8 +1,11 @@
|
|
| 1 |
-
#
|
| 2 |
-
#
|
| 3 |
-
# *
|
| 4 |
-
#
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
# Lightweight CPU embeddings via ONNX (no PyTorch -> small image, fast).
|
| 8 |
fastembed
|
|
|
|
| 1 |
+
# Bumped 0.3.2 -> 0.3.32 (Jun 2026) and still built FROM SOURCE (Dockerfile
|
| 2 |
+
# --no-binary):
|
| 3 |
+
# * Qwen3.5 uses the `qwen35` architecture, which needs llama.cpp >= b9616.
|
| 4 |
+
# The old 0.3.2 pin can't load it ("unknown architecture 'qwen35'").
|
| 5 |
+
# * 0.3.32 vendors a recent llama.cpp (past the old CPU "set_rows" decode
|
| 6 |
+
# crash) and works on Debian glibc when compiled here.
|
| 7 |
+
# If a future wheel regresses, pin the last-known-good 0.3.x that loads qwen35.
|
| 8 |
+
llama-cpp-python==0.3.32
|
| 9 |
|
| 10 |
# Lightweight CPU embeddings via ONNX (no PyTorch -> small image, fast).
|
| 11 |
fastembed
|