feat: add semantic document search endpoint

- Implement POST /search with query and result limit validation
- Return matching document chunks and similarity scores
- Support configurable result limits from 1 to 20
- Return sanitized validation and search failure responses
- Add endpoint tests for successful searches, invalid inputs, and error handling
This commit is contained in:
Brandon Wolfgang 2026-10-22 14:25:02 -05:00
parent da134a1689
commit 9fa99b84b6
2 changed files with 120 additions and 0 deletions

View file

@ -296,4 +296,97 @@ describe("API endpoints", () => {
await app.close(); await app.close();
} }
}); });
it("POST /search returns matching results", async () => {
const expectedResults = [
{
documentId: "1",
title: "RAG smoke test",
content: "Relevant knowledge base content.",
chunkIndex: 0,
similarity: 0.92,
},
];
const app = buildApp(
undefined,
undefined,
undefined,
async (query, limit) => {
assert.equal(query, "knowledge base search");
assert.equal(limit, 3);
return expectedResults;
},
);
try {
const response = await app.inject({
method: "POST",
url: "/search",
payload: {
query: "knowledge base search",
limit: 3,
},
});
assert.equal(response.statusCode, 200);
assert.deepEqual(response.json(), { results: expectedResults });
} finally {
await app.close();
}
});
it("POST /search rejects invalid input", async () => {
const app = buildApp(undefined, undefined, undefined, async () => {
throw new Error("Search should not run");
});
try {
for (const payload of [
{ query: " " },
{ query: "valid query", limit: 0 },
{ query: "valid query", limit: 21 },
{ query: "valid query", limit: 1.5 },
{ query: 123 },
]) {
const response = await app.inject({
method: "POST",
url: "/search",
payload,
});
assert.equal(response.statusCode, 400);
assert.deepEqual(response.json(), {
error: "Invalid search request",
});
}
} finally {
await app.close();
}
});
it("POST /search returns 503 when search fails", async () => {
const app = buildApp(undefined, undefined, undefined, async () => {
throw new Error("Internal database connection details");
});
try {
const response = await app.inject({
method: "POST",
url: "/search",
payload: { query: "knowledge base search" },
});
assert.equal(response.statusCode, 503);
assert.deepEqual(response.json(), {
error: "Document search failed",
});
assert.equal(
response.body.includes("Internal database connection details"),
false,
);
} finally {
await app.close();
}
});
}); });

View file

@ -15,10 +15,16 @@ const documentSchema = z.object({
content: z.string().trim().min(1).max(100_000), content: z.string().trim().min(1).max(100_000),
}); });
const searchSchema = z.object({
query: z.string().trim().min(1).max(2_000),
limit: z.number().int().min(1).max(20).optional(),
});
export const buildApp = ( export const buildApp = (
generate = generateResponse, generate = generateResponse,
checkHealth = checkOllamaHealth, checkHealth = checkOllamaHealth,
ingest = ingestDocumentDefault, ingest = ingestDocumentDefault,
search = searchDocumentsDefault,
) => { ) => {
const app = Fastify({ const app = Fastify({
logger: true, logger: true,
@ -126,6 +132,27 @@ export const buildApp = (
} }
}); });
app.post("/search", async (request, reply) => {
const result = searchSchema.safeParse(request.body);
if (!result.success) {
return reply.code(400).send({
error: "Invalid search request",
});
}
try {
const results = await search(result.data.query, result.data.limit);
return { results };
} catch (error) {
request.log.error({ err: error }, "Document search failed");
return reply.code(503).send({
error: "Document search failed",
});
}
});
app.get("/test-error", async () => { app.get("/test-error", async () => {
throw new Error("Sensitive internal detail"); throw new Error("Sensitive internal detail");
}); });