File size: 8,921 Bytes
5eb1dee
ed7c75c
5eb1dee
 
 
ed7c75c
 
 
5eb1dee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ed7c75c
5eb1dee
 
 
d095bd1
 
 
5eb1dee
 
 
ed7c75c
 
 
 
5eb1dee
 
 
 
ed7c75c
 
 
 
 
5eb1dee
ed7c75c
 
 
5eb1dee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d095bd1
 
 
 
 
 
 
 
 
5eb1dee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d095bd1
 
 
 
 
 
 
5eb1dee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d095bd1
 
5eb1dee
 
 
 
 
 
 
ed7c75c
 
 
 
 
 
5eb1dee
 
 
ed7c75c
 
 
 
 
5eb1dee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
"""
CPU RAG Space — bge-small (fastembed) + FAISS + Qwen3.5-0.8B (llama.cpp),
served as an OpenAI-compatible API with a small web UI.

Everything runs on CPU and fits the Hugging Face free tier (2 vCPU / 16 GB).
Tuned for lowest CPU latency: small MoE model, 2K context, flash-attention,
short answers, relevance-gated RAG, and a startup warm-up so the first real
query is snappy. Needs llama-cpp-python >= 0.3.32 (qwen35 arch).
"""

import glob
import json
import os

import faiss
import numpy as np
from fastapi import FastAPI, File, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastembed import TextEmbedding
from llama_cpp import Llama
from pydantic import BaseModel

# --------------------------------------------------------------------------- #
# Config (all overridable via Space "Variables")
# --------------------------------------------------------------------------- #
MODEL_DIR = os.environ.get("MODEL_DIR", "models")
LLM_FILE = os.environ.get("LLM_FILE", "Qwen3.5-0.8B-Q4_K_M.gguf")
LLM_PATH = os.path.join(MODEL_DIR, LLM_FILE)
EMBED_MODEL = os.environ.get("EMBED_MODEL", "BAAI/bge-small-en-v1.5")
FASTEMBED_CACHE = os.environ.get("FASTEMBED_CACHE")
# RAG prompts are ~500 tokens, so 2K context is plenty. A smaller KV cache means
# less RAM and faster per-token cache ops than the old 8192.
N_CTX = int(os.environ.get("N_CTX", "2048"))
N_THREADS = int(os.environ.get("N_THREADS", str(os.cpu_count() or 2)))
TOP_K = int(os.environ.get("TOP_K", "4"))
DOCS_DIR = os.environ.get("DOCS_DIR", "documents")
# Only inject retrieved context when the best hit is actually relevant (cosine
# similarity). Below this, treat it as normal chat instead of forcing doc QA —
# stops greetings/off-topic messages from dredging up irrelevant chunks.
RAG_MIN_SCORE = float(os.environ.get("RAG_MIN_SCORE", "0.4"))

CHUNK_SIZE = 800        # characters per chunk
CHUNK_OVERLAP = 120

# Plain assistant persona used when nothing relevant is retrieved.
CHAT_SYSTEM = "You are a friendly, concise assistant."

# Used when we DO have relevant context. No literal "[filename]" example — a
# tiny model will just echo it. Sources are returned separately in the JSON.
RAG_SYSTEM = (
    "You are a helpful assistant. Use the context below to answer the user's "
    "question. If the context does not contain the answer, say so briefly and "
    "answer from your own knowledge if you can. Keep answers concise.\n\n"
    "Context:\n{context}"
)

# --------------------------------------------------------------------------- #
# Lazily-initialised singletons
# --------------------------------------------------------------------------- #
app = FastAPI(title="CPU RAG Space")

_embedder = None
_llm = None
_index = None            # faiss.IndexFlatIP
_chunks = []             # list[{"text": str, "source": str}]


def embedder():
    global _embedder
    if _embedder is None:
        _embedder = TextEmbedding(EMBED_MODEL, cache_dir=FASTEMBED_CACHE)
    return _embedder


def embed(texts):
    vecs = np.array(list(embedder().embed(list(texts))), dtype="float32")
    faiss.normalize_L2(vecs)          # cosine similarity via inner product
    return vecs


def llm():
    global _llm
    if _llm is None:
        _llm = Llama(
            model_path=LLM_PATH,
            n_ctx=N_CTX,
            n_threads=N_THREADS,          # decode threads (= physical cores)
            n_threads_batch=N_THREADS,    # prefill threads
            n_batch=512,
            flash_attn=True,              # cheaper attention -> faster on CPU
            verbose=False,
        )
    return _llm


# --------------------------------------------------------------------------- #
# Indexing / retrieval
# --------------------------------------------------------------------------- #
def chunk_text(text, source):
    out, i, n = [], 0, len(text)
    step = max(CHUNK_SIZE - CHUNK_OVERLAP, 1)
    while i < n:
        piece = text[i:i + CHUNK_SIZE].strip()
        if piece:
            out.append({"text": piece, "source": source})
        i += step
    return out


def add_chunks(new_chunks):
    global _index, _chunks
    if not new_chunks:
        return 0
    vecs = embed([c["text"] for c in new_chunks])
    if _index is None:
        _index = faiss.IndexFlatIP(vecs.shape[1])
    _index.add(vecs)
    _chunks.extend(new_chunks)
    return len(new_chunks)


def build_index():
    patterns = ("*.txt", "*.md")
    files = []
    for p in patterns:
        files += glob.glob(os.path.join(DOCS_DIR, "**", p), recursive=True)
    all_chunks = []
    for f in files:
        try:
            with open(f, encoding="utf-8") as fh:
                all_chunks += chunk_text(fh.read(), os.path.basename(f))
        except Exception as exc:
            print(f"[rag] skip {f}: {exc}")
    add_chunks(all_chunks)


def retrieve(query, k=TOP_K):
    if _index is None or _index.ntotal == 0:
        return []
    scores, ids = _index.search(embed([query]), min(k, _index.ntotal))
    hits = []
    for score, idx in zip(scores[0], ids[0]):
        if idx < 0:
            continue
        c = _chunks[idx]
        hits.append({"text": c["text"], "source": c["source"], "score": float(score)})
    return hits


# --------------------------------------------------------------------------- #
# Startup
# --------------------------------------------------------------------------- #
@app.on_event("startup")
def _startup():
    print("[rag] loading embedder + llm ...")
    embedder()
    llm()
    build_index()
    # Warm up the decode path (kernels, KV cache) so the FIRST real user query
    # doesn't pay the one-off jit/allocation cost.
    try:
        llm().create_chat_completion(
            messages=[{"role": "user", "content": "hi"}], max_tokens=1)
    except Exception as exc:
        print(f"[rag] warmup skipped: {exc}")
    print(f"[rag] ready. indexed_chunks={len(_chunks)}")


# --------------------------------------------------------------------------- #
# OpenAI-compatible chat endpoint (with RAG)
# --------------------------------------------------------------------------- #
class ChatMessage(BaseModel):
    role: str
    content: str


class ChatRequest(BaseModel):
    model: str = "cpu-rag"
    messages: list[ChatMessage]
    temperature: float = 0.3
    top_p: float = 0.9
    max_tokens: int = 256      # short RAG answers -> lower latency on CPU
    stream: bool = False       # API default off for OpenAI-client compat; UI streams
    use_rag: bool = True


def _augment(req: ChatRequest):
    msgs = [m.model_dump() for m in req.messages]
    users = [m for m in msgs if m["role"] == "user"]
    query = users[-1]["content"] if users else ""

    # Retrieve, then keep only chunks that clear the relevance bar.
    hits = retrieve(query) if req.use_rag else []
    ctxs = [c for c in hits if c["score"] >= RAG_MIN_SCORE]

    non_system = [m for m in msgs if m["role"] != "system"]
    if ctxs:
        context = "\n\n".join(f"[{c['source']}] {c['text']}" for c in ctxs)
        system = {"role": "system", "content": RAG_SYSTEM.format(context=context)}
    else:
        # Nothing relevant -> behave like a normal chat assistant, no forced
        # "answer only from context" (which made the model spit citations).
        system = {"role": "system", "content": CHAT_SYSTEM}
    return [system] + non_system, ctxs


@app.post("/v1/chat/completions")
def chat_completions(req: ChatRequest):
    messages, ctxs = _augment(req)
    params = dict(messages=messages, temperature=req.temperature, top_p=req.top_p,
                  max_tokens=req.max_tokens, stop=["<|im_end|>", "<|endoftext|>"])

    if req.stream:
        def gen():
            for chunk in llm().create_chat_completion(**params, stream=True):
                yield f"data: {json.dumps(chunk)}\n\n"
            yield "data: [DONE]\n\n"
        return StreamingResponse(gen(), media_type="text/event-stream")

    resp = llm().create_chat_completion(**params)
    resp["sources"] = [{"source": c["source"], "score": round(c["score"], 3)} for c in ctxs]
    return JSONResponse(resp)


# --------------------------------------------------------------------------- #
# Ingest / stats / UI
# --------------------------------------------------------------------------- #
@app.post("/ingest")
async def ingest(file: UploadFile = File(...)):
    text = (await file.read()).decode("utf-8", "ignore")
    added = add_chunks(chunk_text(text, file.filename))
    return {"file": file.filename, "added_chunks": added, "total_chunks": len(_chunks)}


@app.get("/stats")
def stats():
    return {"indexed_chunks": len(_chunks), "embed_model": EMBED_MODEL,
            "llm": LLM_FILE, "n_ctx": N_CTX, "threads": N_THREADS, "top_k": TOP_K}


@app.get("/", response_class=HTMLResponse)
def home():
    with open(os.path.join(os.path.dirname(__file__), "index.html"), encoding="utf-8") as f:
        return f.read()