|
@@ -38,8 +38,10 @@ export class SearchEndpoint extends BaseEndpointComponent {
|
|
|
summary: 'Semantic search in scraped content',
|
|
summary: 'Semantic search in scraped content',
|
|
|
description:
|
|
description:
|
|
|
'Run a semantic (vector) search over a site\'s scraped content. ' +
|
|
'Run a semantic (vector) search over a site\'s scraped content. ' +
|
|
|
- 'Omit `category` to search all four buckets in parallel and return a merged, score-ranked list. ' +
|
|
|
|
|
|
|
+ 'Omit `category` to search all categories in parallel and return a merged, score-ranked list. ' +
|
|
|
'Use `?format=text` for plain-text output suitable for voice/LLM pipelines; JSON is the default. ' +
|
|
'Use `?format=text` for plain-text output suitable for voice/LLM pipelines; JSON is the default. ' +
|
|
|
|
|
+ 'Use `?full=true` to replace the truncated `snippet` field with a `content` field containing the full page markdown; results are deduplicated by content_id. ' +
|
|
|
|
|
+ 'Use `?limit=N` (or include `limit` in the body) to cap result count (1-20). Query takes precedence. ' +
|
|
|
'Requires the site to have Qdrant enabled and a custom_id set.',
|
|
'Requires the site to have Qdrant enabled and a custom_id set.',
|
|
|
tags: ['Search'],
|
|
tags: ['Search'],
|
|
|
requiresAuth: true,
|
|
requiresAuth: true,
|
|
@@ -58,6 +60,20 @@ export class SearchEndpoint extends BaseEndpointComponent {
|
|
|
required: false,
|
|
required: false,
|
|
|
description: 'Response format: "json" (default) or "text"',
|
|
description: 'Response format: "json" (default) or "text"',
|
|
|
},
|
|
},
|
|
|
|
|
+ {
|
|
|
|
|
+ name: 'full',
|
|
|
|
|
+ in: 'query',
|
|
|
|
|
+ type: 'boolean',
|
|
|
|
|
+ required: false,
|
|
|
|
|
+ description: 'When truthy, each result gets a `content` field with the full page markdown instead of `snippet`. Results are deduplicated by content_id (highest score kept).',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: 'limit',
|
|
|
|
|
+ in: 'query',
|
|
|
|
|
+ type: 'number',
|
|
|
|
|
+ required: false,
|
|
|
|
|
+ description: 'Max results (1-20). Takes precedence over `limit` in the request body. Default 10.',
|
|
|
|
|
+ },
|
|
|
],
|
|
],
|
|
|
requestBody: {
|
|
requestBody: {
|
|
|
required: true,
|
|
required: true,
|
|
@@ -96,7 +112,8 @@ export class SearchEndpoint extends BaseEndpointComponent {
|
|
|
const startTime = Date.now();
|
|
const startTime = Date.now();
|
|
|
const identifier = req.params.id;
|
|
const identifier = req.params.id;
|
|
|
const format = String(req.query.format || 'json').toLowerCase();
|
|
const format = String(req.query.format || 'json').toLowerCase();
|
|
|
- const { q, category, limit: limitRaw } = req.body || {};
|
|
|
|
|
|
|
+ const full = ['1', 'true', 'yes'].includes(String(req.query.full || '').toLowerCase());
|
|
|
|
|
+ const { q, category, limit: bodyLimit } = req.body || {};
|
|
|
|
|
|
|
|
// Service availability
|
|
// Service availability
|
|
|
if (!this.qdrantService || !this.embeddingService || !this.config.qdrant.enabled) {
|
|
if (!this.qdrantService || !this.embeddingService || !this.config.qdrant.enabled) {
|
|
@@ -122,8 +139,11 @@ export class SearchEndpoint extends BaseEndpointComponent {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Validate limit
|
|
|
|
|
|
|
+ // Validate limit — query string wins over body for backwards-friendly
|
|
|
|
|
+ // override (clients can keep posting limit in body; new callers can use
|
|
|
|
|
+ // ?limit=2 without rewriting their JSON body).
|
|
|
let limit = 10;
|
|
let limit = 10;
|
|
|
|
|
+ const limitRaw = req.query.limit !== undefined ? req.query.limit : bodyLimit;
|
|
|
if (limitRaw !== undefined) {
|
|
if (limitRaw !== undefined) {
|
|
|
const parsed = Number(limitRaw);
|
|
const parsed = Number(limitRaw);
|
|
|
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 20) {
|
|
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 20) {
|
|
@@ -162,18 +182,35 @@ export class SearchEndpoint extends BaseEndpointComponent {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Run search — single category or all four in parallel
|
|
|
|
|
|
|
+ // Run search — single category or all categories in parallel.
|
|
|
|
|
+ // When `full=true` we over-fetch (3×) so deduplicating by content_id
|
|
|
|
|
+ // afterwards still leaves room to fill `limit` results.
|
|
|
const categoriesToSearch: Category[] = category ? [category as Category] : CATEGORIES;
|
|
const categoriesToSearch: Category[] = category ? [category as Category] : CATEGORIES;
|
|
|
|
|
+ const perCategoryLimit = full ? limit * 3 : limit;
|
|
|
|
|
|
|
|
const perCategory = await Promise.all(
|
|
const perCategory = await Promise.all(
|
|
|
categoriesToSearch.map((cat) =>
|
|
categoriesToSearch.map((cat) =>
|
|
|
- this.qdrantService!.searchInCategory(siteCustomId, cat, embeddingResult.embedding, limit)
|
|
|
|
|
|
|
+ this.qdrantService!.searchInCategory(siteCustomId, cat, embeddingResult.embedding, perCategoryLimit)
|
|
|
)
|
|
)
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
- // Flatten, preserve each hit's category (Qdrant payload already has content_type),
|
|
|
|
|
- // sort by score desc, cap at `limit`
|
|
|
|
|
- const flat = perCategory.flat().sort((a, b) => b.score - a.score).slice(0, limit);
|
|
|
|
|
|
|
+ let flat = perCategory.flat().sort((a, b) => b.score - a.score);
|
|
|
|
|
+
|
|
|
|
|
+ // Dedupe by content_id when returning full content — otherwise multiple
|
|
|
|
|
+ // chunks of the same page each carry the same 5–50 KB body and the
|
|
|
|
|
+ // response balloons. Highest-scoring chunk wins.
|
|
|
|
|
+ if (full) {
|
|
|
|
|
+ const seen = new Set<string>();
|
|
|
|
|
+ flat = flat.filter((hit) => {
|
|
|
|
|
+ const cid = (hit.payload as any)?.content_id;
|
|
|
|
|
+ if (!cid) return true;
|
|
|
|
|
+ if (seen.has(cid)) return false;
|
|
|
|
|
+ seen.add(cid);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ flat = flat.slice(0, limit);
|
|
|
|
|
|
|
|
const durationMs = Date.now() - startTime;
|
|
const durationMs = Date.now() - startTime;
|
|
|
|
|
|
|
@@ -182,11 +219,13 @@ export class SearchEndpoint extends BaseEndpointComponent {
|
|
|
|
|
|
|
|
if (format === 'text') {
|
|
if (format === 'text') {
|
|
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
|
- res.send(this.formatAsText(flat, q));
|
|
|
|
|
|
|
+ res.send(this.formatAsText(flat, q, full));
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Default JSON response
|
|
|
|
|
|
|
+ // Default JSON response. `full=true` swaps the truncated snippet for
|
|
|
|
|
+ // a `content` field carrying the full markdown of the page (looked up
|
|
|
|
|
+ // by content_id). Default shape is unchanged for backwards compat.
|
|
|
res.json({
|
|
res.json({
|
|
|
site_id: identifier,
|
|
site_id: identifier,
|
|
|
query: q,
|
|
query: q,
|
|
@@ -194,14 +233,18 @@ export class SearchEndpoint extends BaseEndpointComponent {
|
|
|
total: flat.length,
|
|
total: flat.length,
|
|
|
results: flat.map((hit) => {
|
|
results: flat.map((hit) => {
|
|
|
const p = hit.payload as any;
|
|
const p = hit.payload as any;
|
|
|
- return {
|
|
|
|
|
|
|
+ const base = {
|
|
|
score: hit.score,
|
|
score: hit.score,
|
|
|
content_type: p?.content_type || null,
|
|
content_type: p?.content_type || null,
|
|
|
url: p?.url || null,
|
|
url: p?.url || null,
|
|
|
title: p?.title || null,
|
|
title: p?.title || null,
|
|
|
content_id: p?.content_id || null,
|
|
content_id: p?.content_id || null,
|
|
|
- snippet: this.makeSnippet(p?.chunk_text || ''),
|
|
|
|
|
};
|
|
};
|
|
|
|
|
+ if (full) {
|
|
|
|
|
+ const row = p?.content_id ? this.db.getContentById(p.content_id) : null;
|
|
|
|
|
+ return { ...base, content: row?.content ?? p?.chunk_text ?? '' };
|
|
|
|
|
+ }
|
|
|
|
|
+ return { ...base, snippet: this.makeSnippet(p?.chunk_text || '') };
|
|
|
}),
|
|
}),
|
|
|
});
|
|
});
|
|
|
} catch (error: any) {
|
|
} catch (error: any) {
|
|
@@ -246,7 +289,7 @@ export class SearchEndpoint extends BaseEndpointComponent {
|
|
|
return (lastSpace > maxLength * 0.6 ? cut.slice(0, lastSpace) : cut) + '…';
|
|
return (lastSpace > maxLength * 0.6 ? cut.slice(0, lastSpace) : cut) + '…';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private formatAsText(hits: Array<{ score: number; payload: any }>, query: string): string {
|
|
|
|
|
|
|
+ private formatAsText(hits: Array<{ score: number; payload: any }>, query: string, full = false): string {
|
|
|
if (hits.length === 0) {
|
|
if (hits.length === 0) {
|
|
|
return `No results for: "${query}"`;
|
|
return `No results for: "${query}"`;
|
|
|
}
|
|
}
|
|
@@ -254,7 +297,11 @@ export class SearchEndpoint extends BaseEndpointComponent {
|
|
|
.map((hit) => {
|
|
.map((hit) => {
|
|
|
const p = hit.payload || {};
|
|
const p = hit.payload || {};
|
|
|
const title = p.title || 'Untitled';
|
|
const title = p.title || 'Untitled';
|
|
|
- const text = p.chunk_text || '';
|
|
|
|
|
|
|
+ let text = p.chunk_text || '';
|
|
|
|
|
+ if (full && p.content_id) {
|
|
|
|
|
+ const row = this.db.getContentById(p.content_id);
|
|
|
|
|
+ if (row?.content) text = row.content;
|
|
|
|
|
+ }
|
|
|
return `${title}\n${text}`;
|
|
return `${title}\n${text}`;
|
|
|
})
|
|
})
|
|
|
.join('\n\n');
|
|
.join('\n\n');
|