File size: 7,459 Bytes
96a7879 afdc322 96a7879 afdc322 96a7879 d579aae d923c42 96a7879 8f8f7fa 96a7879 8f8f7fa 96a7879 8f8f7fa afdc322 8f8f7fa afdc322 96a7879 d579aae d923c42 96a7879 f4db22e 96a7879 | 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 | """
LoomChat — LightRAG wired to a self-hosted Ollama Space.
No external API keys. No token limits. Completely free.
LLM: Ollama Space → qwen2.5:3b (~12 tok/s on HF CPU)
Embeddings: Ollama Space → nomic-embed-text (768-dim, fast on CPU)
Graph: Neo4j Aura Free
Vectors: Qdrant Cloud Free
KV/Cache: HF Persistent Disk /data (50 GB, survives restarts)
"""
import json
import os
import re
from pathlib import Path
import httpx
import numpy as np
from lightrag import LightRAG, QueryParam
from lightrag.utils import EmbeddingFunc
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
# Ollama Space URL — set this to your deployed ollama-space HF URL
# e.g. https://your-username-loomchat-ollama.hf.space
OLLAMA_BASE_URL = os.environ["OLLAMA_SPACE_URL"].rstrip("/")
OLLAMA_API_BASE = f"{OLLAMA_BASE_URL}/v1"
# LLM endpoint — defaults to Ollama Space but can be any OpenAI-compatible URL
# e.g. set LLM_BASE_URL=https://idnameraj-copaw-agent.hf.space to use CoPaw
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", OLLAMA_API_BASE).rstrip("/")
# Model name is optional — leave blank to omit from request (e.g. CoPaw doesn't need it)
LLM_MODEL = os.environ.get("OLLAMA_LLM_MODEL", "")
EMBED_MODEL = os.environ.get("OLLAMA_EMBED_MODEL", "nomic-embed-text")
EMBED_DIM = int(os.environ.get("OLLAMA_EMBED_DIM", "768"))
NEO4J_URI = os.environ["NEO4J_URI"]
NEO4J_USERNAME = os.environ["NEO4J_USERNAME"]
NEO4J_PASSWORD = os.environ["NEO4J_PASSWORD"]
QDRANT_URL = os.environ["QDRANT_URL"]
QDRANT_API_KEY = os.environ["QDRANT_API_KEY"]
QDRANT_COLLECTION = os.environ.get("QDRANT_COLLECTION", "loomchat")
LIGHTRAG_WORKING_DIR = os.environ.get("LIGHTRAG_WORKING_DIR", "/data/lightrag")
# Propagate Neo4j env vars — LightRAG's Neo4JStorage reads these directly
os.environ["NEO4J_URI"] = NEO4J_URI
os.environ["NEO4J_USERNAME"] = NEO4J_USERNAME
os.environ["NEO4J_PASSWORD"] = NEO4J_PASSWORD
# ---------------------------------------------------------------------------
# LLM — raw httpx call (avoids openai_complete double-model kwarg conflict).
# LLM_BASE_URL can point to Ollama Space or CoPaw Space.
# LLM_MODEL is optional — omitted from request if empty (CoPaw doesn't need it).
# ---------------------------------------------------------------------------
async def llm_model_func(
prompt: str,
system_prompt: str | None = None,
history_messages: list | None = None,
**kwargs,
) -> str:
# Pop LightRAG internal kwargs we don't forward to the HTTP backend
kwargs.pop("hashing_kv", None)
kwargs.pop("cache_type", None)
kwargs.pop("chunk_id", None)
kwargs.pop("_priority", None)
keyword_extraction = kwargs.pop("keyword_extraction", False)
messages: list[dict] = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
if history_messages:
messages.extend(history_messages)
messages.append({"role": "user", "content": prompt})
body: dict = {"messages": messages, "stream": False}
if LLM_MODEL:
body["model"] = LLM_MODEL
body["max_tokens"] = kwargs.get("max_tokens", 1024)
body["temperature"] = kwargs.get("temperature", 0.1)
async with httpx.AsyncClient(timeout=180.0) as client:
resp = await client.post(
f"{LLM_BASE_URL}/v1/chat/completions",
json=body,
)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
if not keyword_extraction:
return content
# LightRAG expects {"high_level_keywords": [...], "low_level_keywords": [...]}
# Strip markdown code fences the model may add
cleaned = re.sub(r"```(?:json)?|```", "", content).strip()
try:
parsed = json.loads(cleaned)
if isinstance(parsed, list):
# Model returned a list — flatten into expected dict shape
flat = [k for item in parsed for k in (item if isinstance(item, list) else [str(item)])]
return json.dumps({"high_level_keywords": flat, "low_level_keywords": []})
return json.dumps(parsed) # already a dict, re-serialise cleanly
except json.JSONDecodeError:
# Fallback: treat whole response as a single keyword
return json.dumps({"high_level_keywords": [content.strip()], "low_level_keywords": []})
# ---------------------------------------------------------------------------
# Embeddings — LightRAG's built-in openai_embed, pre-bound to Ollama Space.
# ---------------------------------------------------------------------------
async def embed_model_func(texts: list[str]) -> list[list[float]]:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
f"{OLLAMA_API_BASE}/embeddings",
json={"model": EMBED_MODEL, "input": texts},
headers={"Authorization": "Bearer ollama"},
)
resp.raise_for_status()
data = resp.json()["data"]
vectors = [item["embedding"] for item in sorted(data, key=lambda x: x["index"])]
return np.array(vectors, dtype=np.float32)
# ---------------------------------------------------------------------------
# LightRAG singleton
# ---------------------------------------------------------------------------
_rag_instance: LightRAG | None = None
QUERY_MODES = {"naive", "local", "global", "hybrid", "mix"}
DEFAULT_MODE = "mix"
def get_rag() -> LightRAG:
if _rag_instance is None:
raise RuntimeError("RAG not initialised — await init_rag() at startup")
return _rag_instance
async def init_rag() -> LightRAG:
global _rag_instance
Path(LIGHTRAG_WORKING_DIR).mkdir(parents=True, exist_ok=True)
_rag_instance = LightRAG(
working_dir=LIGHTRAG_WORKING_DIR,
# LLM — qwen2.5:3b via Ollama
llm_model_func=llm_model_func,
llm_model_max_async=2, # CPU is single-threaded; keep concurrency low
# Embeddings — nomic-embed-text via Ollama (768-dim)
embedding_func=EmbeddingFunc(
embedding_dim=EMBED_DIM,
max_token_size=8192,
func=embed_model_func,
),
embedding_batch_num=8,
embedding_func_max_async=2,
# Graph → Neo4j Aura Free
graph_storage="Neo4JStorage",
# Vectors → Qdrant Cloud Free
vector_storage="QdrantVectorDBStorage",
vector_db_storage_cls_kwargs={
"collection_name": QDRANT_COLLECTION,
"url": QDRANT_URL,
"api_key": QDRANT_API_KEY,
},
# KV + chunk cache → /data persistent disk (JsonKVStorage default)
)
await _rag_instance.initialize_storages()
return _rag_instance
async def query(
question: str,
mode: str = DEFAULT_MODE,
top_k: int = 10,
only_need_context: bool = False,
) -> str:
if mode not in QUERY_MODES:
raise ValueError(f"mode must be one of {QUERY_MODES}")
return await get_rag().aquery(
question,
param=QueryParam(
mode=mode,
top_k=top_k,
only_need_context=only_need_context,
),
)
|