1*5e6dd16eSAndreas Gohr<?php 2*5e6dd16eSAndreas Gohr 3*5e6dd16eSAndreas Gohrnamespace dokuwiki\plugin\aichat\Storage; 4*5e6dd16eSAndreas Gohr 5*5e6dd16eSAndreas Gohruse dokuwiki\HTTP\DokuHTTPClient; 6*5e6dd16eSAndreas Gohruse dokuwiki\plugin\aichat\Chunk; 7*5e6dd16eSAndreas Gohr 8*5e6dd16eSAndreas Gohr/** 9*5e6dd16eSAndreas Gohr * Implements the storage backend using a Chroma DB in server mode 10*5e6dd16eSAndreas Gohr */ 11*5e6dd16eSAndreas Gohrclass ChromaStorage extends AbstractStorage 12*5e6dd16eSAndreas Gohr{ 13*5e6dd16eSAndreas Gohr /** @var string URL to the chroma server instance */ 14*5e6dd16eSAndreas Gohr protected $baseurl; 15*5e6dd16eSAndreas Gohr 16*5e6dd16eSAndreas Gohr /** @var DokuHTTPClient http client */ 17*5e6dd16eSAndreas Gohr protected $http; 18*5e6dd16eSAndreas Gohr 19*5e6dd16eSAndreas Gohr protected $tenant = 'default_tenant'; 20*5e6dd16eSAndreas Gohr protected $database = 'default_database'; 21*5e6dd16eSAndreas Gohr protected $collection = ''; 22*5e6dd16eSAndreas Gohr protected $collectionID = ''; 23*5e6dd16eSAndreas Gohr 24*5e6dd16eSAndreas Gohr /** 25*5e6dd16eSAndreas Gohr * PineconeStorage constructor. 26*5e6dd16eSAndreas Gohr */ 27*5e6dd16eSAndreas Gohr public function __construct() 28*5e6dd16eSAndreas Gohr { 29*5e6dd16eSAndreas Gohr $helper = plugin_load('helper', 'aichat'); 30*5e6dd16eSAndreas Gohr 31*5e6dd16eSAndreas Gohr $this->baseurl = $helper->getConf('chroma_baseurl'); 32*5e6dd16eSAndreas Gohr $this->tenant = $helper->getConf('chroma_tenant'); 33*5e6dd16eSAndreas Gohr $this->database = $helper->getConf('chroma_database'); 34*5e6dd16eSAndreas Gohr $this->collection = $helper->getConf('chroma_collection'); 35*5e6dd16eSAndreas Gohr 36*5e6dd16eSAndreas Gohr $this->http = new DokuHTTPClient(); 37*5e6dd16eSAndreas Gohr $this->http->headers['Content-Type'] = 'application/json'; 38*5e6dd16eSAndreas Gohr $this->http->headers['Accept'] = 'application/json'; 39*5e6dd16eSAndreas Gohr $this->http->keep_alive = false; 40*5e6dd16eSAndreas Gohr $this->http->timeout = 30; 41*5e6dd16eSAndreas Gohr 42*5e6dd16eSAndreas Gohr if($helper->getConf('chroma_apikey')) { 43*5e6dd16eSAndreas Gohr $this->http->headers['Authorization'] = 'Bearer ' . $helper->getConf('chroma_apikey'); 44*5e6dd16eSAndreas Gohr } 45*5e6dd16eSAndreas Gohr } 46*5e6dd16eSAndreas Gohr 47*5e6dd16eSAndreas Gohr /** 48*5e6dd16eSAndreas Gohr * Execute a query against the Chroma API 49*5e6dd16eSAndreas Gohr * 50*5e6dd16eSAndreas Gohr * @param string $endpoint API endpoint, will be added to the base URL 51*5e6dd16eSAndreas Gohr * @param mixed $data The data to send, will be JSON encoded 52*5e6dd16eSAndreas Gohr * @param string $method POST|GET 53*5e6dd16eSAndreas Gohr * @return mixed 54*5e6dd16eSAndreas Gohr * @throws \Exception 55*5e6dd16eSAndreas Gohr */ 56*5e6dd16eSAndreas Gohr protected function runQuery($endpoint, $data, $method = 'POST') 57*5e6dd16eSAndreas Gohr { 58*5e6dd16eSAndreas Gohr $url = $this->baseurl . '/api/v1' . $endpoint . '?tenant=' . $this->tenant . '&database=' . $this->database; 59*5e6dd16eSAndreas Gohr 60*5e6dd16eSAndreas Gohr if (is_array($data) && $data === []) { 61*5e6dd16eSAndreas Gohr $json = '{}'; 62*5e6dd16eSAndreas Gohr } else { 63*5e6dd16eSAndreas Gohr $json = json_encode($data); 64*5e6dd16eSAndreas Gohr } 65*5e6dd16eSAndreas Gohr 66*5e6dd16eSAndreas Gohr $this->http->sendRequest($url, $json, $method); 67*5e6dd16eSAndreas Gohr $response = $this->http->resp_body; 68*5e6dd16eSAndreas Gohr 69*5e6dd16eSAndreas Gohr if (!$response) { 70*5e6dd16eSAndreas Gohr throw new \Exception('Chroma API returned no response. ' . $this->http->error); 71*5e6dd16eSAndreas Gohr } 72*5e6dd16eSAndreas Gohr 73*5e6dd16eSAndreas Gohr try { 74*5e6dd16eSAndreas Gohr $result = json_decode($response, true, 512, JSON_THROW_ON_ERROR); 75*5e6dd16eSAndreas Gohr } catch (\Exception $e) { 76*5e6dd16eSAndreas Gohr throw new \Exception('Chroma API returned invalid JSON. ' . $response); 77*5e6dd16eSAndreas Gohr } 78*5e6dd16eSAndreas Gohr 79*5e6dd16eSAndreas Gohr if ((int)$this->http->status !== 200) { 80*5e6dd16eSAndreas Gohr if (isset($result['detail'][0]['msg'])) { 81*5e6dd16eSAndreas Gohr $error = $result['detail'][0]['msg']; 82*5e6dd16eSAndreas Gohr } else if (isset($result['detail']['msg'])) { 83*5e6dd16eSAndreas Gohr $error = $result['detail']['msg']; 84*5e6dd16eSAndreas Gohr } else if (isset($result['detail']) && is_string($result['detail'])) { 85*5e6dd16eSAndreas Gohr $error = $result['detail']; 86*5e6dd16eSAndreas Gohr } else if (isset($result['error'])) { 87*5e6dd16eSAndreas Gohr $error = $result['error']; 88*5e6dd16eSAndreas Gohr } else { 89*5e6dd16eSAndreas Gohr $error = $this->http->error; 90*5e6dd16eSAndreas Gohr } 91*5e6dd16eSAndreas Gohr 92*5e6dd16eSAndreas Gohr throw new \Exception('Chroma API returned error. ' . $error); 93*5e6dd16eSAndreas Gohr } 94*5e6dd16eSAndreas Gohr 95*5e6dd16eSAndreas Gohr return $result; 96*5e6dd16eSAndreas Gohr } 97*5e6dd16eSAndreas Gohr 98*5e6dd16eSAndreas Gohr /** 99*5e6dd16eSAndreas Gohr * Get the collection ID for the configured collection 100*5e6dd16eSAndreas Gohr * 101*5e6dd16eSAndreas Gohr * @return string 102*5e6dd16eSAndreas Gohr * @throws \Exception 103*5e6dd16eSAndreas Gohr */ 104*5e6dd16eSAndreas Gohr protected function getCollectionID() 105*5e6dd16eSAndreas Gohr { 106*5e6dd16eSAndreas Gohr if ($this->collectionID) return $this->collectionID; 107*5e6dd16eSAndreas Gohr 108*5e6dd16eSAndreas Gohr $result = $this->runQuery( 109*5e6dd16eSAndreas Gohr '/collections/', 110*5e6dd16eSAndreas Gohr [ 111*5e6dd16eSAndreas Gohr 'name' => $this->collection, 112*5e6dd16eSAndreas Gohr 'get_or_create' => true 113*5e6dd16eSAndreas Gohr ] 114*5e6dd16eSAndreas Gohr ); 115*5e6dd16eSAndreas Gohr $this->collectionID = $result['id']; 116*5e6dd16eSAndreas Gohr return $this->collectionID; 117*5e6dd16eSAndreas Gohr } 118*5e6dd16eSAndreas Gohr 119*5e6dd16eSAndreas Gohr /** @inheritdoc */ 120*5e6dd16eSAndreas Gohr public function getChunk($chunkID) 121*5e6dd16eSAndreas Gohr { 122*5e6dd16eSAndreas Gohr $data = $this->runQuery( 123*5e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/get', 124*5e6dd16eSAndreas Gohr [ 125*5e6dd16eSAndreas Gohr 'ids' => [(string)$chunkID], 126*5e6dd16eSAndreas Gohr 'include' => [ 127*5e6dd16eSAndreas Gohr 'metadatas', 128*5e6dd16eSAndreas Gohr 'documents', 129*5e6dd16eSAndreas Gohr 'embeddings' 130*5e6dd16eSAndreas Gohr ] 131*5e6dd16eSAndreas Gohr ] 132*5e6dd16eSAndreas Gohr ); 133*5e6dd16eSAndreas Gohr 134*5e6dd16eSAndreas Gohr if (!$data) return null; 135*5e6dd16eSAndreas Gohr if (!$data['ids']) return null; 136*5e6dd16eSAndreas Gohr 137*5e6dd16eSAndreas Gohr return new Chunk( 138*5e6dd16eSAndreas Gohr $data['metadatas'][0]['page'], 139*5e6dd16eSAndreas Gohr (int)$data['ids'][0], 140*5e6dd16eSAndreas Gohr $data['documents'][0], 141*5e6dd16eSAndreas Gohr $data['embeddings'][0], 142*5e6dd16eSAndreas Gohr $data['metadatas'][0]['language'] ?? '', 143*5e6dd16eSAndreas Gohr $data['metadatas'][0]['created'] 144*5e6dd16eSAndreas Gohr ); 145*5e6dd16eSAndreas Gohr } 146*5e6dd16eSAndreas Gohr 147*5e6dd16eSAndreas Gohr /** @inheritdoc */ 148*5e6dd16eSAndreas Gohr public function startCreation($clear = false) 149*5e6dd16eSAndreas Gohr { 150*5e6dd16eSAndreas Gohr if ($clear) { 151*5e6dd16eSAndreas Gohr $this->runQuery('/collections/' . $this->collection, '', 'DELETE'); 152*5e6dd16eSAndreas Gohr $this->collectionID = ''; 153*5e6dd16eSAndreas Gohr } 154*5e6dd16eSAndreas Gohr } 155*5e6dd16eSAndreas Gohr 156*5e6dd16eSAndreas Gohr /** @inheritdoc */ 157*5e6dd16eSAndreas Gohr public function reusePageChunks($page, $firstChunkID) 158*5e6dd16eSAndreas Gohr { 159*5e6dd16eSAndreas Gohr // no-op 160*5e6dd16eSAndreas Gohr } 161*5e6dd16eSAndreas Gohr 162*5e6dd16eSAndreas Gohr /** @inheritdoc */ 163*5e6dd16eSAndreas Gohr public function deletePageChunks($page, $firstChunkID) 164*5e6dd16eSAndreas Gohr { 165*5e6dd16eSAndreas Gohr // delete all possible chunk IDs 166*5e6dd16eSAndreas Gohr $ids = range($firstChunkID, $firstChunkID + 99, 1); 167*5e6dd16eSAndreas Gohr $ids = array_map(function ($id) { 168*5e6dd16eSAndreas Gohr return (string)$id; 169*5e6dd16eSAndreas Gohr }, $ids); 170*5e6dd16eSAndreas Gohr $this->runQuery( 171*5e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/delete', 172*5e6dd16eSAndreas Gohr [ 173*5e6dd16eSAndreas Gohr 'ids' => $ids 174*5e6dd16eSAndreas Gohr ] 175*5e6dd16eSAndreas Gohr ); 176*5e6dd16eSAndreas Gohr } 177*5e6dd16eSAndreas Gohr 178*5e6dd16eSAndreas Gohr /** @inheritdoc */ 179*5e6dd16eSAndreas Gohr public function addPageChunks($chunks) 180*5e6dd16eSAndreas Gohr { 181*5e6dd16eSAndreas Gohr $ids = []; 182*5e6dd16eSAndreas Gohr $embeddings = []; 183*5e6dd16eSAndreas Gohr $metadatas = []; 184*5e6dd16eSAndreas Gohr $documents = []; 185*5e6dd16eSAndreas Gohr 186*5e6dd16eSAndreas Gohr foreach ($chunks as $chunk) { 187*5e6dd16eSAndreas Gohr $ids[] = (string)$chunk->getId(); 188*5e6dd16eSAndreas Gohr $embeddings[] = $chunk->getEmbedding(); 189*5e6dd16eSAndreas Gohr $metadatas[] = [ 190*5e6dd16eSAndreas Gohr 'page' => $chunk->getPage(), 191*5e6dd16eSAndreas Gohr 'created' => $chunk->getCreated(), 192*5e6dd16eSAndreas Gohr 'language' => $chunk->getLanguage() 193*5e6dd16eSAndreas Gohr ]; 194*5e6dd16eSAndreas Gohr $documents[] = $chunk->getText(); 195*5e6dd16eSAndreas Gohr 196*5e6dd16eSAndreas Gohr } 197*5e6dd16eSAndreas Gohr 198*5e6dd16eSAndreas Gohr $this->runQuery( 199*5e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/upsert', 200*5e6dd16eSAndreas Gohr [ 201*5e6dd16eSAndreas Gohr 'ids' => $ids, 202*5e6dd16eSAndreas Gohr 'embeddings' => $embeddings, 203*5e6dd16eSAndreas Gohr 'metadatas' => $metadatas, 204*5e6dd16eSAndreas Gohr 'documents' => $documents 205*5e6dd16eSAndreas Gohr ] 206*5e6dd16eSAndreas Gohr ); 207*5e6dd16eSAndreas Gohr } 208*5e6dd16eSAndreas Gohr 209*5e6dd16eSAndreas Gohr /** @inheritdoc */ 210*5e6dd16eSAndreas Gohr public function finalizeCreation() 211*5e6dd16eSAndreas Gohr { 212*5e6dd16eSAndreas Gohr // no-op 213*5e6dd16eSAndreas Gohr } 214*5e6dd16eSAndreas Gohr 215*5e6dd16eSAndreas Gohr /** @inheritdoc */ 216*5e6dd16eSAndreas Gohr public function runMaintenance() 217*5e6dd16eSAndreas Gohr { 218*5e6dd16eSAndreas Gohr // no-op 219*5e6dd16eSAndreas Gohr } 220*5e6dd16eSAndreas Gohr 221*5e6dd16eSAndreas Gohr /** @inheritdoc */ 222*5e6dd16eSAndreas Gohr public function getPageChunks($page, $firstChunkID) 223*5e6dd16eSAndreas Gohr { 224*5e6dd16eSAndreas Gohr $ids = range($firstChunkID, $firstChunkID + 99, 1); 225*5e6dd16eSAndreas Gohr $ids = array_map(function ($id) { 226*5e6dd16eSAndreas Gohr return (string)$id; 227*5e6dd16eSAndreas Gohr }, $ids); 228*5e6dd16eSAndreas Gohr 229*5e6dd16eSAndreas Gohr $data = $this->runQuery( 230*5e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/get', 231*5e6dd16eSAndreas Gohr [ 232*5e6dd16eSAndreas Gohr 'ids' => $ids, 233*5e6dd16eSAndreas Gohr 'include' => [ 234*5e6dd16eSAndreas Gohr 'metadatas', 235*5e6dd16eSAndreas Gohr 'documents', 236*5e6dd16eSAndreas Gohr 'embeddings' 237*5e6dd16eSAndreas Gohr ], 238*5e6dd16eSAndreas Gohr 'limit' => 100, 239*5e6dd16eSAndreas Gohr ] 240*5e6dd16eSAndreas Gohr ); 241*5e6dd16eSAndreas Gohr 242*5e6dd16eSAndreas Gohr if (!$data) return []; 243*5e6dd16eSAndreas Gohr if (!$data['ids']) return null; 244*5e6dd16eSAndreas Gohr 245*5e6dd16eSAndreas Gohr $chunks = []; 246*5e6dd16eSAndreas Gohr foreach ($data['ids'] as $idx => $id) { 247*5e6dd16eSAndreas Gohr $chunks[] = new Chunk( 248*5e6dd16eSAndreas Gohr $data['metadatas'][$idx]['page'], 249*5e6dd16eSAndreas Gohr (int)$id, 250*5e6dd16eSAndreas Gohr $data['documents'][$idx], 251*5e6dd16eSAndreas Gohr $data['embeddings'][$idx], 252*5e6dd16eSAndreas Gohr $data['metadatas'][$idx]['language'] ?? '', 253*5e6dd16eSAndreas Gohr $data['metadatas'][$idx]['created'] 254*5e6dd16eSAndreas Gohr ); 255*5e6dd16eSAndreas Gohr } 256*5e6dd16eSAndreas Gohr return $chunks; 257*5e6dd16eSAndreas Gohr } 258*5e6dd16eSAndreas Gohr 259*5e6dd16eSAndreas Gohr /** @inheritdoc */ 260*5e6dd16eSAndreas Gohr public function getSimilarChunks($vector, $lang = '', $limit = 4) 261*5e6dd16eSAndreas Gohr { 262*5e6dd16eSAndreas Gohr $limit *= 2; // we can't check ACLs, so we return more than requested 263*5e6dd16eSAndreas Gohr 264*5e6dd16eSAndreas Gohr if ($lang) { 265*5e6dd16eSAndreas Gohr $filter = ['language' => ['$eq', $lang]]; 266*5e6dd16eSAndreas Gohr } else { 267*5e6dd16eSAndreas Gohr $filter = null; 268*5e6dd16eSAndreas Gohr } 269*5e6dd16eSAndreas Gohr 270*5e6dd16eSAndreas Gohr $data = $this->runQuery( 271*5e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/query', 272*5e6dd16eSAndreas Gohr [ 273*5e6dd16eSAndreas Gohr 'query_embeddings' => [$vector], 274*5e6dd16eSAndreas Gohr 'n_results' => (int)$limit, 275*5e6dd16eSAndreas Gohr 'where' => $filter, 276*5e6dd16eSAndreas Gohr 'include' => [ 277*5e6dd16eSAndreas Gohr 'metadatas', 278*5e6dd16eSAndreas Gohr 'documents', 279*5e6dd16eSAndreas Gohr 'embeddings', 280*5e6dd16eSAndreas Gohr 'distances', 281*5e6dd16eSAndreas Gohr ] 282*5e6dd16eSAndreas Gohr ] 283*5e6dd16eSAndreas Gohr ); 284*5e6dd16eSAndreas Gohr 285*5e6dd16eSAndreas Gohr $chunks = []; 286*5e6dd16eSAndreas Gohr foreach ($data['ids'][0] as $idx => $id) { 287*5e6dd16eSAndreas Gohr $chunks[] = new Chunk( 288*5e6dd16eSAndreas Gohr $data['metadatas'][0][$idx]['page'], 289*5e6dd16eSAndreas Gohr (int)$id, 290*5e6dd16eSAndreas Gohr $data['documents'][0][$idx], 291*5e6dd16eSAndreas Gohr $data['embeddings'][0][$idx], 292*5e6dd16eSAndreas Gohr $data['metadatas'][0][$idx]['language'] ?? '', 293*5e6dd16eSAndreas Gohr $data['metadatas'][0][$idx]['created'], 294*5e6dd16eSAndreas Gohr $data['distances'][0][$idx] 295*5e6dd16eSAndreas Gohr ); 296*5e6dd16eSAndreas Gohr } 297*5e6dd16eSAndreas Gohr return $chunks; 298*5e6dd16eSAndreas Gohr } 299*5e6dd16eSAndreas Gohr 300*5e6dd16eSAndreas Gohr /** @inheritdoc */ 301*5e6dd16eSAndreas Gohr public function statistics() 302*5e6dd16eSAndreas Gohr { 303*5e6dd16eSAndreas Gohr $count = $this->runQuery('/collections/' . $this->getCollectionID() . '/count', '', 'GET'); 304*5e6dd16eSAndreas Gohr $version = $this->runQuery('/version', '', 'GET'); 305*5e6dd16eSAndreas Gohr 306*5e6dd16eSAndreas Gohr return [ 307*5e6dd16eSAndreas Gohr 'chroma_version' => $version, 308*5e6dd16eSAndreas Gohr 'collection_id' => $this->getCollectionID(), 309*5e6dd16eSAndreas Gohr 'chunks' => $count 310*5e6dd16eSAndreas Gohr ]; 311*5e6dd16eSAndreas Gohr } 312*5e6dd16eSAndreas Gohr} 313