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 2404afb84fSAndreas Gohr /** @inheritdoc */ 2504afb84fSAndreas Gohr public function __construct(array $config) 265e6dd16eSAndreas Gohr { 2704afb84fSAndreas Gohr $this->baseurl = $config['chroma_baseurl'] ?? ''; 2804afb84fSAndreas Gohr $this->tenant = $config['chroma_tenant'] ?? ''; 2904afb84fSAndreas Gohr $this->database = $config['chroma_database'] ?? ''; 3004afb84fSAndreas Gohr $this->collection = $config['chroma_collection'] ?? ''; 315e6dd16eSAndreas Gohr 325e6dd16eSAndreas Gohr $this->http = new DokuHTTPClient(); 335e6dd16eSAndreas Gohr $this->http->headers['Content-Type'] = 'application/json'; 345e6dd16eSAndreas Gohr $this->http->headers['Accept'] = 'application/json'; 355e6dd16eSAndreas Gohr $this->http->keep_alive = false; 365e6dd16eSAndreas Gohr $this->http->timeout = 30; 375e6dd16eSAndreas Gohr 3804afb84fSAndreas Gohr if (!empty($config['chroma_apikey'])) { 3904afb84fSAndreas Gohr $this->http->headers['Authorization'] = 'Bearer ' . $config['chroma_apikey']; 405e6dd16eSAndreas Gohr } 415e6dd16eSAndreas Gohr } 425e6dd16eSAndreas Gohr 435e6dd16eSAndreas Gohr /** 445e6dd16eSAndreas Gohr * Execute a query against the Chroma API 455e6dd16eSAndreas Gohr * 465e6dd16eSAndreas Gohr * @param string $endpoint API endpoint, will be added to the base URL 475e6dd16eSAndreas Gohr * @param mixed $data The data to send, will be JSON encoded 485e6dd16eSAndreas Gohr * @param string $method POST|GET 495e6dd16eSAndreas Gohr * @return mixed 505e6dd16eSAndreas Gohr * @throws \Exception 515e6dd16eSAndreas Gohr */ 5230b9cbc7Ssplitbrain protected function runQuery($endpoint, mixed $data, $method = 'POST') 535e6dd16eSAndreas Gohr { 545e6dd16eSAndreas Gohr $url = $this->baseurl . '/api/v1' . $endpoint . '?tenant=' . $this->tenant . '&database=' . $this->database; 555e6dd16eSAndreas Gohr 564c0099a8SAndreas Gohr if ($data === []) { 575e6dd16eSAndreas Gohr $json = '{}'; 585e6dd16eSAndreas Gohr } else { 5930b9cbc7Ssplitbrain $json = json_encode($data, JSON_THROW_ON_ERROR); 605e6dd16eSAndreas Gohr } 615e6dd16eSAndreas Gohr 625e6dd16eSAndreas Gohr $this->http->sendRequest($url, $json, $method); 635e6dd16eSAndreas Gohr $response = $this->http->resp_body; 645e6dd16eSAndreas Gohr 655e6dd16eSAndreas Gohr if (!$response) { 66*42b2c6e8SAndreas Gohr throw new \Exception('Chroma API returned no response. ' . $this->http->error, 4001); 675e6dd16eSAndreas Gohr } 685e6dd16eSAndreas Gohr 695e6dd16eSAndreas Gohr try { 7030b9cbc7Ssplitbrain $result = json_decode((string)$response, true, 512, JSON_THROW_ON_ERROR); 714a647d20SAndreas Gohr } catch (\Exception $e) { 72*42b2c6e8SAndreas Gohr throw new \Exception('Chroma API returned invalid JSON. ' . $response, 4003, $e); 735e6dd16eSAndreas Gohr } 745e6dd16eSAndreas Gohr 755e6dd16eSAndreas Gohr if ((int)$this->http->status !== 200) { 765e6dd16eSAndreas Gohr if (isset($result['detail'][0]['msg'])) { 775e6dd16eSAndreas Gohr $error = $result['detail'][0]['msg']; 785e6dd16eSAndreas Gohr } elseif (isset($result['detail']['msg'])) { 795e6dd16eSAndreas Gohr $error = $result['detail']['msg']; 805e6dd16eSAndreas Gohr } elseif (isset($result['detail']) && is_string($result['detail'])) { 815e6dd16eSAndreas Gohr $error = $result['detail']; 825e6dd16eSAndreas Gohr } elseif (isset($result['error'])) { 835e6dd16eSAndreas Gohr $error = $result['error']; 845e6dd16eSAndreas Gohr } else { 855e6dd16eSAndreas Gohr $error = $this->http->error; 865e6dd16eSAndreas Gohr } 875e6dd16eSAndreas Gohr 88*42b2c6e8SAndreas Gohr throw new \Exception('Chroma API returned error. ' . $error, 4002); 895e6dd16eSAndreas Gohr } 905e6dd16eSAndreas Gohr 915e6dd16eSAndreas Gohr return $result; 925e6dd16eSAndreas Gohr } 935e6dd16eSAndreas Gohr 945e6dd16eSAndreas Gohr /** 955e6dd16eSAndreas Gohr * Get the collection ID for the configured collection 965e6dd16eSAndreas Gohr * 975e6dd16eSAndreas Gohr * @return string 985e6dd16eSAndreas Gohr * @throws \Exception 995e6dd16eSAndreas Gohr */ 1005e6dd16eSAndreas Gohr protected function getCollectionID() 1015e6dd16eSAndreas Gohr { 1025e6dd16eSAndreas Gohr if ($this->collectionID) return $this->collectionID; 1035e6dd16eSAndreas Gohr 1045e6dd16eSAndreas Gohr $result = $this->runQuery( 1055e6dd16eSAndreas Gohr '/collections/', 1065e6dd16eSAndreas Gohr [ 1075e6dd16eSAndreas Gohr 'name' => $this->collection, 1085e6dd16eSAndreas Gohr 'get_or_create' => true 1095e6dd16eSAndreas Gohr ] 1105e6dd16eSAndreas Gohr ); 1115e6dd16eSAndreas Gohr $this->collectionID = $result['id']; 1125e6dd16eSAndreas Gohr return $this->collectionID; 1135e6dd16eSAndreas Gohr } 1145e6dd16eSAndreas Gohr 1155e6dd16eSAndreas Gohr /** @inheritdoc */ 1165e6dd16eSAndreas Gohr public function getChunk($chunkID) 1175e6dd16eSAndreas Gohr { 1185e6dd16eSAndreas Gohr $data = $this->runQuery( 1195e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/get', 1205e6dd16eSAndreas Gohr [ 1215e6dd16eSAndreas Gohr 'ids' => [(string)$chunkID], 1225e6dd16eSAndreas Gohr 'include' => [ 1235e6dd16eSAndreas Gohr 'metadatas', 1245e6dd16eSAndreas Gohr 'documents', 1255e6dd16eSAndreas Gohr 'embeddings' 1265e6dd16eSAndreas Gohr ] 1275e6dd16eSAndreas Gohr ] 1285e6dd16eSAndreas Gohr ); 1295e6dd16eSAndreas Gohr 1305e6dd16eSAndreas Gohr if (!$data) return null; 1315e6dd16eSAndreas Gohr if (!$data['ids']) return null; 1325e6dd16eSAndreas Gohr 1335e6dd16eSAndreas Gohr return new Chunk( 1345e6dd16eSAndreas Gohr $data['metadatas'][0]['page'], 1355e6dd16eSAndreas Gohr (int)$data['ids'][0], 1365e6dd16eSAndreas Gohr $data['documents'][0], 1375e6dd16eSAndreas Gohr $data['embeddings'][0], 1385e6dd16eSAndreas Gohr $data['metadatas'][0]['language'] ?? '', 1395e6dd16eSAndreas Gohr $data['metadatas'][0]['created'] 1405e6dd16eSAndreas Gohr ); 1415e6dd16eSAndreas Gohr } 1425e6dd16eSAndreas Gohr 1435e6dd16eSAndreas Gohr /** @inheritdoc */ 1445e6dd16eSAndreas Gohr public function startCreation($clear = false) 1455e6dd16eSAndreas Gohr { 1465e6dd16eSAndreas Gohr if ($clear) { 1475e6dd16eSAndreas Gohr $this->runQuery('/collections/' . $this->collection, '', 'DELETE'); 1485e6dd16eSAndreas Gohr $this->collectionID = ''; 1495e6dd16eSAndreas Gohr } 1505e6dd16eSAndreas Gohr } 1515e6dd16eSAndreas Gohr 1525e6dd16eSAndreas Gohr /** @inheritdoc */ 1535e6dd16eSAndreas Gohr public function reusePageChunks($page, $firstChunkID) 1545e6dd16eSAndreas Gohr { 1555e6dd16eSAndreas Gohr // no-op 1565e6dd16eSAndreas Gohr } 1575e6dd16eSAndreas Gohr 1585e6dd16eSAndreas Gohr /** @inheritdoc */ 1595e6dd16eSAndreas Gohr public function deletePageChunks($page, $firstChunkID) 1605e6dd16eSAndreas Gohr { 1615e6dd16eSAndreas Gohr // delete all possible chunk IDs 1625e6dd16eSAndreas Gohr $ids = range($firstChunkID, $firstChunkID + 99, 1); 16330b9cbc7Ssplitbrain $ids = array_map(static fn($id) => (string)$id, $ids); 1645e6dd16eSAndreas Gohr $this->runQuery( 1655e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/delete', 1665e6dd16eSAndreas Gohr [ 1675e6dd16eSAndreas Gohr 'ids' => $ids 1685e6dd16eSAndreas Gohr ] 1695e6dd16eSAndreas Gohr ); 1705e6dd16eSAndreas Gohr } 1715e6dd16eSAndreas Gohr 1725e6dd16eSAndreas Gohr /** @inheritdoc */ 1735e6dd16eSAndreas Gohr public function addPageChunks($chunks) 1745e6dd16eSAndreas Gohr { 1755e6dd16eSAndreas Gohr $ids = []; 1765e6dd16eSAndreas Gohr $embeddings = []; 1775e6dd16eSAndreas Gohr $metadatas = []; 1785e6dd16eSAndreas Gohr $documents = []; 1795e6dd16eSAndreas Gohr 1805e6dd16eSAndreas Gohr foreach ($chunks as $chunk) { 1815e6dd16eSAndreas Gohr $ids[] = (string)$chunk->getId(); 1825e6dd16eSAndreas Gohr $embeddings[] = $chunk->getEmbedding(); 1835e6dd16eSAndreas Gohr $metadatas[] = [ 1845e6dd16eSAndreas Gohr 'page' => $chunk->getPage(), 1855e6dd16eSAndreas Gohr 'created' => $chunk->getCreated(), 1865e6dd16eSAndreas Gohr 'language' => $chunk->getLanguage() 1875e6dd16eSAndreas Gohr ]; 1885e6dd16eSAndreas Gohr $documents[] = $chunk->getText(); 1895e6dd16eSAndreas Gohr } 1905e6dd16eSAndreas Gohr 1915e6dd16eSAndreas Gohr $this->runQuery( 1925e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/upsert', 1935e6dd16eSAndreas Gohr [ 1945e6dd16eSAndreas Gohr 'ids' => $ids, 1955e6dd16eSAndreas Gohr 'embeddings' => $embeddings, 1965e6dd16eSAndreas Gohr 'metadatas' => $metadatas, 1975e6dd16eSAndreas Gohr 'documents' => $documents 1985e6dd16eSAndreas Gohr ] 1995e6dd16eSAndreas Gohr ); 2005e6dd16eSAndreas Gohr } 2015e6dd16eSAndreas Gohr 2025e6dd16eSAndreas Gohr /** @inheritdoc */ 2035e6dd16eSAndreas Gohr public function finalizeCreation() 2045e6dd16eSAndreas Gohr { 2055e6dd16eSAndreas Gohr // no-op 2065e6dd16eSAndreas Gohr } 2075e6dd16eSAndreas Gohr 2085e6dd16eSAndreas Gohr /** @inheritdoc */ 2095e6dd16eSAndreas Gohr public function runMaintenance() 2105e6dd16eSAndreas Gohr { 2115e6dd16eSAndreas Gohr // no-op 2125e6dd16eSAndreas Gohr } 2135e6dd16eSAndreas Gohr 2145e6dd16eSAndreas Gohr /** @inheritdoc */ 2155e6dd16eSAndreas Gohr public function getPageChunks($page, $firstChunkID) 2165e6dd16eSAndreas Gohr { 2175e6dd16eSAndreas Gohr $ids = range($firstChunkID, $firstChunkID + 99, 1); 21830b9cbc7Ssplitbrain $ids = array_map(static fn($id) => (string)$id, $ids); 2195e6dd16eSAndreas Gohr 2205e6dd16eSAndreas Gohr $data = $this->runQuery( 2215e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/get', 2225e6dd16eSAndreas Gohr [ 2235e6dd16eSAndreas Gohr 'ids' => $ids, 2245e6dd16eSAndreas Gohr 'include' => [ 2255e6dd16eSAndreas Gohr 'metadatas', 2265e6dd16eSAndreas Gohr 'documents', 2275e6dd16eSAndreas Gohr 'embeddings' 2285e6dd16eSAndreas Gohr ], 2295e6dd16eSAndreas Gohr 'limit' => 100, 2305e6dd16eSAndreas Gohr ] 2315e6dd16eSAndreas Gohr ); 2325e6dd16eSAndreas Gohr 2335e6dd16eSAndreas Gohr if (!$data) return []; 2345e6dd16eSAndreas Gohr if (!$data['ids']) return null; 2355e6dd16eSAndreas Gohr 2365e6dd16eSAndreas Gohr $chunks = []; 2375e6dd16eSAndreas Gohr foreach ($data['ids'] as $idx => $id) { 2385e6dd16eSAndreas Gohr $chunks[] = new Chunk( 2395e6dd16eSAndreas Gohr $data['metadatas'][$idx]['page'], 2405e6dd16eSAndreas Gohr (int)$id, 2415e6dd16eSAndreas Gohr $data['documents'][$idx], 2425e6dd16eSAndreas Gohr $data['embeddings'][$idx], 2435e6dd16eSAndreas Gohr $data['metadatas'][$idx]['language'] ?? '', 2445e6dd16eSAndreas Gohr $data['metadatas'][$idx]['created'] 2455e6dd16eSAndreas Gohr ); 2465e6dd16eSAndreas Gohr } 2475e6dd16eSAndreas Gohr return $chunks; 2485e6dd16eSAndreas Gohr } 2495e6dd16eSAndreas Gohr 2505e6dd16eSAndreas Gohr /** @inheritdoc */ 2515e6dd16eSAndreas Gohr public function getSimilarChunks($vector, $lang = '', $limit = 4) 2525e6dd16eSAndreas Gohr { 2535e6dd16eSAndreas Gohr $limit *= 2; // we can't check ACLs, so we return more than requested 2545e6dd16eSAndreas Gohr 2555e6dd16eSAndreas Gohr if ($lang) { 2564c0099a8SAndreas Gohr $filter = ['language' => $lang]; 2575e6dd16eSAndreas Gohr } else { 2585e6dd16eSAndreas Gohr $filter = null; 2595e6dd16eSAndreas Gohr } 2605e6dd16eSAndreas Gohr 2615e6dd16eSAndreas Gohr $data = $this->runQuery( 2625e6dd16eSAndreas Gohr '/collections/' . $this->getCollectionID() . '/query', 2635e6dd16eSAndreas Gohr [ 2645e6dd16eSAndreas Gohr 'query_embeddings' => [$vector], 2655e6dd16eSAndreas Gohr 'n_results' => (int)$limit, 2665e6dd16eSAndreas Gohr 'where' => $filter, 2675e6dd16eSAndreas Gohr 'include' => [ 2685e6dd16eSAndreas Gohr 'metadatas', 2695e6dd16eSAndreas Gohr 'documents', 2705e6dd16eSAndreas Gohr 'embeddings', 2715e6dd16eSAndreas Gohr 'distances', 2725e6dd16eSAndreas Gohr ] 2735e6dd16eSAndreas Gohr ] 2745e6dd16eSAndreas Gohr ); 2755e6dd16eSAndreas Gohr 2765e6dd16eSAndreas Gohr $chunks = []; 2775e6dd16eSAndreas Gohr foreach ($data['ids'][0] as $idx => $id) { 2785e6dd16eSAndreas Gohr $chunks[] = new Chunk( 2795e6dd16eSAndreas Gohr $data['metadatas'][0][$idx]['page'], 2805e6dd16eSAndreas Gohr (int)$id, 2815e6dd16eSAndreas Gohr $data['documents'][0][$idx], 2825e6dd16eSAndreas Gohr $data['embeddings'][0][$idx], 2835e6dd16eSAndreas Gohr $data['metadatas'][0][$idx]['language'] ?? '', 2845e6dd16eSAndreas Gohr $data['metadatas'][0][$idx]['created'], 2855e6dd16eSAndreas Gohr $data['distances'][0][$idx] 2865e6dd16eSAndreas Gohr ); 2875e6dd16eSAndreas Gohr } 2885e6dd16eSAndreas Gohr return $chunks; 2895e6dd16eSAndreas Gohr } 2905e6dd16eSAndreas Gohr 2915e6dd16eSAndreas Gohr /** @inheritdoc */ 2925e6dd16eSAndreas Gohr public function statistics() 2935e6dd16eSAndreas Gohr { 2945e6dd16eSAndreas Gohr $count = $this->runQuery('/collections/' . $this->getCollectionID() . '/count', '', 'GET'); 2955e6dd16eSAndreas Gohr $version = $this->runQuery('/version', '', 'GET'); 2965e6dd16eSAndreas Gohr 2975e6dd16eSAndreas Gohr return [ 2985e6dd16eSAndreas Gohr 'chroma_version' => $version, 2995e6dd16eSAndreas Gohr 'collection_id' => $this->getCollectionID(), 3005e6dd16eSAndreas Gohr 'chunks' => $count 3015e6dd16eSAndreas Gohr ]; 3025e6dd16eSAndreas Gohr } 3035e6dd16eSAndreas Gohr} 304