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:
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user