2026-04-05 22:44:45 -05:00
|
|
|
/**
|
|
|
|
|
* Qdrant graph embeddings write service.
|
|
|
|
|
*
|
|
|
|
|
* Stores entity/vector pairs in Qdrant for graph embeddings lookup.
|
|
|
|
|
* Collection naming: t_{user}_{collection}_{dimension}
|
|
|
|
|
* Collections are lazily created on first write with cosine distance.
|
|
|
|
|
*
|
|
|
|
|
* Python reference: trustgraph-flow/trustgraph/storage/graph_embeddings/qdrant/write.py
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { QdrantClient } from "@qdrant/js-client-rest";
|
2026-06-01 16:22:25 -05:00
|
|
|
import { errorMessage, type Term } from "@trustgraph/base";
|
|
|
|
|
import { Context, Effect, Layer } from "effect";
|
|
|
|
|
import * as S from "effect/Schema";
|
2026-04-05 22:44:45 -05:00
|
|
|
|
|
|
|
|
export interface QdrantGraphEmbeddingsConfig {
|
|
|
|
|
url?: string;
|
|
|
|
|
apiKey?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GraphEmbeddingEntity {
|
|
|
|
|
entity: Term;
|
|
|
|
|
vector: number[];
|
|
|
|
|
chunkId?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GraphEmbeddingsMessage {
|
|
|
|
|
user: string;
|
|
|
|
|
collection: string;
|
|
|
|
|
entities: GraphEmbeddingEntity[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTermValue(term: Term): string | null {
|
|
|
|
|
switch (term.type) {
|
|
|
|
|
case "IRI":
|
|
|
|
|
return term.iri;
|
|
|
|
|
case "LITERAL":
|
|
|
|
|
return term.value;
|
|
|
|
|
case "BLANK":
|
|
|
|
|
return term.id;
|
|
|
|
|
case "TRIPLE":
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
export interface QdrantGraphEmbeddingsStore {
|
|
|
|
|
readonly store: (message: GraphEmbeddingsMessage) => Promise<void>;
|
|
|
|
|
readonly deleteCollection: (user: string, collection: string) => Promise<void>;
|
|
|
|
|
}
|
2026-04-05 22:44:45 -05:00
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
export function makeQdrantGraphEmbeddingsStore(
|
|
|
|
|
config: QdrantGraphEmbeddingsConfig = {},
|
|
|
|
|
): QdrantGraphEmbeddingsStore {
|
|
|
|
|
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
|
|
|
|
|
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
|
2026-04-05 22:44:45 -05:00
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
const client = new QdrantClient({
|
|
|
|
|
url,
|
|
|
|
|
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
|
|
|
|
|
});
|
|
|
|
|
const knownCollections = new Set<string>();
|
2026-04-05 22:44:45 -05:00
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
console.log("[QdrantGraphEmbeddings] Store initialized");
|
2026-04-05 22:44:45 -05:00
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
const collectionName = (user: string, collection: string, dim: number): string =>
|
|
|
|
|
`t_${user}_${collection}_${dim}`;
|
2026-04-05 22:44:45 -05:00
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
const ensureCollection = async (name: string, dim: number): Promise<void> => {
|
|
|
|
|
if (knownCollections.has(name)) return;
|
2026-04-05 22:44:45 -05:00
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
const exists = await client.collectionExists(name);
|
2026-04-05 22:44:45 -05:00
|
|
|
if (!exists.exists) {
|
|
|
|
|
console.log(`[QdrantGraphEmbeddings] Creating collection ${name} (dim=${dim})`);
|
2026-06-01 20:26:47 -05:00
|
|
|
await client.createCollection(name, {
|
2026-04-05 22:44:45 -05:00
|
|
|
vectors: { size: dim, distance: "Cosine" },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
knownCollections.add(name);
|
|
|
|
|
};
|
2026-04-05 22:44:45 -05:00
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
const store = async (message: GraphEmbeddingsMessage): Promise<void> => {
|
2026-04-05 22:44:45 -05:00
|
|
|
for (const entry of message.entities) {
|
|
|
|
|
const entityValue = getTermValue(entry.entity);
|
2026-05-12 08:06:58 -05:00
|
|
|
if (entityValue === null || entityValue.length === 0) continue;
|
|
|
|
|
if (entry.vector.length === 0) continue;
|
2026-04-05 22:44:45 -05:00
|
|
|
|
|
|
|
|
const dim = entry.vector.length;
|
2026-06-01 20:26:47 -05:00
|
|
|
const name = collectionName(message.user, message.collection, dim);
|
2026-04-05 22:44:45 -05:00
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
await ensureCollection(name, dim);
|
2026-04-05 22:44:45 -05:00
|
|
|
|
|
|
|
|
const payload: Record<string, unknown> = { entity: entityValue };
|
2026-05-12 08:06:58 -05:00
|
|
|
if (entry.chunkId !== undefined && entry.chunkId.length > 0) {
|
2026-04-05 22:44:45 -05:00
|
|
|
payload.chunk_id = entry.chunkId;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
await client.upsert(name, {
|
2026-04-05 22:44:45 -05:00
|
|
|
points: [
|
|
|
|
|
{
|
2026-05-12 08:06:58 -05:00
|
|
|
id: crypto.randomUUID(),
|
2026-04-05 22:44:45 -05:00
|
|
|
vector: entry.vector,
|
|
|
|
|
payload,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-01 20:26:47 -05:00
|
|
|
};
|
2026-04-05 22:44:45 -05:00
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
const deleteCollection = async (user: string, collection: string): Promise<void> => {
|
2026-04-05 22:44:45 -05:00
|
|
|
const prefix = `t_${user}_${collection}_`;
|
|
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
const allCollections = await client.getCollections();
|
2026-04-05 22:44:45 -05:00
|
|
|
const matching = allCollections.collections.filter((c) =>
|
|
|
|
|
c.name.startsWith(prefix),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (matching.length === 0) {
|
|
|
|
|
console.log(`[QdrantGraphEmbeddings] No collections matching prefix ${prefix}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const coll of matching) {
|
2026-06-01 20:26:47 -05:00
|
|
|
await client.deleteCollection(coll.name);
|
|
|
|
|
knownCollections.delete(coll.name);
|
2026-04-05 22:44:45 -05:00
|
|
|
console.log(`[QdrantGraphEmbeddings] Deleted collection: ${coll.name}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`[QdrantGraphEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`,
|
|
|
|
|
);
|
2026-06-01 20:26:47 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return { store, deleteCollection };
|
2026-04-05 22:44:45 -05:00
|
|
|
}
|
2026-06-01 16:22:25 -05:00
|
|
|
|
|
|
|
|
export class QdrantGraphEmbeddingsStoreError extends S.TaggedErrorClass<QdrantGraphEmbeddingsStoreError>()(
|
|
|
|
|
"QdrantGraphEmbeddingsStoreError",
|
|
|
|
|
{
|
|
|
|
|
message: S.String,
|
|
|
|
|
operation: S.String,
|
|
|
|
|
cause: S.DefectWithStack,
|
|
|
|
|
},
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
export interface QdrantGraphEmbeddingsStoreServiceShape {
|
|
|
|
|
readonly store: (
|
|
|
|
|
message: GraphEmbeddingsMessage,
|
|
|
|
|
) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>;
|
|
|
|
|
readonly deleteCollection: (
|
|
|
|
|
user: string,
|
|
|
|
|
collection: string,
|
|
|
|
|
) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class QdrantGraphEmbeddingsStoreService extends Context.Service<
|
|
|
|
|
QdrantGraphEmbeddingsStoreService,
|
|
|
|
|
QdrantGraphEmbeddingsStoreServiceShape
|
|
|
|
|
>()(
|
|
|
|
|
"@trustgraph/flow/storage/embeddings/qdrant-graph/QdrantGraphEmbeddingsStoreService",
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
const qdrantGraphEmbeddingsStoreError = (operation: string, cause: unknown) =>
|
|
|
|
|
new QdrantGraphEmbeddingsStoreError({
|
|
|
|
|
operation,
|
|
|
|
|
message: errorMessage(cause),
|
|
|
|
|
cause,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const makeQdrantGraphEmbeddingsStoreService = (
|
|
|
|
|
config: QdrantGraphEmbeddingsConfig = {},
|
|
|
|
|
): QdrantGraphEmbeddingsStoreServiceShape => {
|
2026-06-01 20:26:47 -05:00
|
|
|
const store = makeQdrantGraphEmbeddingsStore(config);
|
2026-06-01 16:22:25 -05:00
|
|
|
return {
|
|
|
|
|
store: Effect.fn("QdrantGraphEmbeddingsStore.store")(function* (message) {
|
|
|
|
|
return yield* Effect.tryPromise({
|
|
|
|
|
try: () => store.store(message),
|
|
|
|
|
catch: (cause) => qdrantGraphEmbeddingsStoreError("store", cause),
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
deleteCollection: Effect.fn("QdrantGraphEmbeddingsStore.deleteCollection")(function* (
|
|
|
|
|
user,
|
|
|
|
|
collection,
|
|
|
|
|
) {
|
|
|
|
|
return yield* Effect.tryPromise({
|
|
|
|
|
try: () => store.deleteCollection(user, collection),
|
|
|
|
|
catch: (cause) => qdrantGraphEmbeddingsStoreError("delete-collection", cause),
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const QdrantGraphEmbeddingsStoreLive = (
|
|
|
|
|
config: QdrantGraphEmbeddingsConfig = {},
|
|
|
|
|
): Layer.Layer<QdrantGraphEmbeddingsStoreService> =>
|
|
|
|
|
Layer.succeed(
|
|
|
|
|
QdrantGraphEmbeddingsStoreService,
|
|
|
|
|
QdrantGraphEmbeddingsStoreService.of(makeQdrantGraphEmbeddingsStoreService(config)),
|
|
|
|
|
);
|