File size: 6,077 Bytes
f7b053e
 
 
 
 
 
 
 
0a799f9
 
 
 
4397111
0a799f9
5688c6d
 
f7b053e
00af3a0
f7b053e
5688c6d
f7b053e
0a799f9
f7b053e
0a799f9
 
 
5688c6d
 
0a799f9
5688c6d
 
 
 
 
0a799f9
 
 
f7b053e
 
00af3a0
 
 
 
 
 
f7b053e
 
00af3a0
 
f7b053e
 
 
 
 
 
 
00af3a0
 
f7b053e
 
 
 
00af3a0
f7b053e
00af3a0
f7b053e
 
00af3a0
f7b053e
 
00af3a0
f7b053e
 
 
 
 
0a799f9
f7b053e
 
 
 
 
 
 
 
 
 
 
 
 
 
00af3a0
0a799f9
f7b053e
 
 
 
0a799f9
 
f7b053e
 
00af3a0
 
f7b053e
00af3a0
0a799f9
 
f7b053e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
619c352
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a799f9
 
 
 
 
 
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
"""
ChromaDB client + embedding singleton for ResearchRAG.

Collections per user:
  {name}_{hash}         β†’ child chunks (small, embedded, searched)
  {name}_{hash}_parent  β†’ parent chunks (large, LLM context, looked up by ID)
"""

import chromadb
from chromadb.config import Settings as ChromaSettings
from sentence_transformers import SentenceTransformer
from app.config import get_settings
from app.logger import logger
import hashlib
import threading

_client      = None
_collections: dict[str, chromadb.Collection] = {}
_embedder    = None
_embedder_lock = threading.Lock()


# ─── Embedder ─────────────────────────────────────────────────────────────────

def get_embedder() -> SentenceTransformer:
    global _embedder
    # Same race as the reranker: concurrent threadpool workers could each see
    # None and each load a ~1.1 GB model. Double-checked locking bounds it to one.
    if _embedder is None:
        with _embedder_lock:
            if _embedder is None:
                cfg = get_settings()
                logger.info("embedder_loading model=%s", cfg.embedding_model)
                _embedder = SentenceTransformer(cfg.embedding_model)
    return _embedder


# ─── Collection naming ────────────────────────────────────────────────────────

def _normalize_user_id(user_id: str | None) -> str:
    if not user_id:
        return "default"
    return user_id.strip().lower() or "default"


def _collection_name(user_id: str | None, role: str = "child") -> str:
    cfg        = get_settings()
    normalized = _normalize_user_id(user_id)
    if normalized == "default":
        base = cfg.chroma_collection
    else:
        suffix = hashlib.md5(normalized.encode()).hexdigest()[:12]
        base   = f"{cfg.chroma_collection}_{suffix}"
    if role == "parent":
        return f"{base}_parent"
    return base


# ─── Client init ──────────────────────────────────────────────────────────────

def _get_client() -> chromadb.PersistentClient:
    global _client
    if _client is None:
        cfg     = get_settings()
        _client = chromadb.PersistentClient(
            path     = cfg.chroma_path,
            settings = ChromaSettings(anonymized_telemetry=False),
        )
    return _client


def _get_or_create(name: str) -> chromadb.Collection:
    client = _get_client()
    col = client.get_or_create_collection(
        name     = name,
        metadata = {"hnsw:space": "cosine"},
    )
    return col


# ─── Public collection accessors ─────────────────────────────────────────────

def init_chroma(user_id: str | None = None):
    global _collections
    normalized = _normalize_user_id(user_id)

    child_name  = _collection_name(user_id, "child")
    parent_name = _collection_name(user_id, "parent")

    _collections[normalized]              = _get_or_create(child_name)
    _collections[f"{normalized}_parent"]  = _get_or_create(parent_name)

    get_embedder()
    print(
        f"[ChromaDB] Ready β€” {child_name} ({_collections[normalized].count()} child chunks) "
        f"| {parent_name} ({_collections[f'{normalized}_parent'].count()} parent chunks)"
    )


def get_collection(user_id: str | None = None) -> chromadb.Collection:
    """Returns the CHILD collection (for embedding + search)."""
    normalized = _normalize_user_id(user_id)
    if normalized not in _collections:
        init_chroma(user_id)
    return _collections[normalized]


def get_parent_collection(user_id: str | None = None) -> chromadb.Collection:
    """Returns the PARENT collection (for LLM context lookup)."""
    normalized = _normalize_user_id(user_id)
    key = f"{normalized}_parent"
    if key not in _collections:
        init_chroma(user_id)
    return _collections[key]


def get_parents_by_ids(
    parent_ids: list[str],
    user_id: str | None = None,
) -> list[dict]:
    """
    Fetch parent chunks by their IDs from the parent collection.
    Returns list of {id, text, metadata} dicts.
    """
    if not parent_ids:
        return []
    col     = get_parent_collection(user_id)
    unique  = list(dict.fromkeys(parent_ids))   # deduplicate, preserve order
    try:
        results = col.get(ids=unique, include=["documents", "metadatas"])
    except Exception:
        return []
    out = []
    for pid, doc, meta in zip(
        results.get("ids", []),
        results.get("documents", []),
        results.get("metadatas", []),
    ):
        out.append({"id": pid, "text": doc, "metadata": meta})
    return out


# ─── Utilities ────────────────────────────────────────────────────────────────

def embed_documents(texts: list[str]) -> list[list[float]]:
    """Embed passages for indexing. e5 models need the 'passage: ' prefix."""
    prefix = get_settings().embedding_passage_prefix
    return get_embedder().encode(
        [f"{prefix}{t}" for t in texts],
        normalize_embeddings=True,
        show_progress_bar=False,
    ).tolist()


def embed_query(text: str) -> list[float]:
    """Embed a search query. e5 models need the 'query: ' prefix."""
    prefix = get_settings().embedding_query_prefix
    return get_embedder().encode(
        f"{prefix}{text}",
        normalize_embeddings=True,
    ).tolist()


# Backward-compat alias β€” all remaining callers embed documents.
embed_texts = embed_documents


def make_doc_id(source: str, chunk_index: int) -> str:
    """Stable unique ID for a chunk."""
    raw = f"{source}::{chunk_index}"
    return hashlib.md5(raw.encode()).hexdigest()