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:
parent
da134a1689
commit
9fa99b84b6
2 changed files with 120 additions and 0 deletions
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue