하나의 인터페이스, 세 가지 검색 백엔드: 웹 검색은 또 다른 리포지토리일 뿐
요약
본 글은 LLM 애플리케이션 개발 시 웹 검색 기능을 통합하는 아키텍처 패턴을 제시합니다. Solon AI의 `solon-ai-rag-searchs` 패밀리는 모든 검색 백엔드를 단일 `Repository` 인터페이스로 통일하여, 사용자가 어떤 검색 엔진을 쓰는지 신경 쓸 필요가 없게 만듭니다. 이 인터페이스는 웹 검색(Bocha), Baidu AI Search 등 다양한 구현체를 하나의 표준화된 방식으로 호출할 수 있게 합니다.
핵심 포인트
- 웹 검색 기능을 단일 `Repository` 인터페이스로 추상화하여 개발 복잡성을 줄입니다.
- 구현체별 API 차이를 숨기고, 모든 백엔드를 동일한 `search(query)` 메서드로 통일합니다.
- Bocha와 Baidu 등 다양한 서비스의 특성을 표준화된 방식으로 통합하는 방법을 보여줍니다.
LLM을 사용하여 무언가를 구축한다면, 언젠가 웹 검색이 필요할 것입니다. 모델의 학습 데이터는 고정되어 있지만, 사용자의 질문은 그렇지 않습니다.
Solon AI에서 웹 검색은 측면에 부착된 특별한 서브시스템이 아닙니다. 그것은 Repository입니다. 벡터 스토어가 구현하는 인터페이스와 동일하며, 인메모리 문서 목록이 구현하는 인터페이스와도 같습니다. 이 단일 설계 결정이 바로 solon-ai-rag-searchs 패밀리의 핵심이며, 예상치 못한 방식으로 조합됩니다.
가장 중요한 하나의 메서드
public interface Repository {
default List<Document> search(String query) throws IOException {
return search(new QueryCondition(query));
...
}
필수 메서드가 하나 있습니다. 그 위에 기본값(default)이 두 개 추가되었습니다. 이 인터페이스는 3.1에 포함된 이후로 @Preview로 표시되어 왔는데, 이는 '표면이 작아서 신중하게 성장할 수 있다'는 솔직한 신호로 읽힙니다.
호출자가 표현할 수 있는 모든 것은 QueryCondition을 통해 전달됩니다:
| 필드 | 기본값 | 참고 사항 |
|---|---|---|
limit | 4 | 최대 문서 개수 |
| ... | ||
참고할 점은 여기에 없는 것들입니다: provider, apiKey, 또는 공급자(vendor) 필드가 없습니다. 그것들은 구현체의 빌더에 존재합니다. 이 인터페이스는 어떤 검색 엔진이 질문에 답변했는지 결코 알지 못합니다. |
세 가지 구현체, 세 가지 철학
Bocha: 레퍼런스 구현체
BochaWebSearchRepository repo = BochaWebSearchRepository
.of("https://api.bochaai.com/v1/web-search")
.apiKey("sk-...")
...
Bocha 어댑터(@since 3.1)는 의도적으로 단순합니다: query, count, 그리고 선택적인 freshness로 JSON 본문을 작성하고, POST하며, data.webPages.value[]를 Document로 매핑합니다. 응답 코드가 200이 아니면 IOException을 발생시키는데, 빈 목록으로 위장하거나 조용한 폴백(fallback)은 없습니다.
소스 코드에는 왜 이것이 단순하게 유지되는지를 알려주는 주석이 있습니다: "此示例,可作为对接其它搜索的参考" —
Baidu AI Search: 두 가지 모드, 하나의 플래그
BaiduWebSearchRepository repo = BaiduWebSearchRepository
.ofAI() // 또는 ofBasic()
.apiKey("bce-...")
...
Baidu 어댑터(@since 3.0, Baidu의 AI Search V2 엔드포인트를 기반으로 구축됨)는 두 가지 성격을 가지고 있습니다:
- BASIC: 클래식 검색 결과 목록입니다.
model필드를 전송하지 않으며, 서버가 모드를 추론합니다. - AI:
model을 전송하고(기본값은ernie-3.5-8k; 빌더 javadoc에는deepseek-r1,deepseek-v3, 그리고 ERNIE 4.0 turbo 변형도 나열되어 있음) LLM이 합성한 답변과 참고 자료를 받습니다.
제가 가장 마음에 드는 세부 사항은 다음과 같습니다: AI 모드에서는 합성된 답변이 동일 목록의 첫 번째 Document로 반환된다는 점입니다. 이 문서에는 Solon AI智能回答라는 제목과 metadata("type", "ai_answer")가 포함됩니다. 참고 자료는 그 뒤를 이어 metadata("source", "baidu_search")와 함께 제공됩니다. 호출자는 AI 답변을 소비하기 위해 두 번째 유형이나 특별한 분기가 필요하지 않습니다. 그것은 단순히 문서 제로(document zero)일 뿐입니다.
사용자의 limit은 top_k를 가진 web 타입의 resource_type_filter로 변환됩니다. 비어 있거나 공백인 쿼리는 네트워크 호출 없이 빈 목록으로 단축 처리되며, 응답의 오류 코드는 IOException이 됩니다. 아무것도 파싱되지 않을 경우에도 예외가 발생합니다. 즉, "no content found"는 흡수되지 않고 노출됩니다.
Tavily: 검색, 추출, 크롤링, 매핑
Tavily는 세 가지 중 가장 기능이 풍부한(maximalist) 서비스입니다. solon-ai-search-tavily 모듈(@since 3.9.5)은 TavilySimpleSearchRepository를 통해 네 가지 작업을 노출합니다:
TavilySimpleSearchRepository repo = TavilySimpleSearchRepository
.of("tvly-...")
.build();
...
검색 외에도 extract(condition) (특정 URL에서 깨끗한 콘텐츠를 가져옴), crawl(condition) (maxDepth 1–5, 최대 maxBreadth 500, 경로 정규식 필터를 사용하여 사이트를 탐색함), 그리고 map(condition) (구조화 전용: 단순히 URL 목록만 제공하며, 저렴하고 나중에 추출에 사용되도록 설계됨)이 있습니다.
표준 QueryCondition.freshness는 충실하게 번역되어 → ONE_DAY는 "day", ONE_YEAR는 "year"로 처리되므로, 벤더 중립적인 경로를 통해 여전히 시간 필터링이 가능합니다.
실제 모듈에는 두 개의 리포지토리 클래스가 포함되어 있으며, 그 이름은 방심하는 사람에게 함정입니다:
TavilyWebSearchRepository— 이름만 거창할 뿐, 이것은 간소화된 버전입니다:Repository를 구현하고search를 위임(delegate)합니다.TavilySimpleSearchRepository— 이름이 소박하지만, 이것은 완전한 버전입니다: 전체 매개변수로 검색하고, 추출하며, 크롤링하고, 매핑합니다. 이 또한Repository를 구현합니다.
간소화된 버전은 getFullRepository()를 노출하여 나중에 재구축할 필요 없이 좁게 시작해서 넓힐 수 있게 합니다. (만약 TavilySearchRepository라는 클래스 이름을 언급하는 오래된 문서를 발견한다면, 그 클래스 이름은 소스 트리에는 존재하지 않으니 TavilySimpleSearchRepository를 사용하세요.)
그리고 리포지토리 추상화가 전혀 필요 없다면, 기본이 되는 TavilyClient가 공개되어 있습니다: ClientBuilder.of(apiKey).apiBase(...).timeout(...).build()는 원시적인 네 가지 작업을 제공합니다.
선택적 임베딩 모델 — 그리고 왜 보통 사용하지 말아야 하는지
세 개의 어댑터 모두 선택적 EmbeddingModel을 받습니다. 이것이 존재할 경우, HTTP 호출 이후의 흐름은 어디서나 동일합니다:
embeddingModel.embed(docs);
float[] queryEmbed = embeddingModel.embed(condition.getQuery());
return SimilarityUtil.refilter(docs.stream()
...
모든 결과를 임베딩하고, 쿼리를 임베딩한 다음, 점수를 매기고, refilter를 수행합니다 — 이는 재순위화(re-ranks)하고 사용자의 similarityThreshold와 limit을 적용하는 역할을 합니다.
모듈 README는 직관에 반하는 점을 명확히 합니다: **웹 검색(web search)**의 경우, 유사도 재순위화(similarity re-ranking)는 보통 의미가 없습니다. 스니펫은 이미 엔진 자체의 관련성 메커니즘에 의해 순위가 매겨져 돌아오며, 짧은 쿼리와 웹 스니펫 간의 코사인 유사도는 기껏해야 노이즈 신호입니다. 따라서 지침은 다음과 같습니다: 명확한 이유가 없다면 임베딩 모델을 웹 검색 리포지토리에 전달하지 마십시오. 이 매개변수는 실제로 그렇게 해야 하는 경우를 위해 존재합니다.
이는 일반적인 원칙의 좋은 예시입니다: 기본적으로 비활성화되어 있고 '아마도 건너뛰세요(probably skip this)'라고 문서화된 기능이 켜고 끌 수 없는 마법 같은 파이프라인보다 낫습니다.
수동 검색에서 검색하는 에이전트로의 전환
promptAugment는 수동 RAG입니다: 사용자가 검색하고, 결과를 사용자 메시지에 붙여 넣은 다음, 전송합니다. FAQ에는 적합하지만, 모델이 언제 검색해야 할지, 또는 무엇을 검색해야 할지를 결정해야 하는 상황에서는 쓸모가 없습니다.
3.10.1 버전부터 RepositoryTool이 있습니다:
RepositoryTool tool = new RepositoryTool(webSearchRepo);
// 선택적으로: new RepositoryTool(repo, rerankingModel)
이는 프레임워크의 도구 제공자 기반을 확장하고 두 개의 매개변수(queries: 쿼리 문자열 목록이며, 호출당 최대 5개로 제한됨—출처 주석이 그 이유를 설명합니다: 모델이 한 번에 열 페이지를 가져가서 자체 컨텍스트를 폭발시키는 것을 막기 위함)와 topK (기본값 3)를 가진 repository_query 도구로 등록합니다.
각 쿼리에 대해 검색하고, 선택적으로 재순위화하며, 제목, 관련성 점수, 내용, 인용 URL이 포함된 마크다운 형식으로 결과를 포맷합니다. 자체 도구에 복사할 가치가 있는 한 가지 세부 사항: 모든 문서의 내용은 도구 응답에 들어가기 전에 2000자로 잘리며, 명시적인 ...(내용이 길어 잘림) 표시가 붙습니다. 컨텍스트 예산은 모델의 자제심에 의존하는 것이 아니라 도구 계층에서 방어됩니다.
모든 Repository를 사용하기 때문에 동일한 도구 클래스가 벡터 스토어, 인메모리 리포지토리, 또는 Bocha/Baidu/Tavily 웹 리포지토리와 함께 작동합니다. 하나의 도구로, 구성된 지식 백엔드에 관계없이 작동합니다.
사용하지 말아야 할 때
- 중립 경로(neutral path)에서는 도메인 필터링이 필요합니다 —
includeDomains/excludeDomains는 Tavily의SearchCondition에만 존재하며, 공유된QueryCondition에는 그러한 필드가 없습니다. 다른 경우들은 쿼리나 다운스트림에서 필터링을 수행하세요. - 스트리밍 검색 결과를 기대하고 있습니다. 인터페이스는 완성된
List<Document>를 반환하지만, Baidu의 AI 모드 합성(synthesis)은 시간이 걸리는 만큼 오래 걸립니다. - 하이브리드 검색 의미론(hybrid search semantics)이 필요합니다 —
QueryCondition의searchType(HYBRID)는 이를 이해하는 리포지토리(3.3 이상)를 위한 것입니다. 웹 어댑터는 자신에게 적용되지 않는 것은 무시합니다.
핵심 요약 (The takeaway)
solon-ai-rag-searchs는 오직 한 가지 일, 즉 웹 검색을 Repository 뒤에 있는 모든 다른 지식 소스와 상호 교환 가능하게 만드는 데 특화된 작은 패밀리입니다. 그 결과
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기