DocumentRepository.ts•8.65 kB
import { BM25Calculator, escapeRegExp, type SearchResult, type DocumentChunk } from '../utils/bm25.js';
import { KnowledgeDocument } from '../models/Document.js';
/**
 * 문서 저장소
 * BM25 알고리즘을 사용하여 문서를 검색하고 관리
 */
export class DocumentRepository {
  private readonly instanceId: string = `repo-${Date.now()}-${Math.random()}`;
  private readonly documents: Map<number, KnowledgeDocument> = new Map();
  private readonly domainCalculators: Map<string, BM25Calculator> = new Map();
  private globalCalculator: BM25Calculator | null = null;
  private initialized: boolean = false;
  private initializing: boolean = false;
  constructor() {
    console.error(`[DEBUG] Repository constructor (${this.instanceId}): 초기화 대기 상태`);
  }
  /**
   * 비동기로 Repository 초기화
   * @param documents 로드된 문서 배열
   */
  async initialize(documents: KnowledgeDocument[]): Promise<void> {
    if (this.initialized) {
      console.error(`[DEBUG] Repository (${this.instanceId}) already initialized`);
      return;
    }
    if (this.initializing) {
      console.error(`[DEBUG] Repository (${this.instanceId}) already initializing, waiting...`);
      // 초기화 완료까지 대기
      while (this.initializing && !this.initialized) {
        await new Promise(resolve => setTimeout(resolve, 100));
      }
      return;
    }
    try {
      this.initializing = true;
      console.error(`[DEBUG] Repository initialization started (${this.instanceId}): received ${documents.length} documents`);
      // 문서 저장
      documents.forEach(doc => {
        console.error(`[DEBUG] Storing document: ID=${doc.id}, domainName=${doc.domainName}`);
        this.documents.set(doc.id, doc);
      });
      console.error(`[DEBUG] Repository: stored ${this.documents.size} documents in Map`);
      // BM25 인덱스 구축 (무거운 작업)
      console.error(`[DEBUG] Building BM25 indexes...`);
      await this.buildBM25Indexes(documents);
      
      this.initialized = true;
      console.error(`[DEBUG] Repository initialization completed (${this.instanceId})`);
    } catch (error) {
      console.error(`[DEBUG] Repository initialization failed: ${error}`);
      throw error;
    } finally {
      this.initializing = false;
    }
  }
  /**
   * BM25 인덱스 구축 (별도 메서드로 분리)
   */
  private async buildBM25Indexes(documents: KnowledgeDocument[]): Promise<void> {
    const domainChunks = new Map<string, DocumentChunk[]>();
    const allChunks: DocumentChunk[] = [];
    documents.forEach(doc => {
      const chunks = doc.getChunks();
      allChunks.push(...chunks);
      const domain = doc.domainName || 'general';
      if (!domainChunks.has(domain)) {
        domainChunks.set(domain, []);
      }
      domainChunks.get(domain)!.push(...chunks);
    });
    // 도메인별 계산기 초기화 (CPU 집약적 작업)
    domainChunks.forEach((chunks, domain) => {
      this.domainCalculators.set(domain, new BM25Calculator(chunks));
      console.error(`[DEBUG] Created BM25Calculator for domain: ${domain} (${chunks.length} chunks)`);
    });
    // 전역 계산기 초기화
    if (allChunks.length > 0) {
      this.globalCalculator = new BM25Calculator(allChunks);
      console.error(`[DEBUG] Created global BM25Calculator (${allChunks.length} chunks)`);
    }
  }
  /**
   * 초기화 완료 여부 확인
   */
  isInitialized(): boolean {
    return this.initialized;
  }
  /**
   * 초기화 완료 보장 (가드 메서드)
   */
  private ensureInitialized(): void {
    if (!this.initialized) {
      throw new Error('Repository가 아직 초기화되지 않았습니다. 잠시 후 다시 시도해주세요.');
    }
  }
  /**
   * 키워드로 문서 검색
   * @param keywords 검색 키워드 배열
   * @param options 검색 옵션
   * @returns 검색 결과 문자열
   */
  async searchDocuments(
    keywords: string[],
    options: {
      domain?: string;
      topN?: number;
      contextWindow?: number;
    } = {}
  ): Promise<string> {
    this.ensureInitialized();
    const { domain, topN = 10, contextWindow = 1 } = options;
    // 검색할 계산기 선택
    let calculator: BM25Calculator | null;
    if (domain && this.domainCalculators.has(domain)) {
      calculator = this.domainCalculators.get(domain)!;
    } else {
      calculator = this.globalCalculator;
    }
    if (!calculator) {
      return "검색 가능한 문서가 없습니다.";
    }
    // 키워드를 정규식 패턴으로 변환
    const pattern = keywords
      .map(keyword => escapeRegExp(keyword.trim()))
      .filter(keyword => keyword.length > 0)
      .join("|");
    if (!pattern) {
      return "유효한 검색 키워드가 없습니다.";
    }
    // BM25 검색 수행
    const results = calculator.calculate(pattern);
    const topResults = results.slice(0, topN);
    return this.formatSearchResults(topResults, contextWindow);
  }
  /**
   * ID로 문서 조회
   * @param id 문서 ID
   * @returns 문서 객체 또는 null
   */
  getDocumentById(id: number): KnowledgeDocument | null {
    this.ensureInitialized();
    return this.documents.get(id) || null;
  }
  /**
   * Repository 인스턴스 ID 조회
   */
  getInstanceId(): string {
    return this.instanceId;
  }
  /**
   * 모든 도메인 목록 조회
   * @returns 도메인 정보 배열
   */
  listDomains(): Array<{ name: string; documentCount: number }> {
    this.ensureInitialized();
    console.error(`[DEBUG] listDomains called on instance ${this.instanceId}, documents.size=${this.documents.size}`);
    const domainCounts = new Map<string, number>();
    this.documents.forEach(doc => {
      const domain = doc.domainName || 'general';
      console.error(`[DEBUG] Processing document: ID=${doc.id}, domain=${domain}`);
      domainCounts.set(domain, (domainCounts.get(domain) || 0) + 1);
    });
    console.error(`[DEBUG] domainCounts Map: ${JSON.stringify([...domainCounts.entries()])}`);
    const result = Array.from(domainCounts.entries()).map(([name, count]) => ({
      name,
      documentCount: count
    }));
    console.error(`[DEBUG] listDomains result: ${JSON.stringify(result)}`);
    return result;
  }
  /**
   * 특정 도메인의 문서 목록 조회
   * @param domain 도메인 이름
   * @returns 문서 정보 배열
   */
  listDocumentsByDomain(domain?: string): Array<{
    id: number;
    title: string;
    description: string;
    keywords: string[];
  }> {
    this.ensureInitialized();
    const docs: KnowledgeDocument[] = [];
    this.documents.forEach(doc => {
      if (!domain || doc.domainName === domain || (!doc.domainName && domain === 'general')) {
        docs.push(doc);
      }
    });
    return docs.map(doc => ({
      id: doc.id,
      title: doc.title,
      description: doc.description,
      keywords: doc.keywords
    }));
  }
  /**
   * 검색 결과 포맷팅
   */
  private formatSearchResults(results: SearchResult[], contextWindow: number): string {
    if (results.length === 0) {
      return "검색 결과가 없습니다.";
    }
    const formattedResults = results
      .map(result => {
        const document = this.documents.get(result.id);
        if (!document) return null;
        const chunks = document.getChunkWithWindow(result.chunkId, contextWindow);
        if (chunks.length === 0) return null;
        return this.formatChunks(chunks, result.score);
      })
      .filter(result => result !== null);
    return formattedResults.join("\n\n---\n\n");
  }
  /**
   * 청크 포맷팅
   */
  private formatChunks(chunks: DocumentChunk[], score: number): string {
    const firstChunk = chunks[0];
    const content = chunks.map(chunk => chunk.text).join("\n\n");
    return `## 문서: ${firstChunk.originTitle}
* 문서 ID: ${firstChunk.id}
* 관련도 점수: ${score.toFixed(2)}
${content}`;
  }
  /**
   * 통계 정보 조회
   */
  getStatistics() {
    // getStatistics는 초기화 체크 없이 허용 (디버그용)
    let totalDocuments = 0;
    let totalChunks = 0;
    let totalWords = 0;
    this.documents.forEach(doc => {
      totalDocuments++;
      const chunks = doc.getChunks();
      totalChunks += chunks.length;
      totalWords += chunks.reduce((sum, chunk) => sum + chunk.wordCount, 0);
    });
    return {
      totalDocuments,
      totalChunks,
      totalWords,
      averageChunksPerDocument: totalDocuments > 0 ? totalChunks / totalDocuments : 0,
      averageWordsPerChunk: totalChunks > 0 ? totalWords / totalChunks : 0,
      domains: this.listDomains()
    };
  }
}