xref: /plugin/aichat/Storage/ChromaStorage.php (revision 5e6dd16e3c119de7bfaea05e364c6f864ff9ca03)
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