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,
        ),
    )