Coverage for /var/devmt/py/docp-dbi_1.1.0/docp_dbi/databases/chroma.py: 100%
66 statements
« prev ^ index » next coverage.py v7.8.0, created at 2026-04-20 13:13 +0100
« prev ^ index » next coverage.py v7.8.0, created at 2026-04-20 13:13 +0100
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3"""
4:Purpose: This module provides a localised wrapper and specialised
5 functionality around the :class:`langchain_chroma.Chroma`
6 class, for interacting with a ChromaDB database.
8:Platform: Linux/Windows | Python 3.11+
9:Developer: J Berendt
10:Email: development@s3dev.uk
12:Comments: This module uses the :class:`langchain_chroma.Chroma`
13 class, rather than the base ``chromadb`` library as
14 langchain's implementation provides the ``add_texts`` method
15 which supports **GPU processing** and parallelisation. This
16 functionality is implemented and accessed through the
17 :meth:`~ChromaDB.add_documents` method.
19"""
20# pylint: disable=wrong-import-order
22from __future__ import annotations
24import chromadb
25import os
26from hashlib import md5
27# langchain's Chroma is used rather than the base chromadb as it provides
28# the add_texts method which support GPU processing and parallelisation.
29from langchain_chroma import Chroma as _LangChroma
30# locals
31try:
32 from ._embeddingfunctions import CustomEmbeddingFunction
33except ImportError:
34 from docp_dbi.databases._embeddingfunctions import CustomEmbeddingFunction
37class ChromaDB(_LangChroma):
38 r"""Wrapper class around the ``chromadb`` library.
40 Args:
41 path (str): Path to the chromadb database's *directory*.
42 collection (str): Collection name.
43 embedding_model_path (str, optional): Path to a *local* embedding
44 model *repo* of your choosing. Defaults to None.
45 If not provided, the ``SentenceTransformers`` class be be
46 allowed free reign to download or use the model named in the
47 :attr:`DEFAULT_EMBEDDING_MODEL` attribute.
48 repo_id (str, optional): HuggingFace repository ID for the
49 requested embedding model. Defaults to None.
50 offline (bool, optional): Remain offline. Use the local model
51 embedding function model repo rather than obtaining one
52 online. Defaults to False.
54 :Examples:
56 Load a new PDF document into ChromaDB::
58 >>> from docp_dbi import ChromaDB
59 >>> from docp_parsers import PDFParser
60 >>> from langchain_text_splitters import RecursiveCharacterTextSplitter
62 # Parse the PDF document.
63 >>> pdf = PDFParser(path='/path/to/documents/rag-pipelines-how-to.pdf')
64 >>> pdf.extract_text()
66 # Setup a text splitter (for chunking the document).
67 >>> splitter = RecursiveCharacterTextSplitter(
68 ... separators=['\n\n\n', '\n\n', '\n', '.'],
69 ... chunk_size=512,
70 ... chunk_overlap=128
71 ... )
72 # Split the document for storage.
73 >>> docs = splitter.split_documents(pdf.doc.documents)
75 # Create a database interface, using an offline, local user-defined embedding model.
76 >>> db = ChromaDB(path='/path/to/databases/chroma/',
77 ... collection='test',
78 ... embedding_model_path=('/path/to/models/sentence-transformers/'
79 'all-MiniLM-L6-v2'),
80 ... offline=True)
81 # Embed and store the document chunks.
82 >>> db.add_documents(documents=docs)
84 # Run your first query.
85 >>> result = db.collection.query(query_texts=['How do I implement a RAG pipeline?'])
87 """
89 # TODO: Move this to a config file in docp-core.
90 DEFAULT_EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'
92 def __init__(self,
93 path: str,
94 collection: str,
95 *,
96 embedding_model_path: str=None,
97 repo_id: str=None,
98 offline: bool=False):
99 """Chroma database class initialiser."""
100 self._path = os.path.realpath(path)
101 self._cname = collection
102 self._embpath = embedding_model_path
103 self._repo_id = repo_id
104 self._offline = offline
105 self._oclient = None # Database 'client' object
106 self._ocollection = None # Database 'collection' object.
107 self._set_client()
108 self._set_embedding_fn()
109 self._set_collection()
110 super().__init__(client=self._oclient,
111 collection_name=self._cname,
112 embedding_function=self._embfn,
113 persist_directory=self._path)
115 def __repr__(self) -> str:
116 """Define the information about this class to be displayed."""
117 metric = self.collection.__dict__['_model']['configuration_json']['hnsw']['space']
118 return (f'<class: {self.__class__.__name__}>\n'
119 f'Collection: {self.collection.name}\n'
120 f'Similarity metric: {metric}\n'
121 f'Embedding function: {self.embedding_function.model}\n'
122 f'Embedding model path: {self.embedding_function.model_path}')
124 @property
125 def client(self) -> chromadb.api.client.Client: # nocover
126 """Accessor to the :class:`chromadb.PersistentClient` class."""
127 return self._oclient
129 @property
130 def collection(self) -> chromadb.api.models.Collection.Collection: # nocover
131 """Accessor to the chromadb client's collection object."""
132 return self._ocollection
134 @property
135 def embedding_function(self) -> CustomEmbeddingFunction:
136 """Accessor to the embedding function used."""
137 return self._embfn
139 @property
140 def path(self) -> str:
141 """Accessor to the database's path."""
142 return self._path
144 def add_documents(self, documents: list[Document]) -> None: # noqa # pylint: disable=undefined-variable
145 """Add multiple documents to the collection.
147 This method overrides the base class' ``add_documents`` method
148 to enable local ID derivation. Knowing *how* the IDs are derived
149 gives us greater understanding and querying ability of the
150 documents in the database. Each ID is derived locally by the
151 :meth:`_preproc` method from the file's basename, page number
152 and page content.
154 Additionally, this method wraps the
155 :func:`langchain_chroma.Chroma.add_texts` method which supports
156 GPU processing and parallelisation.
158 Args:
159 documents (list[Document]): A list of :class:`Document`
160 objects. These objects can be of either type:
162 - :class:`langchain_core.documents.Document`
163 - :class:`docp_core.objects.documentobject.Document`
165 """
166 # pylint: disable=arguments-differ
167 if not isinstance(documents, list):
168 documents = [documents]
169 ids_, docs_, meta_ = self._preproc(docs=documents)
170 self.add_texts(ids=ids_, texts=docs_, metadatas=meta_)
172 def show_all(self, include: list[str]=None) -> dict:
173 """Return the entire contents of the collection.
175 This is an alias around ``.collection.get()``.
177 Args:
178 include (list[str], optional): A list of what to include in
179 the results. Can contain `"embeddings"`, `"metadatas"`,
180 `"documents"`. IDs are always included.
181 Defaults to `["metadatas", "documents"]`.
183 """
184 include = include or ['metadatas', 'documents']
185 return self._ocollection.get(include=include)
187 def _get_embedding_function_model(self) -> str:
188 """Derive the path to the embedding function model.
190 .. note::
191 If the user has specified a *local* embedding model path,
192 and that path exists, this model path is returned.
194 If the user provides a repository ID, this ID is passed into
195 the :class:`SentenceTransformer` class to handle as
196 appropriate, via the :class:`CustomEmbeddingFunction`. class
198 If the user does not provide either an embedding model path,
199 nor a repository ID, the default embedding model value is
200 used.
202 Raises:
203 FileNotFoundError: If the requested embedding model path does
204 not exist.
206 Returns:
207 str: The path to the local embedding model, the model
208 repository ID or the default embedding model ID.
210 """
211 # User has specified the path to the local model they want to use.
212 if self._embpath:
213 if os.path.exists(self._embpath):
214 return self._embpath
215 raise FileNotFoundError('The requested model path cannot be found:\n'
216 f'-- {self._embpath}')
217 # User has specified repo_id *only*. Lets SentenceTransformer decide how to handle.
218 if self._repo_id and self._embpath is None: # nocover # N/A in testing env.
219 return self._repo_id
220 # User has *not* specified an embedding model path, nor a repo ID.
221 return self.DEFAULT_EMBEDDING_MODEL # nocover # N/A in testing env.
223 @staticmethod
224 def _preproc(docs: list[Document]) -> tuple: # noqa # pylint: disable=undefined-variable
225 """Pre-process the document objects to create the IDs.
227 Parse the ``Document`` object into its parts for storage.
228 Additionally, create the ID as a hash of the source document's
229 basename, page number and content.
231 Args:
232 docs (list[Document]): A list of Document objects which are
233 used to derive the database ID, and split into the
234 return values.
236 Returns:
237 tuple: A tuple containing:
239 - ([ids], [texts], [metadatas])
241 These values are passed into the :meth:`~add_texts` method
242 for embedding and storage.
244 """
245 ids = []
246 txts = []
247 metas = []
248 for doc in docs:
249 pc = doc.page_content
250 m = doc.metadata
251 pc_, src_ = map(str.encode, (pc, m['source']))
252 pg_ = str(m.get('pageno', 0)).zfill(4)
253 id_ = f'id_{md5(src_).hexdigest()}_{pg_}_{md5(pc_).hexdigest()}'
254 ids.append(id_)
255 txts.append(pc)
256 metas.append(m)
257 return ids, txts, metas
259 def _set_client(self) -> None:
260 """Set the database client object.
262 :Note:
263 ChromaDB telemetry has been disabled.
265 """
266 # pylint: disable=no-member # Settings, PersistentClient
267 settings = chromadb.Settings(anonymized_telemetry=False)
268 self._oclient = chromadb.PersistentClient(path=self._path, settings=settings)
270 def _set_collection(self) -> None:
271 """Set the database collection object.
273 If the collection does not already exist, it is created using
274 *cosine* as a similarity metric.
276 """
277 self._ocollection = self._oclient.get_or_create_collection(self._cname,
278 metadata={'hnsw:space':
279 'cosine'},
280 embedding_function=self._embfn)
282 def _set_embedding_fn(self) -> None:
283 """Set the embeddings function object."""
284 path = self._get_embedding_function_model()
285 self._embfn = CustomEmbeddingFunction(embedding_model_path=path,
286 local_files_only=self._offline)