xref: /plugin/aichat/Storage/ChromaStorage.php (revision 4a647d20a89c87bc2746312604c5608ee49b0923)
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) {
665e6dd16eSAndreas Gohr            throw new \Exception('Chroma API returned no response. ' . $this->http->error);
675e6dd16eSAndreas Gohr        }
685e6dd16eSAndreas Gohr
695e6dd16eSAndreas Gohr        try {
7030b9cbc7Ssplitbrain            $result = json_decode((string)$response, true, 512, JSON_THROW_ON_ERROR);
71*4a647d20SAndreas Gohr        } catch (\Exception $e) {
72*4a647d20SAndreas Gohr            throw new \Exception('Chroma API returned invalid JSON. ' . $response, 0, $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
885e6dd16eSAndreas Gohr            throw new \Exception('Chroma API returned error. ' . $error);
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