15e6dd16eSAndreas Gohr<?php 25e6dd16eSAndreas Gohr 35e6dd16eSAndreas Gohrnamespace dokuwiki\plugin\aichat\Storage; 45e6dd16eSAndreas Gohr 55e6dd16eSAndreas Gohruse dokuwiki\HTTP\DokuHTTPClient; 65e6dd16eSAndreas Gohruse dokuwiki\plugin\aichat\Chunk; 75e6dd16eSAndreas Gohr 85e6dd16eSAndreas Gohr/** 95e6dd16eSAndreas Gohr * Implements the storage backend using a Chroma DB in server mode 105e6dd16eSAndreas Gohr */ 115e6dd16eSAndreas Gohrclass ChromaStorage extends AbstractStorage 125e6dd16eSAndreas Gohr{ 135e6dd16eSAndreas Gohr /** @var string URL to the chroma server instance */ 145e6dd16eSAndreas Gohr protected $baseurl; 155e6dd16eSAndreas Gohr 165e6dd16eSAndreas Gohr /** @var DokuHTTPClient http client */ 175e6dd16eSAndreas Gohr protected $http; 185e6dd16eSAndreas Gohr 195e6dd16eSAndreas Gohr protected $tenant = 'default_tenant'; 205e6dd16eSAndreas Gohr protected $database = 'default_database'; 215e6dd16eSAndreas Gohr protected $collection = ''; 225e6dd16eSAndreas Gohr protected $collectionID = ''; 235e6dd16eSAndreas Gohr 245e6dd16eSAndreas Gohr /** 25*4c0099a8SAndreas Gohr * ChromaStorage constructor. 265e6dd16eSAndreas Gohr */ 275e6dd16eSAndreas Gohr public function __construct() 285e6dd16eSAndreas Gohr { 295e6dd16eSAndreas Gohr $helper = plugin_load('helper', 'aichat'); 305e6dd16eSAndreas Gohr 315e6dd16eSAndreas Gohr $this->baseurl = $helper->getConf('chroma_baseurl'); 325e6dd16eSAndreas Gohr $this->tenant = $helper->getConf('chroma_tenant'); 335e6dd16eSAndreas Gohr $this->database = $helper->getConf('chroma_database'); 345e6dd16eSAndreas Gohr $this->collection = $helper->getConf('chroma_collection'); 355e6dd16eSAndreas Gohr 365e6dd16eSAndreas Gohr $this->http = new DokuHTTPClient(); 375e6dd16eSAndreas Gohr $this->http->headers['Content-Type'] = 'application/json'; 385e6dd16eSAndreas Gohr $this->http->headers['Accept'] = 'application/json'; 395e6dd16eSAndreas Gohr $this->http->keep_alive = false; 405e6dd16eSAndreas Gohr $this->http->timeout = 30; 415e6dd16eSAndreas Gohr 425e6dd16eSAndreas Gohr if ($helper->getConf('chroma_apikey')) { 435e6dd16eSAndreas Gohr $this->http->headers['Authorization'] = 'Bearer ' . $helper->getConf('chroma_apikey'); 445e6dd16eSAndreas Gohr } 455e6dd16eSAndreas Gohr } 465e6dd16eSAndreas Gohr 475e6dd16eSAndreas Gohr /** 485e6dd16eSAndreas Gohr * Execute a query against the Chroma API 495e6dd16eSAndreas Gohr * 505e6dd16eSAndreas Gohr * @param string $endpoint API endpoint, will be added to the base URL 515e6dd16eSAndreas Gohr * @param mixed $data The data to send, will be JSON encoded 525e6dd16eSAndreas Gohr * @param string $method POST|GET 535e6dd16eSAndreas Gohr * @return mixed 545e6dd16eSAndreas Gohr * @throws \Exception 555e6dd16eSAndreas Gohr */ 5630b9cbc7Ssplitbrain protected function runQuery($endpoint, mixed $data, $method = 'POST') 575e6dd16eSAndreas Gohr { 585e6dd16eSAndreas Gohr $url = $this->baseurl . '/api/v1' . $endpoint . '?tenant=' . $this->tenant . '&database=' . $this->database; 595e6dd16eSAndreas Gohr 60*4c0099a8SAndreas Gohr if ($data === []) { 615e6dd16eSAndreas Gohr $json = '{}'; 625e6dd16eSAndreas Gohr } else { 6330b9cbc7Ssplitbrain $json = json_encode($data, JSON_THROW_ON_ERROR); 645e6dd16eSAndreas Gohr } 655e6dd16eSAndreas Gohr 665e6dd16eSAndreas Gohr $this->http->sendRequest($url, $json, $method); 675e6dd16eSAndreas Gohr $response = $this->http->resp_body; 685e6dd16eSAndreas Gohr 695e6dd16eSAndreas Gohr if (!$response) { 705e6dd16eSAndreas Gohr throw new \Exception('Chroma API returned no response. ' . $this->http->error); 715e6dd16eSAndreas Gohr } 725e6dd16eSAndreas Gohr 735e6dd16eSAndreas Gohr try { 7430b9cbc7Ssplitbrain $result = json_decode((string) $response, true, 512, JSON_THROW_ON_ERROR); 7530b9cbc7Ssplitbrain } catch (\Exception) { 765e6dd16eSAndreas Gohr throw new \Exception('Chroma API returned invalid JSON. ' . $response); 775e6dd16eSAndreas Gohr } 785e6dd16eSAndreas Gohr 795e6dd16eSAndreas Gohr if ((int)$this->http->status !== 200) { 805e6dd16eSAndreas Gohr if (isset($result['detail'][0]['msg'])) { 815e6dd16eSAndreas Gohr $error = $result['detail'][0]['msg']; 825e6dd16eSAndreas Gohr } elseif (isset($result['detail']['msg'])) { 835e6dd16eSAndreas Gohr $error = $result['detail']['msg']; 845e6dd16eSAndreas Gohr } elseif (isset($result['detail']) && is_string($result['detail'])) { 855e6dd16eSAndreas Gohr $error = $result['detail']; 865e6dd16eSAndreas Gohr } elseif (isset($result['error'])) { 875e6dd16eSAndreas Gohr $error = $result['error']; 885e6dd16eSAndreas Gohr } else { 895e6dd16eSAndreas Gohr $error = $this->http->error; 905e6dd16eSAndreas Gohr } 915e6dd16eSAndreas Gohr 925e6dd16eSAndreas Gohr throw new \Exception('Chroma API returned error. ' . $error); 935e6dd16eSAndreas Gohr } 945e6dd16eSAndreas Gohr 955e6dd16eSAndreas Gohr return $result; 965e6dd16eSAndreas Gohr } 975e6dd16eSAndreas Gohr 985e6dd16eSAndreas Gohr /** 995e6dd16eSAndreas Gohr * Get the collection ID for the configured collection 1005e6dd16eSAndreas Gohr * 1015e6dd16eSAndreas Gohr * @return string 1025e6dd16eSAndreas Gohr * @throws \Exception 1035e6dd16eSAndreas Gohr */ 1045e6dd16eSAndreas Gohr protected function getCollectionID() 1055e6dd16eSAndreas Gohr { 1065e6dd16eSAndreas Gohr if ($this->collectionID) return $this->collectionID; 1075e6dd16eSAndreas Gohr 1085e6dd16eSAndreas Gohr $result = $this->runQuery( 1095e6dd16eSAndreas Gohr '/collections/', 1105e6dd16eSAndreas Gohr [ 1115e6dd16eSAndreas Gohr 'name' => $this->collection, 1125e6dd16eSAndreas Gohr 'get_or_create' => true 1135e6dd16eSAndreas Gohr ] 1145e6dd16eSAndreas Gohr ); 1155e6dd16eSAndreas Gohr $this->collectionID = $result['id']; 1165e6dd16eSAndreas Gohr return $this->collectionID; 1175e6dd16eSAndreas Gohr } 1185e6dd16eSAndreas Gohr 1195e6dd16eSAndreas Gohr /** @inheritdoc */ 1205e6dd16eSAndreas Gohr public function getChunk($chunkID) 1215e6dd16eSAndreas Gohr { 1225e6dd16eSAndreas Gohr $data = $this->runQuery( 1235e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/get', 1245e6dd16eSAndreas Gohr [ 1255e6dd16eSAndreas Gohr 'ids' => [(string)$chunkID], 1265e6dd16eSAndreas Gohr 'include' => [ 1275e6dd16eSAndreas Gohr 'metadatas', 1285e6dd16eSAndreas Gohr 'documents', 1295e6dd16eSAndreas Gohr 'embeddings' 1305e6dd16eSAndreas Gohr ] 1315e6dd16eSAndreas Gohr ] 1325e6dd16eSAndreas Gohr ); 1335e6dd16eSAndreas Gohr 1345e6dd16eSAndreas Gohr if (!$data) return null; 1355e6dd16eSAndreas Gohr if (!$data['ids']) return null; 1365e6dd16eSAndreas Gohr 1375e6dd16eSAndreas Gohr return new Chunk( 1385e6dd16eSAndreas Gohr $data['metadatas'][0]['page'], 1395e6dd16eSAndreas Gohr (int)$data['ids'][0], 1405e6dd16eSAndreas Gohr $data['documents'][0], 1415e6dd16eSAndreas Gohr $data['embeddings'][0], 1425e6dd16eSAndreas Gohr $data['metadatas'][0]['language'] ?? '', 1435e6dd16eSAndreas Gohr $data['metadatas'][0]['created'] 1445e6dd16eSAndreas Gohr ); 1455e6dd16eSAndreas Gohr } 1465e6dd16eSAndreas Gohr 1475e6dd16eSAndreas Gohr /** @inheritdoc */ 1485e6dd16eSAndreas Gohr public function startCreation($clear = false) 1495e6dd16eSAndreas Gohr { 1505e6dd16eSAndreas Gohr if ($clear) { 1515e6dd16eSAndreas Gohr $this->runQuery('/collections/' . $this->collection, '', 'DELETE'); 1525e6dd16eSAndreas Gohr $this->collectionID = ''; 1535e6dd16eSAndreas Gohr } 1545e6dd16eSAndreas Gohr } 1555e6dd16eSAndreas Gohr 1565e6dd16eSAndreas Gohr /** @inheritdoc */ 1575e6dd16eSAndreas Gohr public function reusePageChunks($page, $firstChunkID) 1585e6dd16eSAndreas Gohr { 1595e6dd16eSAndreas Gohr // no-op 1605e6dd16eSAndreas Gohr } 1615e6dd16eSAndreas Gohr 1625e6dd16eSAndreas Gohr /** @inheritdoc */ 1635e6dd16eSAndreas Gohr public function deletePageChunks($page, $firstChunkID) 1645e6dd16eSAndreas Gohr { 1655e6dd16eSAndreas Gohr // delete all possible chunk IDs 1665e6dd16eSAndreas Gohr $ids = range($firstChunkID, $firstChunkID + 99, 1); 16730b9cbc7Ssplitbrain $ids = array_map(static fn($id) => (string)$id, $ids); 1685e6dd16eSAndreas Gohr $this->runQuery( 1695e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/delete', 1705e6dd16eSAndreas Gohr [ 1715e6dd16eSAndreas Gohr 'ids' => $ids 1725e6dd16eSAndreas Gohr ] 1735e6dd16eSAndreas Gohr ); 1745e6dd16eSAndreas Gohr } 1755e6dd16eSAndreas Gohr 1765e6dd16eSAndreas Gohr /** @inheritdoc */ 1775e6dd16eSAndreas Gohr public function addPageChunks($chunks) 1785e6dd16eSAndreas Gohr { 1795e6dd16eSAndreas Gohr $ids = []; 1805e6dd16eSAndreas Gohr $embeddings = []; 1815e6dd16eSAndreas Gohr $metadatas = []; 1825e6dd16eSAndreas Gohr $documents = []; 1835e6dd16eSAndreas Gohr 1845e6dd16eSAndreas Gohr foreach ($chunks as $chunk) { 1855e6dd16eSAndreas Gohr $ids[] = (string)$chunk->getId(); 1865e6dd16eSAndreas Gohr $embeddings[] = $chunk->getEmbedding(); 1875e6dd16eSAndreas Gohr $metadatas[] = [ 1885e6dd16eSAndreas Gohr 'page' => $chunk->getPage(), 1895e6dd16eSAndreas Gohr 'created' => $chunk->getCreated(), 1905e6dd16eSAndreas Gohr 'language' => $chunk->getLanguage() 1915e6dd16eSAndreas Gohr ]; 1925e6dd16eSAndreas Gohr $documents[] = $chunk->getText(); 1935e6dd16eSAndreas Gohr } 1945e6dd16eSAndreas Gohr 1955e6dd16eSAndreas Gohr $this->runQuery( 1965e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/upsert', 1975e6dd16eSAndreas Gohr [ 1985e6dd16eSAndreas Gohr 'ids' => $ids, 1995e6dd16eSAndreas Gohr 'embeddings' => $embeddings, 2005e6dd16eSAndreas Gohr 'metadatas' => $metadatas, 2015e6dd16eSAndreas Gohr 'documents' => $documents 2025e6dd16eSAndreas Gohr ] 2035e6dd16eSAndreas Gohr ); 2045e6dd16eSAndreas Gohr } 2055e6dd16eSAndreas Gohr 2065e6dd16eSAndreas Gohr /** @inheritdoc */ 2075e6dd16eSAndreas Gohr public function finalizeCreation() 2085e6dd16eSAndreas Gohr { 2095e6dd16eSAndreas Gohr // no-op 2105e6dd16eSAndreas Gohr } 2115e6dd16eSAndreas Gohr 2125e6dd16eSAndreas Gohr /** @inheritdoc */ 2135e6dd16eSAndreas Gohr public function runMaintenance() 2145e6dd16eSAndreas Gohr { 2155e6dd16eSAndreas Gohr // no-op 2165e6dd16eSAndreas Gohr } 2175e6dd16eSAndreas Gohr 2185e6dd16eSAndreas Gohr /** @inheritdoc */ 2195e6dd16eSAndreas Gohr public function getPageChunks($page, $firstChunkID) 2205e6dd16eSAndreas Gohr { 2215e6dd16eSAndreas Gohr $ids = range($firstChunkID, $firstChunkID + 99, 1); 22230b9cbc7Ssplitbrain $ids = array_map(static fn($id) => (string)$id, $ids); 2235e6dd16eSAndreas Gohr 2245e6dd16eSAndreas Gohr $data = $this->runQuery( 2255e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/get', 2265e6dd16eSAndreas Gohr [ 2275e6dd16eSAndreas Gohr 'ids' => $ids, 2285e6dd16eSAndreas Gohr 'include' => [ 2295e6dd16eSAndreas Gohr 'metadatas', 2305e6dd16eSAndreas Gohr 'documents', 2315e6dd16eSAndreas Gohr 'embeddings' 2325e6dd16eSAndreas Gohr ], 2335e6dd16eSAndreas Gohr 'limit' => 100, 2345e6dd16eSAndreas Gohr ] 2355e6dd16eSAndreas Gohr ); 2365e6dd16eSAndreas Gohr 2375e6dd16eSAndreas Gohr if (!$data) return []; 2385e6dd16eSAndreas Gohr if (!$data['ids']) return null; 2395e6dd16eSAndreas Gohr 2405e6dd16eSAndreas Gohr $chunks = []; 2415e6dd16eSAndreas Gohr foreach ($data['ids'] as $idx => $id) { 2425e6dd16eSAndreas Gohr $chunks[] = new Chunk( 2435e6dd16eSAndreas Gohr $data['metadatas'][$idx]['page'], 2445e6dd16eSAndreas Gohr (int)$id, 2455e6dd16eSAndreas Gohr $data['documents'][$idx], 2465e6dd16eSAndreas Gohr $data['embeddings'][$idx], 2475e6dd16eSAndreas Gohr $data['metadatas'][$idx]['language'] ?? '', 2485e6dd16eSAndreas Gohr $data['metadatas'][$idx]['created'] 2495e6dd16eSAndreas Gohr ); 2505e6dd16eSAndreas Gohr } 2515e6dd16eSAndreas Gohr return $chunks; 2525e6dd16eSAndreas Gohr } 2535e6dd16eSAndreas Gohr 2545e6dd16eSAndreas Gohr /** @inheritdoc */ 2555e6dd16eSAndreas Gohr public function getSimilarChunks($vector, $lang = '', $limit = 4) 2565e6dd16eSAndreas Gohr { 2575e6dd16eSAndreas Gohr $limit *= 2; // we can't check ACLs, so we return more than requested 2585e6dd16eSAndreas Gohr 2595e6dd16eSAndreas Gohr if ($lang) { 260*4c0099a8SAndreas Gohr $filter = ['language' => $lang]; 2615e6dd16eSAndreas Gohr } else { 2625e6dd16eSAndreas Gohr $filter = null; 2635e6dd16eSAndreas Gohr } 2645e6dd16eSAndreas Gohr 2655e6dd16eSAndreas Gohr $data = $this->runQuery( 2665e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/query', 2675e6dd16eSAndreas Gohr [ 2685e6dd16eSAndreas Gohr 'query_embeddings' => [$vector], 2695e6dd16eSAndreas Gohr 'n_results' => (int)$limit, 2705e6dd16eSAndreas Gohr 'where' => $filter, 2715e6dd16eSAndreas Gohr 'include' => [ 2725e6dd16eSAndreas Gohr 'metadatas', 2735e6dd16eSAndreas Gohr 'documents', 2745e6dd16eSAndreas Gohr 'embeddings', 2755e6dd16eSAndreas Gohr 'distances', 2765e6dd16eSAndreas Gohr ] 2775e6dd16eSAndreas Gohr ] 2785e6dd16eSAndreas Gohr ); 2795e6dd16eSAndreas Gohr 2805e6dd16eSAndreas Gohr $chunks = []; 2815e6dd16eSAndreas Gohr foreach ($data['ids'][0] as $idx => $id) { 2825e6dd16eSAndreas Gohr $chunks[] = new Chunk( 2835e6dd16eSAndreas Gohr $data['metadatas'][0][$idx]['page'], 2845e6dd16eSAndreas Gohr (int)$id, 2855e6dd16eSAndreas Gohr $data['documents'][0][$idx], 2865e6dd16eSAndreas Gohr $data['embeddings'][0][$idx], 2875e6dd16eSAndreas Gohr $data['metadatas'][0][$idx]['language'] ?? '', 2885e6dd16eSAndreas Gohr $data['metadatas'][0][$idx]['created'], 2895e6dd16eSAndreas Gohr $data['distances'][0][$idx] 2905e6dd16eSAndreas Gohr ); 2915e6dd16eSAndreas Gohr } 2925e6dd16eSAndreas Gohr return $chunks; 2935e6dd16eSAndreas Gohr } 2945e6dd16eSAndreas Gohr 2955e6dd16eSAndreas Gohr /** @inheritdoc */ 2965e6dd16eSAndreas Gohr public function statistics() 2975e6dd16eSAndreas Gohr { 2985e6dd16eSAndreas Gohr $count = $this->runQuery('/collections/' . $this->getCollectionID() . '/count', '', 'GET'); 2995e6dd16eSAndreas Gohr $version = $this->runQuery('/version', '', 'GET'); 3005e6dd16eSAndreas Gohr 3015e6dd16eSAndreas Gohr return [ 3025e6dd16eSAndreas Gohr 'chroma_version' => $version, 3035e6dd16eSAndreas Gohr 'collection_id' => $this->getCollectionID(), 3045e6dd16eSAndreas Gohr 'chunks' => $count 3055e6dd16eSAndreas Gohr ]; 3065e6dd16eSAndreas Gohr } 3075e6dd16eSAndreas Gohr} 308