Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

온톨로지 증강 생성과 검색 개선

온톨로지 증강 생성(Ontology-augmented generation, OAG)은 업무 자료를 찾아 대규모 언어 모델(LLM)에 필요한 맥락을 제공합니다. 검색 증강 생성(Retrieval-augmented generation, RAG)을 구성할 때에는 답변 생성 전에 어떤 자료를 검색할지 결정해야 합니다. 적합한 검색 방식은 자료와 질문에 따라 달라집니다.

질문과 필요한 자료 전체가 모델의 컨텍스트 한도에 들어간다면 검색 없이 전달하는 구성부터 검토합니다. 128,000토큰의 컨텍스트 창으로 300쪽 이상 텍스트를 다루는 규모가 예이지만 실제 페이지 수는 텍스트 밀도와 언어에 따라 달라집니다.

기본 구성과 검색 실패 확인

문서 처리에서 청크(Chunk)를 만들고 원본의 미디어 참조(Media reference)를 청크 객체(Object)에 연결합니다. 의미 기반 검색 함수로 가까운 청크를 찾고 Workshop의 PDF Viewer 위젯에서 원문을 표시합니다. 검색한 텍스트와 질문을 답변 생성에 전달합니다.

문서에 있는 내용인데도 답하지 못하면 필요한 문단이 실제로 검색되었고 프롬프트에 전달되었는지 먼저 확인합니다. 자료를 찾지 못한 문제를 생성 프롬프트만으로 해결하려 하지 않습니다.

단순 검색으로 필요한 품질을 얻으면 그 구성을 유지합니다. 부족한 질문 유형을 찾은 뒤 아래 방법을 추가하거나 결합합니다.

HyDE로 질의와 문단의 형식 맞추기

가설 문서 임베딩(Hypothetical document embeddings, HyDE)은 질문에 답할 법한 가상 문단을 LLM으로 만든 뒤 그 문단을 임베딩하여 검색합니다. 짧은 질문과 길고 구조화된 답변 문단 사이의 표현 차이를 줄이는 방법입니다.

가상의 보험 업무 문서에서 "How do we deal with animal collisions?"를 조사한다고 가정합니다. 실제 검색 대상 문단의 형식은 다음과 같을 수 있습니다. 이 예의 보장 내용은 실제 보험 약관이 아닙니다.

Claim Management - Motor: Animal Collision:
Animal collision claims are generally covered in type A, B, D policies.
However, exclusions apply.

모델에 문서 이름, 장, 절과 본문이 있는 가상 문단을 요청하면 다음처럼 같은 형태의 검색 입력을 만들 수 있습니다.

Animal Claims Management: General Terms:
Animal collision is commonly insured in fully comprehensive packages.

가상 문단을 사실 근거로 사용하지 않습니다. 이 텍스트의 임베딩으로 실제 문서 청크를 검색하고 검색된 원문을 답변 근거로 사용합니다.

관련도 순 키워드 검색

범용 임베딩 모델이 특정 산업의 용어 차이를 충분히 구분하지 못할 수 있습니다. 사용자 모델을 추가 학습하는 방법도 있지만 키워드 검색이 더 간단한 대안이 될 수 있습니다.

Object Storage V2의 관련도는 질의와 다른 청크들에 비추어 계산됩니다. TypeScript v1의 matchAnyToken으로 후보를 찾고 orderByRelevance로 정렬하여 상위 결과를 가져옵니다.

키워드만 사용하면 deer collisions라는 질의로 animal collision을 설명한 문단을 놓칠 수 있습니다. 질의 확장으로 의미가 가까운 단어를 함께 검색합니다.

질의 확장과 핵심 요구 추출

키워드 확장

키워드 검색 전에 LLM이 Help me find 같은 불필요한 표현과 불용어를 제거하고 동의어나 관련 용어를 추가하게 합니다. 가상 질문에서 사용할 수 있는 검색어 목록은 다음과 같습니다.

deer, animal, collision, claims, wildlife, accidents, vehicle, damage, car, insurance, policy, coverage, comprehensive, reimbursement

이 목록은 deer를 직접 언급하지 않는 animal, wildlife와 accidents 관련 문단도 후보에 포함합니다.

의미 기반 검색용 요구 추출

의미 기반 검색에서는 단어를 늘리기보다 사용자가 요청한 동작을 간결하게 추출하는 방식이 유용할 수 있습니다. 불용어 제거, 표제어 추출이나 어간 추출을 적용할 수도 있습니다.

예를 들어 "How do we deal with animal collisions?"의 핵심 요구는 "Deal with animal collisions."로 표현할 수 있습니다. 이 텍스트를 임베딩하여 검색합니다. 간단한 질문은 불용어만 제거해도 같은 목적을 달성할 수 있습니다.

역순위 융합으로 결과 결합

역순위 융합(Reciprocal rank fusion, RRF)은 서로 다른 검색에서 얻은 순위 목록을 결합합니다. 문서가 한 목록의 앞에 있을수록 더 큰 점수를 받고 여러 목록에 등장하면 기여 점수를 합합니다.

문서 d에 대한 점수는 sum(1 / (rrfK + rank(d)))입니다. 문서가 없는 목록은 기여하지 않습니다. rrfK가 커지면 정확한 순위 차이보다 목록에 등장했는지가 더 중요해집니다.

아래 구현은 배열 인덱스에 맞춰 순위를 0부터 세고 rrfK의 기본값은 60입니다. 검색 결과 수를 정하는 k와 이 상수는 서로 다릅니다. 청크의 문자열 기본 키 id로 중복을 합친 뒤 점수 내림차순으로 정렬합니다.

TypeScript v1 구현

Chunk 유형에는 문자열 기본 키 id, 문자열 text와 벡터 embedding이 있다고 가정합니다. Object Storage V2의 객체 검색을 사용하고 functions-typescript/functions.json에서 enableVectorProperties를 true로 설정합니다. 제공 모델 구성에 따라 모든 청크를 text-embedding-ada-002로 임베딩하고 벡터 속성의 차원을 1,536으로 지정합니다. TypeScript v1 KNN의 검색 벡터는 최대 2,048차원이며 인덱싱한 벡터와 차원이 같아야 합니다.

Resource imports에서 Chunk, GPT-4.1 및 text-embedding-ada-002를 가져옵니다. 아래 코드를 functions-typescript/src/index.ts에 둡니다. GPT-4.1은 새 언어 모델 바인딩을, 임베딩은 TextEmbeddingAda_002의 레거시 바인딩을 사용하므로 각 응답 형식을 구분합니다. @Uses에는 호출하는 GPT-4.1 메서드의 쿼리 의존성을 명시합니다. 모델 호출 실패나 필요한 출력 누락은 오류로 처리합니다.

import { Function, Integer, Double, Uses } from "@foundry/functions-api";
import { Objects, Chunk } from "@foundry/ontology-api";
import { Gpt41 } from "@foundry/languagemodelservice/models";
import { TextEmbeddingAda_002 } from "@foundry/models-api/language-models";

export class RetrievalFunctions {
    private async complete(prompt: string): Promise<string> {
        const response = await Gpt41.createChatCompletion({
            messages: [{ role: "USER", content: prompt }],
            params: { temperature: 0 },
        });
        if (response.type !== "ok") {
            throw new Error(`Language model request failed: ${response.type}`);
        }
        const completion = response.value.completion;
        if (completion === undefined || completion.length === 0) {
            throw new Error("The language model returned no text");
        }
        return completion;
    }

    private async embed(text: string): Promise<Double[]> {
        const response = await TextEmbeddingAda_002.createEmbeddings({ inputs: [text] });
        const embedding = response.embeddings[0];
        if (embedding === undefined) {
            throw new Error("The embedding model returned no vector");
        }
        return embedding;
    }

    private async vectorSearch(text: string, k: Integer): Promise<Chunk[]> {
        const embedding = await this.embed(text);
        return Objects.search()
            .chunk()
            .nearestNeighbors(obj => obj.embedding.near(embedding, { kValue: k }))
            .orderByRelevance()
            .takeAsync(k);
    }

    @Function()
    @Uses({ queries: [Gpt41.createChatCompletion] })
    public async searchChunksByEmbedding(query: string, k: Integer): Promise<Chunk[]> {
        const prompt = `You are an insurance specialist assistant tasked to assist
colleagues with finding relevant documents for their queries.
Given the following user query: ${JSON.stringify(query)}
Produce a hypothetical paragraph answering it. Give your response in this format:
{Document Name}: {Chapter name}: {Section} > ...
{Content}
Document Name is the document containing the passage, Chapter name is its chapter,
Section is the section name, and Content is the section's content.`;
        const hypothetical = await this.complete(prompt);
        return this.vectorSearch(hypothetical, k);
    }

    @Function()
    public async searchChunksByKeywords(query: string, k: Integer): Promise<Chunk[]> {
        return Objects.search()
            .chunk()
            .filter(obj => obj.text.matchAnyToken(query))
            .orderByRelevance()
            .takeAsync(k);
    }

    @Function()
    @Uses({ queries: [Gpt41.createChatCompletion] })
    public async searchChunksByAugmentedKeywords(query: string, k: Integer): Promise<Chunk[]> {
        const prompt = `You are an insurance AI assistant helping users find relevant
internal documents with keyword search.
Given the following user query: ${JSON.stringify(query)}
Give search terms that would find relevant results. Remove stop words and irrelevant
filler phrases. Add synonyms and related terms to the most important terms.
Return a list of comma-separated values.`;
        const augmentedQuery = await this.complete(prompt);
        return this.searchChunksByKeywords(augmentedQuery, k);
    }

    @Function()
    @Uses({ queries: [Gpt41.createChatCompletion] })
    public async searchChunksByExtractedQuery(query: string, k: Integer): Promise<Chunk[]> {
        const prompt = `You are preparing a user-given query for semantic search.
Extract the key user actions and remove unnecessary stop words.
Given the following user query: ${JSON.stringify(query)}
Return concatenated actions delimited by full stops.`;
        const extractedQuery = await this.complete(prompt);
        return this.vectorSearch(extractedQuery, k);
    }

    private combineResultsWithRRF(
        vectorSearchResults: Chunk[],
        keywordSearchResults: Chunk[],
        rrfK: Integer = 60,
    ): Chunk[] {
        const scores = new Map<string, { chunk: Chunk; score: number }>();
        for (const results of [vectorSearchResults, keywordSearchResults]) {
            results.forEach((chunk, rank) => {
                const contribution = 1 / (rrfK + rank);
                const existing = scores.get(chunk.id);
                if (existing === undefined) {
                    scores.set(chunk.id, { chunk, score: contribution });
                } else {
                    existing.score += contribution;
                }
            });
        }
        return Array.from(scores.values())
            .sort((a, b) => b.score - a.score)
            .map(entry => entry.chunk);
    }

    @Function()
    @Uses({ queries: [Gpt41.createChatCompletion] })
    public async hybridSearch(
        query: string,
        k: Integer,
        n1: Integer,
        n2: Integer,
    ): Promise<Chunk[]> {
        const [keywordResults, vectorResults] = await Promise.all([
            this.searchChunksByKeywords(query, n1),
            this.searchChunksByEmbedding(query, n2),
        ]);
        return this.combineResultsWithRRF(vectorResults, keywordResults).slice(0, k);
    }
}

hybridSearch는 키워드 후보 n1개와 HyDE 벡터 후보 n2개를 병렬로 조회한 뒤 최종 k개를 선택합니다. TypeScript v1의 KNN에 전달하는 이웃 수는 1부터 100까지입니다. 후보가 부족하면 반환 수는 요청한 수보다 작을 수 있습니다.

TypeScript v2에서는 nearestNeighbors의 결과를 fetchPage의 $orderBy: "relevance"로 정렬할 수 있습니다. 이 정렬은 토큰 필터의 관련도 정렬에는 적용되지 않으므로 위 키워드 검색을 .where()로만 바꿔 같은 결과를 기대하지 않습니다. 검색 품질 비교에는 같은 질문 집합과 실제로 검색된 근거를 사용합니다.