2026-04-05 22:44:45 -05:00
|
|
|
/**
|
|
|
|
|
* Qdrant graph embeddings query service.
|
|
|
|
|
*
|
|
|
|
|
* Input: vector, user, collection, limit
|
|
|
|
|
* Output: list of Term entities with scores, deduplicated by entity value
|
|
|
|
|
*
|
|
|
|
|
* Queries limit*2 points and deduplicates by entity value to ensure
|
|
|
|
|
* we return up to `limit` unique entities.
|
|
|
|
|
*
|
|
|
|
|
* Python reference: trustgraph-flow/trustgraph/query/graph_embeddings/qdrant/service.py
|
|
|
|
|
*/
|
|
|
|
|
|
2026-06-01 16:22:25 -05:00
|
|
|
import { errorMessage, type Term } from "@trustgraph/base";
|
2026-06-01 23:19:54 -05:00
|
|
|
import { Config, Context, Effect, Layer } from "effect";
|
|
|
|
|
import * as O from "effect/Option";
|
2026-06-01 16:22:25 -05:00
|
|
|
import * as S from "effect/Schema";
|
2026-06-02 04:10:03 -05:00
|
|
|
import { makeQdrantClient, type QdrantClientFactory, type QdrantClientLike } from "../../qdrant/client.js";
|
2026-04-05 22:44:45 -05:00
|
|
|
|
|
|
|
|
export interface QdrantGraphQueryConfig {
|
|
|
|
|
url?: string;
|
|
|
|
|
apiKey?: string;
|
2026-06-02 04:10:03 -05:00
|
|
|
clientFactory?: QdrantClientFactory;
|
2026-04-05 22:44:45 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface EntityMatch {
|
|
|
|
|
entity: Term;
|
|
|
|
|
score: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GraphEmbeddingsQueryRequest {
|
|
|
|
|
vector: number[];
|
|
|
|
|
user: string;
|
|
|
|
|
collection: string;
|
|
|
|
|
limit: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 23:19:54 -05:00
|
|
|
export class QdrantGraphEmbeddingsQueryError extends S.TaggedErrorClass<QdrantGraphEmbeddingsQueryError>()(
|
|
|
|
|
"QdrantGraphEmbeddingsQueryError",
|
|
|
|
|
{
|
|
|
|
|
message: S.String,
|
|
|
|
|
operation: S.String,
|
|
|
|
|
cause: S.DefectWithStack,
|
|
|
|
|
},
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
const qdrantGraphEmbeddingsQueryError = (operation: string, cause: unknown) =>
|
|
|
|
|
QdrantGraphEmbeddingsQueryError.make({
|
|
|
|
|
operation,
|
|
|
|
|
message: errorMessage(cause),
|
|
|
|
|
cause,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
interface ResolvedQdrantGraphQueryConfig {
|
|
|
|
|
readonly url: string;
|
|
|
|
|
readonly apiKey?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const loadQdrantGraphQueryConfig = Effect.fn("QdrantGraphEmbeddingsQuery.loadConfig")(function* (
|
|
|
|
|
config: QdrantGraphQueryConfig,
|
|
|
|
|
) {
|
|
|
|
|
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 ResolvedQdrantGraphQueryConfig;
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-05 22:44:45 -05:00
|
|
|
function createTerm(value: string): Term {
|
|
|
|
|
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
|
|
|
return { type: "IRI", iri: value };
|
|
|
|
|
}
|
|
|
|
|
return { type: "LITERAL", value };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 04:10:03 -05:00
|
|
|
const GraphPointPayloadSchema = S.Struct({
|
|
|
|
|
entity: S.String,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const decodeGraphPointPayload = (payload: unknown) =>
|
|
|
|
|
S.decodeUnknownEffect(GraphPointPayloadSchema)(payload).pipe(Effect.option);
|
|
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
export interface QdrantGraphEmbeddingsQuery {
|
2026-06-02 04:10:03 -05:00
|
|
|
readonly query: (request: GraphEmbeddingsQueryRequest) => Promise<ReadonlyArray<EntityMatch>>;
|
2026-06-01 23:19:54 -05:00
|
|
|
readonly queryEffect: (
|
|
|
|
|
request: GraphEmbeddingsQueryRequest,
|
|
|
|
|
) => Effect.Effect<ReadonlyArray<EntityMatch>, QdrantGraphEmbeddingsQueryError>;
|
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 makeQdrantGraphEmbeddingsQueryClient = (
|
|
|
|
|
config: QdrantGraphQueryConfig,
|
|
|
|
|
resolved: ResolvedQdrantGraphQueryConfig,
|
|
|
|
|
) =>
|
|
|
|
|
Effect.try({
|
|
|
|
|
try: () =>
|
|
|
|
|
makeQdrantClient(config.clientFactory, {
|
|
|
|
|
url: resolved.url,
|
|
|
|
|
...(resolved.apiKey !== undefined ? { apiKey: resolved.apiKey } : {}),
|
|
|
|
|
}),
|
|
|
|
|
catch: (cause) => qdrantGraphEmbeddingsQueryError("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 makeQdrantGraphEmbeddingsQueryFromClient = (
|
|
|
|
|
client: QdrantClientLike,
|
|
|
|
|
): QdrantGraphEmbeddingsQueryServiceShape => {
|
2026-04-05 22:44:45 -05:00
|
|
|
|
2026-06-01 23:19:54 -05:00
|
|
|
const queryEffect = Effect.fn("QdrantGraphEmbeddingsQuery.query")(function* (
|
|
|
|
|
request: GraphEmbeddingsQueryRequest,
|
|
|
|
|
) {
|
2026-04-05 22:44:45 -05:00
|
|
|
const { vector, user, collection, limit } = request;
|
|
|
|
|
|
2026-05-12 08:06:58 -05:00
|
|
|
if (vector.length === 0) {
|
2026-04-05 22:44:45 -05:00
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dim = vector.length;
|
|
|
|
|
const collectionName = `t_${user}_${collection}_${dim}`;
|
|
|
|
|
|
|
|
|
|
// Check if collection exists -- return empty if not
|
2026-06-01 23:19:54 -05:00
|
|
|
const exists = yield* Effect.tryPromise({
|
|
|
|
|
try: () => client.collectionExists(collectionName),
|
|
|
|
|
catch: (cause) => qdrantGraphEmbeddingsQueryError("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(
|
2026-04-05 22:44:45 -05:00
|
|
|
`[QdrantGraphQuery] Collection ${collectionName} does not exist, returning empty results`,
|
|
|
|
|
);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Query 2x the limit so we have a better chance of getting `limit`
|
|
|
|
|
// unique entities after deduplication (same heuristic as Python impl)
|
2026-06-01 23:19:54 -05:00
|
|
|
const searchResult = yield* Effect.tryPromise({
|
|
|
|
|
try: () =>
|
|
|
|
|
client.search(collectionName, {
|
|
|
|
|
vector,
|
|
|
|
|
limit: limit * 2,
|
|
|
|
|
with_payload: true,
|
|
|
|
|
}),
|
|
|
|
|
catch: (cause) => qdrantGraphEmbeddingsQueryError("search", cause),
|
2026-04-05 22:44:45 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const entitySet = new Set<string>();
|
|
|
|
|
const entities: EntityMatch[] = [];
|
|
|
|
|
|
|
|
|
|
for (const point of searchResult) {
|
2026-06-02 04:10:03 -05:00
|
|
|
const payload = yield* decodeGraphPointPayload(point.payload);
|
|
|
|
|
if (O.isNone(payload)) continue;
|
|
|
|
|
|
|
|
|
|
const entityValue = payload.value.entity;
|
2026-05-12 08:06:58 -05:00
|
|
|
if (entityValue === undefined || entityValue.length === 0) continue;
|
2026-04-05 22:44:45 -05:00
|
|
|
|
|
|
|
|
// Deduplicate by entity value, keeping the highest score (results are
|
|
|
|
|
// already sorted by score descending from Qdrant)
|
|
|
|
|
if (!entitySet.has(entityValue)) {
|
|
|
|
|
entitySet.add(entityValue);
|
|
|
|
|
entities.push({
|
|
|
|
|
entity: createTerm(entityValue),
|
|
|
|
|
score: point.score,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stop once we have enough unique entities
|
|
|
|
|
if (entities.length >= limit) break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return entities;
|
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 {
|
|
|
|
|
query: queryEffect,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const makeQdrantGraphEmbeddingsQueryServiceEffect = Effect.fn(
|
|
|
|
|
"makeQdrantGraphEmbeddingsQueryServiceEffect",
|
|
|
|
|
)(function* (config: QdrantGraphQueryConfig = {}) {
|
|
|
|
|
const resolved = yield* loadQdrantGraphQueryConfig(config).pipe(
|
|
|
|
|
Effect.mapError((cause) => qdrantGraphEmbeddingsQueryError("load-config", cause)),
|
|
|
|
|
);
|
|
|
|
|
const client = yield* makeQdrantGraphEmbeddingsQueryClient(config, resolved);
|
|
|
|
|
yield* Effect.log("[QdrantGraphQuery] Query service initialized");
|
|
|
|
|
return makeQdrantGraphEmbeddingsQueryFromClient(client);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const withQdrantGraphEmbeddingsQuery = <A>(
|
|
|
|
|
config: QdrantGraphQueryConfig,
|
|
|
|
|
use: (query: QdrantGraphEmbeddingsQueryServiceShape) => Effect.Effect<A, QdrantGraphEmbeddingsQueryError>,
|
|
|
|
|
) =>
|
|
|
|
|
makeQdrantGraphEmbeddingsQueryServiceEffect(config).pipe(
|
|
|
|
|
Effect.flatMap(use),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export function makeQdrantGraphEmbeddingsQuery(
|
|
|
|
|
config: QdrantGraphQueryConfig = {},
|
|
|
|
|
): QdrantGraphEmbeddingsQuery {
|
|
|
|
|
const queryEffect = (request: GraphEmbeddingsQueryRequest) =>
|
|
|
|
|
withQdrantGraphEmbeddingsQuery(config, (query) => query.query(request));
|
|
|
|
|
|
2026-06-01 23:19:54 -05:00
|
|
|
return {
|
|
|
|
|
query: (request) => Effect.runPromise(queryEffect(request)),
|
|
|
|
|
queryEffect,
|
|
|
|
|
};
|
2026-04-05 22:44:45 -05:00
|
|
|
}
|
2026-06-01 16:22:25 -05:00
|
|
|
|
|
|
|
|
export interface QdrantGraphEmbeddingsQueryServiceShape {
|
|
|
|
|
readonly query: (
|
|
|
|
|
request: GraphEmbeddingsQueryRequest,
|
|
|
|
|
) => Effect.Effect<ReadonlyArray<EntityMatch>, QdrantGraphEmbeddingsQueryError>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class QdrantGraphEmbeddingsQueryService extends Context.Service<
|
|
|
|
|
QdrantGraphEmbeddingsQueryService,
|
|
|
|
|
QdrantGraphEmbeddingsQueryServiceShape
|
|
|
|
|
>()(
|
|
|
|
|
"@trustgraph/flow/query/embeddings/qdrant-graph/QdrantGraphEmbeddingsQueryService",
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
export const makeQdrantGraphEmbeddingsQueryService = (
|
|
|
|
|
config: QdrantGraphQueryConfig = {},
|
2026-06-02 04:10:03 -05:00
|
|
|
): QdrantGraphEmbeddingsQueryServiceShape => ({
|
|
|
|
|
query: (request) => withQdrantGraphEmbeddingsQuery(config, (query) => query.query(request)),
|
|
|
|
|
});
|
2026-06-01 16:22:25 -05:00
|
|
|
|
|
|
|
|
export const QdrantGraphEmbeddingsQueryLive = (
|
|
|
|
|
config: QdrantGraphQueryConfig = {},
|
2026-06-02 04:10:03 -05:00
|
|
|
): Layer.Layer<QdrantGraphEmbeddingsQueryService, QdrantGraphEmbeddingsQueryError> =>
|
|
|
|
|
Layer.effect(
|
2026-06-01 16:22:25 -05:00
|
|
|
QdrantGraphEmbeddingsQueryService,
|
2026-06-02 04:10:03 -05:00
|
|
|
makeQdrantGraphEmbeddingsQueryServiceEffect(config).pipe(
|
|
|
|
|
Effect.map((service) => QdrantGraphEmbeddingsQueryService.of(service)),
|
|
|
|
|
),
|
2026-06-01 16:22:25 -05:00
|
|
|
);
|