1
16
17
18
19
20
21import { ApiError, ErrorCodes } from "@factory/shared";
22
23import {
24 CONTENT_INDEX_ALIAS,
25 buildFullTextBody,
26 buildHybridKnnOnlyBody,
27 buildHybridBody,
28 buildPrefixBody,
29 contentIndex,
30 dateSortField,
31 decodeCursor,
32 decodeDateCursor,
33 encodeCursor,
34 encodeDateCursor,
35 withDateSort,
36 withSearchAfter,
37} from "./queries.js";
38import { fuseHybrid } from "./rrf.js";
39import { CHUNK_DOC_TYPE, displayType } from "./types.js";
40import type {
41 AnswerCitation,
42 SearchDoc,
43 SearchQuery,
44 SearchResult,
45 SearchResponse,
46 Suggestion,
47 ViewerContext,
48} from "./types.js";
49
50export interface SearchClientOptions {
51
52 baseUrl: string;
53
54 fetchImpl?: typeof fetch;
55
56 auth?: { username: string; password: string };
57
58 timeoutMs?: number;
59 now?: () => number;
60
61 knnK?: number;
62}
63
64interface OsHit {
65 _id: string;
66 _score?: number;
67 _source: SearchDoc & { highlight?: { title?: string[]; body?: string[] } };
68}
69
70interface OsResponse {
71 hits?: {
72 total?: { value?: number };
73 hits?: OsHit[];
74 };
75}
76
77export class SearchClient {
78 readonly baseUrl: string;
79 readonly fetchImpl: typeof fetch;
80 private readonly headers: Record<string, string>;
81 private readonly timeoutMs: number;
82 private readonly now: () => number;
83 private readonly knnK: number;
84
85 constructor(options: SearchClientOptions) {
86 this.baseUrl = options.baseUrl.replace(/\/+$/, "");
87 this.fetchImpl = options.fetchImpl ?? fetch;
88 this.headers = { "content-type": "application/json" };
89 if (options.auth) {
90 this.headers.authorization =
91 "Basic " + Buffer.from(`${options.auth.username}:${options.auth.password}`).toString("base64");
92 }
93 this.timeoutMs = options.timeoutMs ?? 5000;
94 this.now = options.now ?? Date.now;
95 this.knnK = options.knnK ?? 50;
96 }
97
98
99 async indexDocs(docs: SearchDoc[]): Promise<{ indexed: number }> {
100 if (docs.length === 0) return { indexed: 0 };
101 const lines: string[] = [];
102 for (const doc of docs) {
103 lines.push(JSON.stringify({ index: { _index: contentIndex(), _id: doc.docId } }));
104 lines.push(JSON.stringify(doc));
105 }
106 const res = await this.request("/_bulk", { method: "POST", body: lines.join("\n") + "\n" });
107 const parsed = (await res.json()) as { errors?: boolean; items?: { index?: { status?: number } }[] };
108 let indexed = 0;
109 for (const item of parsed.items ?? []) {
110 if (item.index && (item.index.status ?? 200) < 300) indexed++;
111 }
112 return { indexed };
113 }
114
115
116 async deleteDoc(docId: string): Promise<void> {
117 await this.request(`/${contentIndex()}/_doc/${encodeURIComponent(docId)}`, { method: "DELETE" });
118 }
119
120
129 async deleteChunksOf(parentDocIds: string | string[]): Promise<number> {
130 const ids = Array.isArray(parentDocIds) ? parentDocIds : [parentDocIds];
131 if (ids.length === 0) return 0;
132 const parentClause =
133 ids.length === 1
134 ? { term: { parentDocId: ids[0]! } }
135 : { terms: { parentDocId: ids } };
136 const body = JSON.stringify({
137 query: {
138 bool: {
139 filter: [parentClause, { term: { type: CHUNK_DOC_TYPE } }],
140 },
141 },
142 });
143 const res = await this.request(`/${contentIndex()}/_delete_by_query?conflicts=proceed`, {
144 method: "POST",
145 body,
146 });
147 const parsed = (await res.json()) as { deleted?: number };
148 return parsed.deleted ?? 0;
149 }
150
151
155 async updateDoc(docId: string, doc: Record<string, unknown>): Promise<void> {
156 await this.request(`/${contentIndex()}/_update/${encodeURIComponent(docId)}`, {
157 method: "POST",
158 body: JSON.stringify({ doc }),
159 });
160 }
161
162
163 async indexExists(indexOrAlias: string = contentIndex()): Promise<boolean> {
164 try {
165 await this.request(`/${indexOrAlias}`, {});
166 return true;
167 } catch {
168 return false;
169 }
170 }
171
172
183 async countDocs(workspaceIds?: string[]): Promise<number> {
184 const filter: Record<string, unknown>[] = [{
185 bool: { must_not: [{ term: { type: CHUNK_DOC_TYPE } }] },
186 }];
187 if (workspaceIds && workspaceIds.length > 0) {
188 filter.push({ terms: { workspaceId: workspaceIds } });
189 }
190 const out = await this.osRequest<OsResponse>(
191 `/${contentIndex()}/_search`,
192 { query: { bool: { filter } }, size: 0 }
193 );
194 return out.hits?.total?.value ?? 0;
195 }
196
197
202 async createIndexIfMissing(
203 indexName: string,
204 body: { settings?: Record<string, unknown>; mappings?: Record<string, unknown> },
205 ): Promise<boolean> {
206 try {
207 await this.request(`/${indexName}`, { method: "PUT", body: JSON.stringify(body) });
208 return true;
209 } catch (err) {
210
211 if (err instanceof ApiError && /resource_already_exists/.test(err.message)) return false;
212 throw err;
213 }
214 }
215
216
220 async createAlias(indexName: string, alias: string = CONTENT_INDEX_ALIAS): Promise<void> {
221 await this.request("/_aliases", {
222 method: "POST",
223 body: JSON.stringify({
224 actions: [
225 { remove: { index: indexName, alias } },
226 { add: { index: indexName, alias } },
227 ],
228 }),
229 });
230 }
231
232
239 async isAlias(name: string = contentIndex()): Promise<boolean> {
240 try {
241 await this.request(`/_alias/${name}`, {});
242 return true;
243 } catch (err) {
244 if (err instanceof ApiError && err.status === 404) return false;
245 throw err;
246 }
247 }
248
249
250 async deleteIndex(name: string): Promise<void> {
251 await this.request(`/${name}`, { method: "DELETE" });
252 }
253
254
255 async refresh(indexOrAlias: string = contentIndex()): Promise<void> {
256 await this.request(`/${indexOrAlias}/_refresh`, {});
257 }
258
259
260 async getVectorDimension(indexOrAlias: string = contentIndex()): Promise<number | null> {
261 const res = await this.request(`/${indexOrAlias}/_mapping`, {});
262 const parsed = (await res.json()) as Record<string, { mappings?: { properties?: { vector?: { dimension?: number } } } }>;
263 for (const key of Object.keys(parsed)) {
264 const dim = parsed[key]?.mappings?.properties?.vector?.dimension;
265 if (typeof dim === "number") return dim;
266 }
267 return null;
268 }
269
270
276 async docExists(docIds: string[]): Promise<Set<string>> {
277 const present = new Set<string>();
278 if (docIds.length === 0) return present;
279 for (let i = 0; i < docIds.length; i += 500) {
280 const chunk = docIds.slice(i, i + 500);
281 const res = await this.request(`/${contentIndex()}/_mget`, {
282 method: "POST",
283 body: JSON.stringify({ ids: chunk }),
284 });
285 const parsed = (await res.json()) as { docs?: Array<{ found?: boolean; _id?: string }> };
286 for (const d of parsed.docs ?? []) {
287 if (d.found && d._id) present.add(d._id);
288 }
289 }
290 return present;
291 }
292
293
300 async getDocs(docIds: string[]): Promise<Array<{ docId: string; source: Record<string, unknown> }>> {
301 const out: Array<{ docId: string; source: Record<string, unknown> }> = [];
302 if (docIds.length === 0) return out;
303 for (let i = 0; i < docIds.length; i += 500) {
304 const chunk = docIds.slice(i, i + 500);
305 const res = await this.request(`/${contentIndex()}/_mget`, {
306 method: "POST",
307 body: JSON.stringify({ ids: chunk }),
308 });
309 const parsed = (await res.json()) as {
310 docs?: Array<{ found?: boolean; _id?: string; _source?: Record<string, unknown> }>;
311 };
312 for (const d of parsed.docs ?? []) {
313 if (d.found && d._id && d._source) out.push({ docId: d._id, source: d._source });
314 }
315 }
316 return out;
317 }
318
319
326 async search(
327 query: SearchQuery,
328 viewer: ViewerContext,
329 queryVector?: number[],
330 ): Promise<SearchResponse> {
331 const started = this.now();
332 const mode = query.mode ?? "fulltext";
333 if (mode === "hybrid" && !queryVector) {
334 throw new ApiError(ErrorCodes.AI_ERROR, { message: "hybrid search requires queryVector" });
335 }
336 if (mode === "hybrid") {
337 return this.hybridSearch(query, viewer, queryVector!, started);
338 }
339 return this.fullTextSearch(query, viewer, started);
340 }
341
342
351 async suggest(prefix: string, viewer: ViewerContext, size = 10): Promise<Suggestion[]> {
352 const capped = Math.max(1, Math.min(size, 20));
353 const body = buildPrefixBody(prefix, viewer, capped);
354 const os = await this.osRequest<OsResponse>(`/${contentIndex()}/_search`, body);
355 const hits = os.hits?.hits ?? [];
356 const seen = new Set<string>();
357 const out: Suggestion[] = [];
358 for (const h of hits) {
359 const s = h._source;
360
361
362
363 const type = displayType(s);
364 if (seen.has(`${type}:${s.id}`)) continue;
365 seen.add(`${type}:${s.id}`);
366 out.push({ id: s.id, type, title: s.title });
367 if (out.length >= capped) break;
368 }
369 return out;
370 }
371
372 private async fullTextSearch(
373 query: SearchQuery,
374 viewer: ViewerContext,
375 started: number,
376 ): Promise<SearchResponse> {
377
378
379 if (query.sortBy === "createdAt" || query.sortBy === "updatedAt") {
380 return this.dateSortedSearch(query, viewer, started);
381 }
382 let body: Record<string, unknown> = buildFullTextBody(query, viewer);
383 if (query.page) body = withSearchAfter(body, decodeCursor(query.page));
384 const os = await this.osRequest<OsResponse>(`/${contentIndex()}/_search`, body);
385 const hits = os.hits?.hits ?? [];
386 const pageSize = query.pageSize ?? 25;
387 const results = hits.map((h) => this.mapHit(h, "bm25"));
388 const last = hits[hits.length - 1];
389 const cursor =
390 hits.length >= pageSize && last ? encodeCursor([last._score ?? 0, last._source.updatedAt, last._id]) : undefined;
391 return {
392 results,
393 total: os.hits?.total?.value ?? hits.length,
394 ...(cursor !== undefined ? { cursor } : {}),
395 latencyMs: this.now() - started,
396 };
397 }
398
399
406 private async dateSortedSearch(
407 query: SearchQuery,
408 viewer: ViewerContext,
409 started: number,
410 ): Promise<SearchResponse> {
411 const field = dateSortField(query.sortBy!);
412 let body: Record<string, unknown> = buildFullTextBody(query, viewer);
413 if (query.page) body = withDateSort(body, query.sortBy!, decodeDateCursor(query.page));
414 else body = withDateSort(body, query.sortBy!);
415 const os = await this.osRequest<OsResponse>(`/${contentIndex()}/_search`, body);
416 const hits = os.hits?.hits ?? [];
417 const pageSize = query.pageSize ?? 25;
418 const results = hits.map((h) => this.mapHit(h, "bm25"));
419 const last = hits[hits.length - 1];
420 const lastDate = last?._source[field];
421 const cursor =
422 hits.length >= pageSize && last && lastDate
423 ? encodeDateCursor([lastDate, last._id])
424 : undefined;
425 return {
426 results,
427 total: os.hits?.total?.value ?? hits.length,
428 ...(cursor !== undefined ? { cursor } : {}),
429 latencyMs: this.now() - started,
430 };
431 }
432
433 private async hybridSearch(
434 query: SearchQuery,
435 viewer: ViewerContext,
436 queryVector: number[],
437 started: number,
438 ): Promise<SearchResponse> {
439 const pageSize = query.pageSize ?? 25;
440
441
442
443
444 const bm25Body: Record<string, unknown> = {
445 ...buildHybridBody(query, viewer, queryVector),
446 size: this.knnK,
447 };
448
449 const knnBody = buildHybridKnnOnlyBody(queryVector, viewer, this.knnK);
450 const [bm25Os, knnOs] = await Promise.all([
451 this.osRequest<OsResponse>(`/${contentIndex()}/_search`, bm25Body),
452 this.osRequest<OsResponse>(`/${contentIndex()}/_search`, knnBody),
453 ]);
454 const bm25Hits = bm25Os.hits?.hits ?? [];
455 const knnHits = knnOs.hits?.hits ?? [];
456 const bm25List = bm25Hits.map((h) => h._id);
457 const vecList = knnHits.map((h) => h._id);
458 const fused = fuseHybrid(bm25List, vecList);
459 const byId = new Map<string, OsHit>();
460 for (const h of bm25Hits) byId.set(h._id, h);
461 for (const h of knnHits) if (!byId.has(h._id)) byId.set(h._id, h);
462 const results: SearchResult[] = [];
463 for (const f of fused) {
464 const hit = byId.get(f.docId);
465 if (!hit) continue;
466 const source: SearchResult["source"] =
467 f.sources.includes("bm25") && f.sources.includes("vec") ? "bm25+vec" : f.sources.includes("vec") ? "vec" : "bm25";
468 results.push({ ...this.mapHit(hit, source), score: f.score });
469 if (results.length >= pageSize) break;
470 }
471 return {
472 results,
473 total: bm25Os.hits?.total?.value ?? results.length,
474 latencyMs: this.now() - started,
475 };
476 }
477
478 private mapHit(hit: OsHit, source: SearchResult["source"]): SearchResult {
479 const s = hit._source;
480 const hl = s.highlight;
481 const snippet = hl?.body?.[0] ?? s.body.slice(0, 160);
482 const result: SearchResult = {
483 docId: hit._id,
484
485
486 type: displayType(s),
487 id: s.id,
488 title: s.title,
489 snippet,
490 url: s.url,
491 updatedAt: s.updatedAt,
492 workspaceId: s.workspaceId,
493 score: hit._score ?? 0,
494 source,
495 };
496 if (s.icon !== undefined) result.icon = s.icon;
497 if (s.createdByName !== undefined) result.createdByName = s.createdByName;
498 if (s.pageId !== undefined) result.pageId = s.pageId;
499 if (s.blockId !== undefined) result.blockId = s.blockId;
500 if (s.databaseId !== undefined) result.databaseId = s.databaseId;
501 if (s.recordId !== undefined) result.recordId = s.recordId;
502 return result;
503 }
504
505 private async request(path: string, init: { method?: string; body?: string }): Promise<Response> {
506 const controller = new AbortController();
507 const timer = setTimeout(() => controller.abort(), this.timeoutMs);
508 try {
509 const res = await this.fetchImpl(this.baseUrl + path, {
510 method: init.method ?? "GET",
511 headers: this.headers,
512 body: init.body ?? null,
513 signal: controller.signal,
514 });
515 if (!res.ok) {
516 throw new ApiError(ErrorCodes.SEARCH_UNAVAILABLE, {
517 message: `OpenSearch ${res.status} on ${path}`,
518 status: res.status,
519 });
520 }
521 return res;
522 } catch (err) {
523 if (err instanceof ApiError) throw err;
524 throw new ApiError(ErrorCodes.SEARCH_UNAVAILABLE, {
525 message: err instanceof Error ? err.message : "search request failed",
526 });
527 } finally {
528 clearTimeout(timer);
529 }
530 }
531
532 private async osRequest<T>(path: string, body: Record<string, unknown>): Promise<T> {
533 const res = await this.request(path, { method: "POST", body: JSON.stringify(body) });
534 return (await res.json()) as T;
535 }
536}
537
538
539export function citationsFromResults(results: SearchResult[], limit = 5): AnswerCitation[] {
540 return results.slice(0, limit).map((r) => ({
541 docId: r.docId,
542 title: r.title,
543 url: r.url,
544 quote: r.snippet ?? "",
545 relevance: Math.min(1, r.score),
546 }));
547}
548