Files
GMW/services/backend/src/modules/messages/qdrant.ts
T
asepharyana fa72fe03cd feat(archive): show real channel/thread names in semantic search UI
Gateway archive embedder now parses metadata.channel.{channelName,threadName}
from each message and stores channel_name/thread_name in the Qdrant payload.
Backend exposes them; the semantic results card renders the thread name (or
channel name) instead of a raw #snowflake, with the ID as a last-resort
fallback for legacy points. Matches the message feed's channel-label logic
(getMessageChannelLabel).
2026-09-01 19:27:56 +07:00

115 lines
3.0 KiB
TypeScript

import { config } from "@/shared/config/index";
import { createChildLogger } from "@/shared/logger/index";
const logger = createChildLogger("messages-qdrant");
export interface ArchiveHit {
score: number;
payload: {
text: string;
content_hash?: string;
analyzed_at: number;
expires_at: number;
username?: string;
channel_id?: string;
guild_id?: string;
thread_id?: string | null;
channel_name?: string | null;
thread_name?: string | null;
created_at?: number;
};
}
function baseUrl(): string {
return (config.QDRANT_URL ?? "http://100.121.180.82:6333").replace(
/\/+$/,
"",
);
}
function headers(): Record<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json" };
if (config.QDRANT_API_KEY) h["api-key"] = config.QDRANT_API_KEY;
return h;
}
export const ARCHIVE_COLLECTION =
config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
async function request(
method: string,
path: string,
body?: unknown,
timeoutMs = 10_000,
): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${baseUrl()}${path}`, {
method,
headers: headers(),
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
const text = await res.text();
if (!res.ok) {
throw new Error(
`Qdrant ${method} ${path} -> ${res.status}: ${text.slice(0, 200)}`,
);
}
return text ? JSON.parse(text) : null;
} finally {
clearTimeout(timer);
}
}
/** Search the archive collection for the nearest vectors to `vector`. */
export async function searchArchive(
vector: number[],
limit: number,
scoreThreshold: number,
guildId?: string,
): Promise<ArchiveHit[]> {
if (!config.QDRANT_URL) return [];
try {
const json = (await request(
"POST",
`/collections/${ARCHIVE_COLLECTION}/points/search`,
{
vector,
limit,
score_threshold: scoreThreshold,
with_payload: true,
// Optional scope: only return vectors from a specific guild's archive.
// Old points (embedded before rich metadata) have no guild_id payload —
// the `must` match simply excludes them, which is the correct behavior
// for a guild-scoped search.
...(guildId
? {
filter: {
must: [{ key: "guild_id", match: { value: guildId } }],
},
}
: {}),
},
)) as {
result?: Array<{
score?: number;
payload?: ArchiveHit["payload"];
}>;
};
return (json.result ?? [])
.filter((h) => h.payload?.text)
.map((h) => ({
score: h.score ?? 0,
payload: h.payload as ArchiveHit["payload"],
}));
} catch (error) {
logger.warn(
{ error: error instanceof Error ? error.message : String(error) },
"archive search failed",
);
return [];
}
}