使用 Spring AI Alibaba 構建大模型聯網搜索應用
Spring AI 實現了模塊化 RAG 架構,架構的靈感來自于論文“模塊化 RAG:將 RAG 系統轉變為類似樂高的可重構框架”中詳述的模塊化概念。
Spring AI 模塊化 RAG 體系
總體上分為以下幾個步驟:
Pre-Retrieval
增強和轉換用戶輸入,使其更有效地執行檢索任務,解決格式不正確的查詢、query 語義不清晰、或不受支持的語言等。
- QueryAugmenter 查詢增強:使用附加的上下文數據信息增強用戶 query,提供大模型回答問題時的必要上下文信息;
- QueryTransformer 查詢改寫:因為用戶的輸入通常是片面的,關鍵信息較少,不便于大模型理解和回答問題。因此需要使用 prompt 調優手段或者大模型改寫用戶 query;
- QueryExpander 查詢擴展:將用戶 query 擴展為多個語義不同的變體以獲得不同視角,有助于檢索額外的上下文信息并增加找到相關結果的機會。
Retrieval
負責查詢向量存儲等數據系統并檢索和用戶 query 相關性最高的 Document。
- DocumentRetriever:檢索器,根據 QueryExpander 使用不同的數據源進行檢索,例如 搜索引擎、向量存儲、數據庫或知識圖等;
- DocumentJoiner:將從多個 query 和從多個數據源檢索到的 Document 合并為一個 Document 集合;
Post-Retrieval
負責處理檢索到的 Document 以獲得最佳的輸出結果,解決模型中的中間丟失和上下文長度限制等。
- DocumentRanker:根據 Document 和用戶 query 的相關性對 Document 進行排序和排名;
- DocumentSelector:用于從檢索到的 Document 列表中刪除不相關或冗余文檔;
- DocumentCompressor:用于壓縮每個 Document,減少檢索到的信息中的噪音和冗余。
生成
生成用戶 Query 對應的大模型輸出。
Web Search 實踐
接下來,將演示如何使用 Spring AI Alibaba 和阿里云 IQS 服務搭建聯網搜索 RAG 的實現。
資源準備
DashScope apiKey:https://help.aliyun.com/zh/model-studio/developer-reference/get-api-key
阿里云 IQS 服務 apiKey:https://help.aliyun.com/product/2837261.html
Pre-Retrieval
將用戶 Query 使用 qwen-plus 大模型進行增強改寫。
CustomContextQueryAugmenter.java
public class CustomContextQueryAugmenter implements QueryAugmenter {// 定義 prompt tmpl。private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate(// ......);private static final PromptTemplate DEFAULT_EMPTY_PROMPT_TEMPLATE = new PromptTemplate(// ...);@NotNull@Overridepublic Query augment(@Nullable Query query,@Nullable List<Document> documents) {// 1. collect content from documents.AtomicInteger idCounter = new AtomicInteger(1);String documentContext = documents.stream().map(document -> {String text = document.getText();return "[[" + (idCounter.getAndIncrement()) + "]]" + text;}).collect(Collectors.joining("\n-----------------------------------------------\n"));// 2. Define prompt parameters.Map<String, Object> promptParameters = Map.of("query", query.text(),"context", documentContext);// 3. Augment user prompt with document context.return new Query(this.promptTemplate.render(promptParameters));}// 當上下文為空時,返回 DEFAULT_EMPTY_PROMPT_TEMPLATEprivate Query augmentQueryWhenEmptyContext(Query query) {if (this.allowEmptyContext) {logger.debug("Empty context is allowed. Returning the original query.");return query;}logger.debug("Empty context is not allowed. Returning a specific query for empty context.");return new Query(this.emptyPromptTemplate.render());}public static final class Builder {// ......}
}
QueryTransformer 配置 bean,用于 rewrite 用戶 query:
@Bean
public QueryTransformer queryTransformer(ChatClient.Builder chatClientBuilder,@Qualifier("transformerPromptTemplate") PromptTemplate transformerPromptTemplate
) {ChatClient chatClient = chatClientBuilder.defaultOptions(DashScopeChatOptions.builder().withModel("qwen-plus").build()).build();return RewriteQueryTransformer.builder().chatClientBuilder(chatClient.mutate()).promptTemplate(transformerPromptTemplate).targetSearchSystem("聯網搜索").build();
}
QueryExpander.java 查詢變體
public class MultiQueryExpander implements QueryExpander {private static final Logger logger = LoggerFactory.getLogger(MultiQueryExpander.class);private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate(// ...);@NotNull@Overridepublic List<Query> expand(@Nullable Query query) {// ...String resp = this.chatClient.prompt().user(user -> user.text(this.promptTemplate.getTemplate()).param("number", this.numberOfQueries).param("query", query.text())).call().content();// ...List<String> queryVariants = Arrays.stream(resp.split("\n")).filter(StringUtils::hasText).toList();if (CollectionUtils.isEmpty(queryVariants) || this.numberOfQueries != queryVariants.size()) {return List.of(query);}List<Query> queries = queryVariants.stream().filter(StringUtils::hasText).map(queryText -> query.mutate().text(queryText).build()).collect(Collectors.toList());// 是否引入原查詢if (this.includeOriginal) {logger.debug("Including original query in the expanded queries for query: {}", query.text());queries.add(0, query);}return queries;}public static final class Builder {// ......}}
Retrieval
從不同數據源查詢和用戶 query 相似度最高的數據。(這里使用 Web Search)
WebSearchRetriever.java
public class WebSearchRetriever implements DocumentRetriever {// 注入 IQS 搜索引擎private final IQSSearchEngine searchEngine;@NotNull@Overridepublic List<Document> retrieve(@Nullable Query query) {// 搜索GenericSearchResult searchResp = searchEngine.search(query.text());// 清洗數據,將數據轉換為 Spring AI 的 Document 對象List<Document> cleanerData = dataCleaner.getData(searchResp);logger.debug("cleaner data: {}", cleanerData);// 返回結果List<Document> documents = dataCleaner.limitResults(cleanerData, maxResults);logger.debug("WebSearchRetriever#retrieve() document size: {}, raw documents: {}",documents.size(),documents.stream().map(Document::getId).toArray());return enableRanker ? ranking(query, documents) : documents;}private List<Document> ranking(Query query, List<Document> documents) {if (documents.size() == 1) {// 只有一個時,不需要 rankreturn documents;}try {List<Document> rankedDocuments = documentRanker.rank(query, documents);logger.debug("WebSearchRetriever#ranking() Ranked documents: {}", rankedDocuments.stream().map(Document::getId).toArray());return rankedDocuments;} catch (Exception e) {// 降級返回原始結果logger.error("ranking error", e);return documents;}}public static final class Builder {// ...}
}
DocumentJoiner.java 合并 Document
public class ConcatenationDocumentJoiner implements DocumentJoiner {@NotNull@Overridepublic List<Document> join(@Nullable Map<Query, List<List<Document>>> documentsForQuery) {// ...Map<Query, List<List<Document>>> selectDocuments = selectDocuments(documentsForQuery, 10);Set<String> seen = new HashSet<>();return selectDocuments.values().stream()// Flatten List<List<Documents>> to Stream<List<Documents>..flatMap(List::stream)// Flatten Stream<List<Documents> to Stream<Documents>..flatMap(List::stream).filter(doc -> {List<String> keys = extractKeys(doc);for (String key : keys) {if (!seen.add(key)) {logger.info("Duplicate document metadata: {}",doc.getMetadata());// Duplicate keys found.return false;}}// All keys are unique.return true;}).collect(Collectors.toList());}private Map<Query, List<List<Document>>> selectDocuments(Map<Query, List<List<Document>>> documentsForQuery,int totalDocuments) {Map<Query, List<List<Document>>> selectDocumentsForQuery = new HashMap<>();int numberOfQueries = documentsForQuery.size();if (Objects.equals(0, numberOfQueries)) {return selectDocumentsForQuery;}int baseCount = totalDocuments / numberOfQueries;int remainder = totalDocuments % numberOfQueries;// To ensure consistent distribution. sort the keys (optional)List<Query> sortedQueries = new ArrayList<>(documentsForQuery.keySet());// Other sort// sortedQueries.sort(Comparator.comparing(Query::getSomeProperty));Iterator<Query> iterator = sortedQueries.iterator();for (int i = 0; i < numberOfQueries; i ++) {Query query = sortedQueries.get(i);int documentToSelect = baseCount + (i < remainder ? 1 : 0);List<List<Document>> originalDocuments = documentsForQuery.get(query);List<List<Document>> selectedNestLists = new ArrayList<>();int remainingDocuments = documentToSelect;for (List<Document> documentList : originalDocuments) {if (remainingDocuments <= 0) {break;}List<Document> selectSubList = new ArrayList<>();for (Document docs : documentList) {if (remainingDocuments <= 0) {break;}selectSubList.add(docs);remainingDocuments --;}if (!selectSubList.isEmpty()) {selectedNestLists.add(selectSubList);}}selectDocumentsForQuery.put(query, selectedNestLists);}return selectDocumentsForQuery;}private List<String> extractKeys(Document document) {// 提取 keyreturn keys;}
}
Post-Retrieval
處理從聯網搜索種獲得的 Document,以獲得最佳輸出。
DashScopeDocumentRanker.java
public class DashScopeDocumentRanker implements DocumentRanker {// ...@NotNull@Overridepublic List<Document> rank(@Nullable Query query,@Nullable List<Document> documents) {// ...try {List<Document> reorderDocs = new ArrayList<>();// 由調用者控制文檔數DashScopeRerankOptions rerankOptions = DashScopeRerankOptions.builder().withTopN(documents.size()).build();if (Objects.nonNull(query) && StringUtils.hasText(query.text())) {// 組裝參數調用 rankModelRerankRequest rerankRequest = new RerankRequest(query.text(),documents,rerankOptions);RerankResponse rerankResp = rerankModel.call(rerankRequest);rerankResp.getResults().forEach(res -> {Document outputDocs = res.getOutput();// 查找并添加到新的 list 中Optional<Document> foundDocsOptional = documents.stream().filter(doc ->{// debug rerank output.logger.debug("DashScopeDocumentRanker#rank() doc id: {}, outputDocs id: {}", doc.getId(), outputDocs.getId());return Objects.equals(doc.getId(), outputDocs.getId());}).findFirst();foundDocsOptional.ifPresent(reorderDocs::add);});}return reorderDocs;}catch (Exception e) {// 根據異常類型做進一步處理throw new SAAAppException(e.getMessage());}}
}
大模型輸出
WebSearchService.java
@Service
public class SAAWebSearchService {// ...private static final String DEFAULT_WEB_SEARCH_MODEL = "deepseek-r1";public SAAWebSearchService(ChatClient.Builder chatClientBuilder,QueryTransformer queryTransformer,QueryExpander queryExpander,IQSSearchEngine searchEngine,DataClean dataCleaner,DocumentRanker documentRanker,@Qualifier("queryArgumentPromptTemplate") PromptTemplate queryArgumentPromptTemplate) {this.queryTransformer = queryTransformer;this.queryExpander = queryExpander;this.queryArgumentPromptTemplate = queryArgumentPromptTemplate;// 用于 DeepSeek-r1 的 reasoning content 整合到輸出中this.reasoningContentAdvisor = new ReasoningContentAdvisor(1);// 構建 chatClientthis.chatClient = chatClientBuilder.defaultOptions(DashScopeChatOptions.builder().withModel(DEFAULT_WEB_SEARCH_MODEL)// stream 模式下是否開啟增量輸出.withIncrementalOutput(true).build()).build();// 日志this.simpleLoggerAdvisor = new SimpleLoggerAdvisor(100);this.webSearchRetriever = WebSearchRetriever.builder().searchEngine(searchEngine).dataCleaner(dataCleaner).maxResults(2).enableRanker(true).documentRanker(documentRanker).build();}// 處理用戶輸入public Flux<String> chat(String prompt) {return chatClient.prompt().advisors(createRetrievalAugmentationAdvisor(),// 整合到 reasoning content 輸出中reasoningContentAdvisor,simpleLoggerAdvisor).user(prompt).stream().content();}// 創建 advisorprivate RetrievalAugmentationAdvisor createRetrievalAugmentationAdvisor() {return RetrievalAugmentationAdvisor.builder().documentRetriever(webSearchRetriever).queryTransformers(queryTransformer).queryAugmenter(new CustomContextQueryAugmenter(queryArgumentPromptTemplate,null,true)).queryExpander(queryExpander).documentJoiner(new ConcatenationDocumentJoiner()).build();}}
演示
使用問題 杭州有什么推薦旅游的地方嗎
為例。
普通輸出
### 必游景點
1. **西湖** - **核心特色**:杭州的標志性景點,包含“西湖十景”(如蘇堤春曉、斷橋殘雪、三潭印月等),可泛舟湖上或沿湖騎行。 - **推薦活動**:夜游西湖燈光秀、漫步白堤/蘇堤。 2. **靈隱寺與飛來峰** - **文化價值**:千年古剎靈隱寺被譽為“東南佛國”,飛來峰的摩崖石刻為宋代佛教藝術瑰寶。 ---### 自然與生態
1. **西溪國家濕地公園** - **亮點**:國內首個國家濕地公園,可乘搖櫓船游覽,春秋季觀蘆葦、賞梅花。 2. **九溪十八澗** - **特色**:茶園、溪流、古樹構成的徒步路線,適合夏季避暑。 ---### 文化體驗
1. **宋城景區** - **必看演出**:《宋城千古情》通過歌舞演繹杭州歷史,沉浸式體驗南宋文化。 2. **中國茶葉博物館(龍井館)** - **體驗**:了解龍井茶文化,參與采茶、制茶活動,品鑒正宗西湖龍井。 ---### 美食街區
1. **河坊街** - **推薦小吃**:蔥包檜、定勝糕、西湖醋魚、東坡肉。 2. **武林夜市** - **特色**:本地人常去的夜宵聚集地,匯聚浙江風味與網紅美食。 ---### 溫馨提示
- **最佳季節**:春季(3-5月)賞桃柳,秋季(9-11月)觀桂花。
- **交通建議**:西湖周邊景點集中,建議騎行或步行;地鐵1號線覆蓋主城區。 如需更詳細的行程規劃,可補充具體需求(如親子游、攝影主題等)。
聯網搜索輸出
### 杭州旅游推薦#### 1. 西湖風景區
杭州的核心景點,包含斷橋殘雪、蘇堤春曉等經典景觀,四季景色各異,適合漫步或乘船游覽。清晨和傍晚的光線最佳,湖光山色與人文遺跡交融,是攝影和休閑的首選 [2][3][6]。 #### 2. 靈隱寺
千年古剎隱于山林,古木參天,佛教氛圍濃厚。寺內素齋體驗和祈福活動值得嘗試,適合尋求寧靜的游客 [2][5][6]。 #### 3. 西溪國家濕地公園
城市內的生態綠肺,河道縱橫,可乘船觀賞濕地風光,偶遇白鷺等水鳥。春季踏青、秋季賞蘆的絕佳地 [2][3][6]。 #### 4. 天竺三寺
位于西湖區靈隱寺附近,由三座歷史悠久的寺廟組成,建筑風格獨特,環境清幽,適合文化探訪和秋日游覽 [1]。 #### 5. 杭州宋城
以宋代風貌為主題的景區,可換古裝沉浸式體驗市井生活,大型演出《宋城千古情》融合歷史與藝術,視覺震撼 [2][5]。 #### 6. 河坊街
古色古香的商業街,聚集傳統小吃、手工藝品店,可品嘗蔥包檜、定勝糕等美食,適合拍攝人文題材照片 [2][3][5]。 #### 7. 千島湖風景區
以1078座島嶼聞名,梅峰島觀景臺可俯瞰全景,湖光山色如畫卷。適合自駕游和山水攝影 [3][6]。 #### 8. 茅家埠景區
西湖邊的隱逸之地,春季櫻花與湖柳相映,秋季蘆葦搖曳,人少景美,適合徒步和自然攝影 [1][3]。 #### 9. 九溪煙樹(九溪十八澗)
山澗、茶園與楓葉交織的徒步路線,秋季紅葉似火,溪水潺潺,充滿詩意 [2][6]。 #### 10. 太子灣公園
春季郁金香、櫻花盛開,色彩斑斕,是熱門打卡地。適合家庭游和花卉攝影 [2][3]。 ---**其他推薦**
- **浙西大龍灣**:自然峽谷與瀑布群,夏季漂流項目刺激 [1]。
- **中國印學博物館**:展示印章文化與歷史,適合文化愛好者 [1]。
- **塘棲古鎮**:運河畔的江南水鄉,保留明清建筑與民俗風情 [3][6]。 **旅行提示**
- 西湖、靈隱寺等熱門景點建議提前預約門票 [5]。
- 春季多雨,需攜帶雨具;秋季適合戶外活動 [2][5]。 (參考文檔:[1][2][3][5][6])
參考文檔
- Spring AI RAG:https://docs.spring.io/spring-ai/reference/api/retrieval-augmented-generation.html
- 阿里云 IQS:https://help.aliyun.com/product/2837261.html