trustgraph/ts/packages/flow/src/storage/embeddings/qdrant-graph.ts

289 lines
9.5 KiB
TypeScript
Raw Normal View History

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 type { Term } from "@trustgraph/base";
import { errorMessage, } from "@trustgraph/base";
import { Config, Context, Effect, Layer, Match, Random } from "effect";
import * as MutableHashSet from "effect/MutableHashSet";
2026-06-01 23:19:54 -05:00
import * as O from "effect/Option";
2026-06-01 16:22:25 -05:00
import * as S from "effect/Schema";
import type { QdrantClientFactory, QdrantClientLike } from "../../qdrant/client.js";
import { makeQdrantClient, } from "../../qdrant/client.js";
2026-04-05 22:44:45 -05:00
export interface QdrantGraphEmbeddingsConfig {
url?: string;
apiKey?: string;
2026-06-02 04:10:03 -05:00
clientFactory?: QdrantClientFactory;
2026-04-05 22:44:45 -05:00
}
export interface GraphEmbeddingEntity {
entity: Term;
vector: number[];
chunkId?: string;
}
export interface GraphEmbeddingsMessage {
user: string;
collection: string;
entities: GraphEmbeddingEntity[];
}
2026-06-01 23:19:54 -05:00
export class QdrantGraphEmbeddingsStoreError extends S.TaggedErrorClass<QdrantGraphEmbeddingsStoreError>()(
"QdrantGraphEmbeddingsStoreError",
{
message: S.String,
operation: S.String,
2026-06-06 10:33:10 -05:00
cause: S.Defect({ includeStack: true }),
2026-06-01 23:19:54 -05:00
},
) {}
const qdrantGraphEmbeddingsStoreError = (operation: string, cause: unknown) =>
QdrantGraphEmbeddingsStoreError.make({
operation,
message: errorMessage(cause),
cause,
});
interface ResolvedQdrantGraphEmbeddingsConfig {
readonly url: string;
readonly apiKey?: string;
}
const loadQdrantGraphEmbeddingsConfig = Effect.fn("QdrantGraphEmbeddings.loadConfig")(function* (
config: QdrantGraphEmbeddingsConfig,
) {
const envApiKey = O.getOrUndefined(yield* Config.string("QDRANT_API_KEY").pipe(Config.option));
const apiKey = config.apiKey ?? envApiKey;
return {
url: config.url ?? (yield* Config.string("QDRANT_URL").pipe(Config.withDefault("http://localhost:6333"))),
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
} satisfies ResolvedQdrantGraphEmbeddingsConfig;
});
const randomHex = Effect.fn("QdrantGraphEmbeddings.randomHex")(function* (digits: number) {
let result = "";
for (let index = 0; index < digits; index++) {
const value = yield* Random.nextIntBetween(0, 16);
result += value.toString(16);
}
return result;
});
const randomPointId = Effect.fn("QdrantGraphEmbeddings.randomPointId")(function* () {
const part1 = yield* randomHex(8);
const part2 = yield* randomHex(4);
const versionRest = yield* randomHex(3);
const variant = yield* Random.nextIntBetween(8, 12);
const variantRest = yield* randomHex(3);
const part5 = yield* randomHex(12);
return `${part1}-${part2}-4${versionRest}-${variant.toString(16)}${variantRest}-${part5}`;
});
2026-04-05 22:44:45 -05:00
function getTermValue(term: Term): string | null {
return Match.type<Term>().pipe(
Match.discriminatorsExhaustive("type")({
IRI: (iri) => iri.iri,
LITERAL: (literal) => literal.value,
BLANK: (blank) => blank.id,
TRIPLE: () => null,
}),
)(term);
2026-04-05 22:44:45 -05:00
}
2026-06-01 20:26:47 -05:00
export interface QdrantGraphEmbeddingsStore {
2026-06-06 10:33:10 -05:00
readonly store: (
2026-06-01 23:19:54 -05:00
message: GraphEmbeddingsMessage,
) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>;
2026-06-06 10:33:10 -05:00
readonly deleteCollection: (
2026-06-01 23:19:54 -05:00
user: string,
collection: string,
) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>;
2026-06-01 20:26:47 -05:00
}
2026-04-05 22:44:45 -05:00
2026-06-02 04:10:03 -05:00
const makeQdrantGraphEmbeddingsClient = (
config: QdrantGraphEmbeddingsConfig,
resolved: ResolvedQdrantGraphEmbeddingsConfig,
) =>
Effect.try({
try: () =>
makeQdrantClient(config.clientFactory, {
url: resolved.url,
...(resolved.apiKey !== undefined ? { apiKey: resolved.apiKey } : {}),
}),
catch: (cause) => qdrantGraphEmbeddingsStoreError("create-client", cause),
2026-06-01 20:26:47 -05:00
});
2026-04-05 22:44:45 -05:00
2026-06-02 04:10:03 -05:00
const makeQdrantGraphEmbeddingsStoreFromClient = (
client: QdrantClientLike,
): QdrantGraphEmbeddingsStoreServiceShape => {
const knownCollections = MutableHashSet.empty<string>();
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 23:19:54 -05:00
const ensureCollectionEffect = Effect.fn("QdrantGraphEmbeddings.ensureCollection")(function* (
name: string,
dim: number,
) {
if (MutableHashSet.has(knownCollections, name)) return;
2026-04-05 22:44:45 -05:00
2026-06-06 10:33:10 -05:00
const exists = yield* client.collectionExists(name).pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("collection-exists", cause)),
);
2026-04-05 22:44:45 -05:00
if (!exists.exists) {
2026-06-01 23:19:54 -05:00
yield* Effect.log(`[QdrantGraphEmbeddings] Creating collection ${name} (dim=${dim})`);
2026-06-06 10:33:10 -05:00
yield* client.createCollection(
name,
{
vectors: { size: dim, distance: "Cosine" },
},
).pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("create-collection", cause)),
);
2026-04-05 22:44:45 -05:00
}
MutableHashSet.add(knownCollections, name);
2026-06-01 23:19:54 -05:00
});
2026-04-05 22:44:45 -05:00
2026-06-06 10:33:10 -05:00
const storeImpl = Effect.fn("QdrantGraphEmbeddings.store")(function* (message: GraphEmbeddingsMessage) {
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 23:19:54 -05:00
yield* ensureCollectionEffect(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 23:19:54 -05:00
const id = yield* randomPointId();
2026-06-06 10:33:10 -05:00
yield* client.upsert(
name,
{
points: [
{
id,
vector: entry.vector,
payload,
},
],
},
).pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("upsert", cause)),
);
2026-04-05 22:44:45 -05:00
}
2026-06-01 23:19:54 -05:00
});
2026-04-05 22:44:45 -05:00
2026-06-06 10:33:10 -05:00
const deleteCollectionImpl = Effect.fn("QdrantGraphEmbeddings.deleteCollection")(function* (
2026-06-01 23:19:54 -05:00
user: string,
collection: string,
) {
2026-04-05 22:44:45 -05:00
const prefix = `t_${user}_${collection}_`;
2026-06-06 10:33:10 -05:00
const allCollections = yield* client.getCollections.pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("get-collections", cause)),
);
2026-04-05 22:44:45 -05:00
const matching = allCollections.collections.filter((c) =>
c.name.startsWith(prefix),
);
if (matching.length === 0) {
2026-06-01 23:19:54 -05:00
yield* Effect.log(`[QdrantGraphEmbeddings] No collections matching prefix ${prefix}`);
2026-04-05 22:44:45 -05:00
return;
}
for (const coll of matching) {
2026-06-06 10:33:10 -05:00
yield* client.deleteCollection(coll.name).pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("delete-collection", cause)),
);
MutableHashSet.remove(knownCollections, coll.name);
2026-06-01 23:19:54 -05:00
yield* Effect.log(`[QdrantGraphEmbeddings] Deleted collection: ${coll.name}`);
2026-04-05 22:44:45 -05:00
}
2026-06-01 23:19:54 -05:00
yield* Effect.log(
2026-04-05 22:44:45 -05:00
`[QdrantGraphEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`,
);
2026-06-01 23:19:54 -05:00
});
2026-06-01 20:26:47 -05:00
2026-06-02 04:10:03 -05:00
return {
2026-06-06 10:33:10 -05:00
store: storeImpl,
deleteCollection: deleteCollectionImpl,
2026-06-02 04:10:03 -05:00
};
};
export const makeQdrantGraphEmbeddingsStoreServiceEffect = Effect.fn(
"makeQdrantGraphEmbeddingsStoreServiceEffect",
)(function* (config: QdrantGraphEmbeddingsConfig = {}) {
const resolved = yield* loadQdrantGraphEmbeddingsConfig(config).pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("load-config", cause)),
);
const client = yield* makeQdrantGraphEmbeddingsClient(config, resolved);
yield* Effect.log("[QdrantGraphEmbeddings] Store initialized");
return makeQdrantGraphEmbeddingsStoreFromClient(client);
});
const withQdrantGraphEmbeddingsStore = <A>(
config: QdrantGraphEmbeddingsConfig,
use: (store: QdrantGraphEmbeddingsStoreServiceShape) => Effect.Effect<A, QdrantGraphEmbeddingsStoreError>,
) =>
makeQdrantGraphEmbeddingsStoreServiceEffect(config).pipe(
Effect.flatMap(use),
);
export function makeQdrantGraphEmbeddingsStore(
config: QdrantGraphEmbeddingsConfig = {},
): QdrantGraphEmbeddingsStore {
2026-06-01 23:19:54 -05:00
return {
2026-06-06 10:33:10 -05:00
store: (message) => withQdrantGraphEmbeddingsStore(config, (store) => store.store(message)),
2026-06-01 23:19:54 -05:00
deleteCollection: (user, collection) =>
2026-06-06 10:33:10 -05:00
withQdrantGraphEmbeddingsStore(config, (store) => store.deleteCollection(user, collection)),
2026-06-01 23:19:54 -05:00
};
2026-04-05 22:44:45 -05:00
}
2026-06-01 16:22:25 -05:00
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",
) {}
export const makeQdrantGraphEmbeddingsStoreService = (
config: QdrantGraphEmbeddingsConfig = {},
2026-06-02 04:10:03 -05:00
): QdrantGraphEmbeddingsStoreServiceShape => ({
store: (message) => withQdrantGraphEmbeddingsStore(config, (store) => store.store(message)),
deleteCollection: (user, collection) =>
withQdrantGraphEmbeddingsStore(config, (store) => store.deleteCollection(user, collection)),
});
2026-06-01 16:22:25 -05:00
export const QdrantGraphEmbeddingsStoreLive = (
config: QdrantGraphEmbeddingsConfig = {},
2026-06-02 04:10:03 -05:00
): Layer.Layer<QdrantGraphEmbeddingsStoreService, QdrantGraphEmbeddingsStoreError> =>
Layer.effect(
2026-06-01 16:22:25 -05:00
QdrantGraphEmbeddingsStoreService,
2026-06-02 04:10:03 -05:00
makeQdrantGraphEmbeddingsStoreServiceEffect(config).pipe(
Effect.map((service) => QdrantGraphEmbeddingsStoreService.of(service)),
),
2026-06-01 16:22:25 -05:00
);