xref: /plugin/aichat/Storage/ChromaStorage.php (revision 30b9cbc7090f3bc7eca85292f4ff98a1cf513f8f)
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    /**
255e6dd16eSAndreas Gohr     * PineconeStorage 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     */
56*30b9cbc7Ssplitbrain    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
605e6dd16eSAndreas Gohr        if (is_array($data) && $data === []) {
615e6dd16eSAndreas Gohr            $json = '{}';
625e6dd16eSAndreas Gohr        } else {
63*30b9cbc7Ssplitbrain            $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 {
74*30b9cbc7Ssplitbrain            $result = json_decode((string) $response, true, 512, JSON_THROW_ON_ERROR);
75*30b9cbc7Ssplitbrain        } 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);
167*30b9cbc7Ssplitbrain        $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);
222*30b9cbc7Ssplitbrain        $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) {
2605e6dd16eSAndreas Gohr            $filter = ['language' => ['$eq', $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