feat: implement QuestionBank CRUD with pagination and template query

- Add pagination support to findAll (page, limit query params)
- Add findByTemplateId method to service
- Add GET /by-template/:templateId endpoint to controller
- Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import { Module, forwardRef } from '@nestjs/common';
import { RagService } from './rag.service';
import { ElasticsearchModule } from '../elasticsearch/elasticsearch.module';
import { EmbeddingService } from '../knowledge-base/embedding.service';
import { ModelConfigModule } from '../model-config/model-config.module';
import { TenantModule } from '../tenant/tenant.module';
import { UserModule } from '../user/user.module';
import { RerankService } from './rerank.service';
@Module({
imports: [
forwardRef(() => ElasticsearchModule),
ModelConfigModule,
TenantModule,
UserModule,
],
providers: [RagService, EmbeddingService, RerankService],
exports: [RagService],
})
export class RagModule {}
+173
View File
@@ -0,0 +1,173 @@
import { Injectable, Logger } from '@nestjs/common';
import { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
import { EmbeddingService } from '../knowledge-base/embedding.service';
import { RerankService } from './rerank.service';
import { I18nService } from '../i18n/i18n.service';
import { DEFAULT_LANGUAGE } from '../common/constants';
export interface RagSearchResult {
id: string;
score: number;
content: string;
fileId: string;
fileName: string;
title: string;
chunkIndex: number;
startPosition?: number;
endPosition?: number;
}
@Injectable()
export class RagService {
private readonly logger = new Logger(RagService.name);
constructor(
private readonly elasticsearchService: ElasticsearchService,
private readonly embeddingService: EmbeddingService,
private readonly rerankService: RerankService,
private readonly i18nService: I18nService,
) {}
/**
* Main research method for RAG.
* Supports hybrid search, reranking, query expansion, and HyDE.
*/
async searchKnowledge(
query: string,
userId: string,
topK: number = 5,
similarityThreshold: number = 0.0,
embeddingModelId?: string,
enableFullTextSearch: boolean = true,
enableRerank: boolean = false,
rerankModelId?: string,
selectedGroups?: string[],
selectedFiles?: string[],
rerankThreshold: number = 0.0,
tenantId?: string,
enableQueryExpansion: boolean = false,
enableHyDE: boolean = false,
language: string = DEFAULT_LANGUAGE,
): Promise<RagSearchResult[]> {
this.logger.log(
`RAG Search: query="${query}", rerank=${enableRerank}, tenantId=${tenantId}`,
);
// 1. Get embedding for the query if needed
let queryVector: number[] = [];
if (embeddingModelId) {
try {
const vectors = await this.embeddingService.getEmbeddings(
[query],
embeddingModelId,
);
queryVector = vectors[0];
} catch (error) {
this.logger.error('Failed to generate query embedding', error);
}
}
// 2. Perform search via Elasticsearch
// Note: ElasticsearchService.hybridSearch supports explicitFileIds
const searchResults = await this.elasticsearchService.hybridSearch(
queryVector,
query,
userId,
topK * 2, // Get more results for reranking
0.7, // Default vector weight
selectedGroups,
selectedFiles,
tenantId,
);
let finalResults = searchResults;
// 3. Apply Threshold
finalResults = finalResults.filter((r) => r.score >= similarityThreshold);
// 4. Perform Reranking if enabled
if (enableRerank && rerankModelId && finalResults.length > 0) {
try {
// Map search results to string array for reranking
const documentTexts = finalResults.map((r) => r.content);
const rerankedPairs = await this.rerankService.rerank(
query,
documentTexts,
userId,
rerankModelId,
topK,
tenantId,
);
// Map reranked results back to RagSearchResult
finalResults = rerankedPairs
.filter((pair) => pair.score >= rerankThreshold)
.map((pair) => {
const originalResult = finalResults[pair.index];
return {
...originalResult,
score: pair.score, // Update with rerank score
};
});
} catch (error) {
this.logger.error(
'Reranking failed, falling back to original results',
error,
);
}
}
// 5. Final Slice
return finalResults.slice(0, topK);
}
/**
* Extract unique document names as sources
*/
extractSources(results: RagSearchResult[]): string[] {
const sources = new Set<string>();
results.forEach((r) => {
if (r.fileName) sources.add(r.fileName);
});
return Array.from(sources);
}
/**
* Build the RAG prompt with instructions in the correct language
*/
buildRagPrompt(
query: string,
searchResults: RagSearchResult[],
language: string = DEFAULT_LANGUAGE,
): string {
const normalizedLang = this.i18nService.normalizeLanguage(language);
// Build context
let context = '';
if (searchResults.length === 0) {
context =
normalizedLang === 'zh'
? '未找到相关信息。'
: normalizedLang === 'ja'
? '関連情報が見つかりませんでした。'
: 'No relevant information found.';
} else {
searchResults.forEach((result, index) => {
context += `[${index + 1}] File: ${result.fileName} (Score: ${result.score.toFixed(4)})\nContent: ${result.content}\n\n`;
});
}
// Get localized prompt template from I18nService
const promptTemplate = this.i18nService.getPrompt(
language,
'withContext',
false,
);
return promptTemplate
.replace('{context}', context)
.replace('{history}', '') // History placeholders are usually handled in ChatService
.replace('{question}', query);
}
}
+127
View File
@@ -0,0 +1,127 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ModelConfigService } from '../model-config/model-config.service';
import { ModelType } from '../types';
import axios from 'axios';
export interface RerankResult {
index: number;
relevance_score: number;
document?: string; // Optional, some APIs return it
}
@Injectable()
export class RerankService {
private readonly logger = new Logger(RerankService.name);
constructor(
private modelConfigService: ModelConfigService,
private configService: ConfigService,
) {}
/**
* Execute reranking
* @param query User query
* @param documents Candidate document list
* @param userId User ID
* @param rerankModelId Selected rerank model config ID
* @param topN Number of results to return (top N)
*/
async rerank(
query: string,
documents: string[],
userId: string,
rerankModelId: string,
topN: number = 5,
tenantId?: string,
): Promise<{ index: number; score: number }[]> {
if (!documents || documents.length === 0) {
return [];
}
let modelConfig;
try {
// 1. Get model config
modelConfig = await this.modelConfigService.findOne(rerankModelId);
if (!modelConfig || modelConfig.type !== ModelType.RERANK) {
this.logger.warn(`Invalid rerank model config: ${rerankModelId}`);
// Fallback: return original order with dummy scores
return documents.map((_, index) => ({
index,
score: 0.99 - index * 0.01,
}));
}
const apiKey = modelConfig.apiKey;
const baseUrl = modelConfig.baseUrl || ''; // e.g. https://api.siliconflow.cn/v1
const modelName = modelConfig.modelId; // e.g. BAAI/bge-reranker-v2-m3
this.logger.log(
`Reranking ${documents.length} docs with model ${modelName} at ${baseUrl}`,
);
// 2. Build API request (OpenAI/SiliconFlow compatible Rerank API)
// Note: Standard OpenAI API does not have /rerank, but SiliconFlow/Jina/Cohere use similar structure
// SiliconFlow format: POST /v1/rerank { model, query, documents, top_n }
const endpoint = baseUrl.replace(/\/+$/, '');
// Log the exact endpoint being called
this.logger.log(`Calling Rerank API: ${endpoint} (Model: ${modelName})`);
const response = await axios.post(
endpoint,
{
model: modelName,
query: query,
documents: documents,
top_n: topN,
return_documents: false, // We only need indices and scores
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
timeout: 10000,
},
);
// 3. Parse response
// Expected response format (SiliconFlow/Cohere):
// { results: [ { index: 0, relevance_score: 0.98 }, ... ] }
if (response.data && response.data.results) {
const results = response.data.results as RerankResult[];
return results
.map((r) => ({
index: r.index,
score: r.relevance_score,
}))
.sort((a, b) => b.score - a.score); // Ensure sorted
} else {
this.logger.error(
'Unexpected rerank API response structure',
response.data,
);
return documents.map((_, index) => ({ index, score: 0 }));
}
} catch (error) {
let errorMessage = error.message;
if (
error.code === 'EPROTO' ||
error.message.includes('wrong version number')
) {
errorMessage = `${error.message}. This often happens when using HTTPS to connect to an HTTP server. Please check your model Base URL protocol (http vs https).`;
} else if (error.response?.status === 404) {
const endpoint = modelConfig?.baseUrl?.replace(/\/+$/, '');
errorMessage = `Endpoint not found (404). Tried: ${endpoint}. Please check if your Base URL is correct. (Note: We use the Base URL exactly as provided for Rerank models).`;
}
this.logger.error(`Rerank failed: ${errorMessage}`, error.response?.data);
// Fallback on error: return original order
return documents.map((_, index) => ({ index, score: 0 }));
}
}
}