implemenation on the bot
This commit is contained in:
77
services/ai/collections.js
Normal file
77
services/ai/collections.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ChromaClient } from 'chromadb';
|
||||
import { MODEL_ID, embed, embedMany } from './embed.js';
|
||||
|
||||
// ==============================|| Chroma collections ||============================== //
|
||||
//
|
||||
// Two collections, deliberately separate:
|
||||
//
|
||||
// intent_examples — ~20 phrasings per intent. Answers "which question is
|
||||
// this?", never "what is the number?".
|
||||
// console_docs — chunked markdown. Genuine document Q&A.
|
||||
//
|
||||
// NO OPERATIONAL DATA IS EMBEDDED. No bookings, riders or customers ever enter
|
||||
// the vector store. A vector store is a snapshot and this data changes by the
|
||||
// minute; every figure still comes from a live API call through the existing
|
||||
// typed functions. That is the whole reason this design is safe to build.
|
||||
|
||||
export const INTENTS_COLLECTION = 'intent_examples';
|
||||
export const DOCS_COLLECTION = 'console_docs';
|
||||
|
||||
const client = new ChromaClient({ path: process.env.CHROMA_URL || 'http://localhost:8000' });
|
||||
|
||||
// Chroma would otherwise call its own default embedding function (which
|
||||
// downloads a different model). We embed ourselves so queries and documents
|
||||
// are guaranteed to come from the same model.
|
||||
const noopEmbeddingFunction = { generate: async (texts) => embedMany(texts) };
|
||||
|
||||
export const getCollection = async (name) =>
|
||||
client.getOrCreateCollection({
|
||||
name,
|
||||
embeddingFunction: noopEmbeddingFunction,
|
||||
metadata: {
|
||||
// Stamped so a model swap is detectable rather than silently degrading
|
||||
// every score. Re-seed after changing it.
|
||||
'hnsw:space': 'cosine',
|
||||
embeddingModel: MODEL_ID
|
||||
}
|
||||
});
|
||||
|
||||
export const resetCollection = async (name) => {
|
||||
try {
|
||||
await client.deleteCollection({ name });
|
||||
} catch {
|
||||
// Not present yet — nothing to delete.
|
||||
}
|
||||
return getCollection(name);
|
||||
};
|
||||
|
||||
// Chroma returns cosine DISTANCE (0 = identical). Similarity is the useful
|
||||
// direction for a human-facing confidence, so convert once, here, rather than
|
||||
// leaving every call site to remember which way round it is.
|
||||
const toSimilarity = (distance) => 1 - Number(distance);
|
||||
|
||||
export const query = async (name, text, topK = 5) => {
|
||||
const collection = await getCollection(name);
|
||||
const vector = await embed(text);
|
||||
const res = await collection.query({ queryEmbeddings: [vector], nResults: topK });
|
||||
|
||||
const ids = res.ids?.[0] || [];
|
||||
return ids.map((id, i) => ({
|
||||
id,
|
||||
document: res.documents?.[0]?.[i] || '',
|
||||
metadata: res.metadatas?.[0]?.[i] || {},
|
||||
score: toSimilarity(res.distances?.[0]?.[i] ?? 1)
|
||||
}));
|
||||
};
|
||||
|
||||
export const health = async () => {
|
||||
await client.heartbeat();
|
||||
const names = (await client.listCollections()).map((c) => c.name ?? c);
|
||||
const counts = {};
|
||||
for (const name of [INTENTS_COLLECTION, DOCS_COLLECTION]) {
|
||||
if (!names.includes(name)) continue;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
counts[name] = await (await getCollection(name)).count();
|
||||
}
|
||||
return { chroma: 'up', collections: counts };
|
||||
};
|
||||
Reference in New Issue
Block a user