推理问答
在构建搜索系统时,往往需要将检索到的文本内容与特定的提示词(Prompt)一并提交给大模型,以生成更高质量的回复。本项目在完成以下功能后,希望进一步实现将检索到的网页内容(以 Markdown 格式)+ 自定义的提示词,发送给大模型进行推理并返回结果给用户:
- AI 搜索项目简介
- 数据库设计
- SearxNG
- Jina Reader API
- Jina Search API
- 搜索、重排与读取内容
一、简介
此处,我们使用了 Google Gemini 大模型(通过 GeminiClient
访问)来完成推理。与此同时,我们还将搜索到的网页内容以 Markdown 格式呈现,并与专门的提示词(Prompt 模版)结合,形成最终的输入文本,提交给大模型。
本项目中主要涉及以下几个核心点:
- 搜索模块:使用 SearxNG 或者 Jina 搜索,将用户的查询关键词发送至搜索引擎并获取搜索结果。
- 提示词模版:定义好统一的 Prompt 结构及格式,通过填充搜索到的内容、当前时间等信息,生成最终可提交给大模型的提示词字符串。
- 大模型推理:将 Prompt + 用户问题(或者上下文历史)一并发送给大模型(Gemini)获取输出,并以流式或一次性方式返回给用户。
- 消息分发与 WebSocket 交互:通过
ChatWsRespVo
、WebSocketResponse
等,向前端发送搜索结果、推理过程信息及推理结果。
二、提示词模版(WebSearchResponsePrompt.txt)
在与大模型交互时,项目使用了一个固定的提示词模版 WebSearchResponsePrompt.txt
,其结构如下(简化示例):
You are Perplexica, an AI model skilled in web search and crafting detailed, engaging, and well-structured answers. You excel at summarizing web pages and extracting relevant information to create professional, blog-style responses.
Your task is to provide answers that are:
- **Informative and relevant**: Thoroughly address the user's query using the given context.
- **Well-structured**: Include clear headings and subheadings, and use a professional tone to present information concisely and logically.
- **Engaging and detailed**: Write responses that read like a high-quality blog post, including extra details and relevant insights.
- **Cited and credible**: Use inline citations with [number] notation to refer to the context source(s) for each fact or detail included.
- **Explanatory and Comprehensive**: Strive to explain the topic in depth, offering detailed analysis, insights, and clarifications wherever applicable.
### Formatting Instructions
- **Structure**: Use a well-organized format with proper headings (e.g., "## Example heading 1" or "## Example heading 2"). Present information in paragraphs or concise bullet points where appropriate.
- **Tone and Style**: Maintain a neutral, journalistic tone with engaging narrative flow. Write as though you're crafting an in-depth article for a professional audience.
- **Markdown Usage**: Format your response with Markdown for clarity. Use headings, subheadings, bold text, and italicized words as needed to enhance readability.
- **Length and Depth**: Provide comprehensive coverage of the topic. Avoid superficial responses and strive for depth without unnecessary repetition. Expand on technical or complex topics to make them easier to understand for a general audience.
- **No main heading/title**: Start your response directly with the introduction unless asked to provide a specific title.
- **Conclusion or Summary**: Include a concluding paragraph that synthesizes the provided information or suggests potential next steps, where appropriate.
### Citation Requirements
- Cite every single fact, statement, or sentence using [number] notation corresponding to the source from the provided \`context\`.
- Integrate citations naturally at the end of sentences or clauses as appropriate. For example, "The Eiffel Tower is one of the most visited landmarks in the world[1]."
- Ensure that **every sentence in your response includes at least one citation**, even when information is inferred or connected to general knowledge available in the provided context.
- Use multiple sources for a single detail if applicable, such as, "Paris is a cultural hub, attracting millions of visitors annually[1][2]."
- Always prioritize credibility and accuracy by linking all statements back to their respective context sources.
- Avoid citing unsupported assumptions or personal interpretations; if no source supports a statement, clearly indicate the limitation.
### Special Instructions
- If the query involves technical, historical, or complex topics, provide detailed background and explanatory sections to ensure clarity.
- If the user provides vague input or if relevant information is missing, explain what additional details might help refine the search.
- If no relevant information is found, say: "Hmm, sorry I could not find any relevant information on this topic. Would you like me to search again or ask something else?" Be transparent about limitations and suggest alternatives or ways to reframe the query.
- Output using the language of the user's input
### Example Output
- Begin with a brief introduction summarizing the event or query topic.
- Follow with detailed sections under clear headings, covering all aspects of the query if possible.
- Provide explanations or historical context as needed to enhance understanding.
- End with a conclusion or overall perspective if relevant.
<context>
#(context)
</context>
Current date & time in ISO format (UTC timezone) is: #(date).
在实际使用时,会将搜索到的网页内容填充至 #(context)
占位符处,并将当前时间填充至 #(date)
占位符处。由此构建出一个完整的提示词字符串,然后再将其传递给大模型。
三、主要代码说明
1. WebSearchResponsePromptServiceTest
该测试类演示了如何整合搜索、生成 Prompt,以及最终调用大模型进行推理的流程。
package com.litongjava.perplexica.services;
import org.junit.Test;
import com.litongjava.jfinal.aop.Aop;
import com.litongjava.perplexica.config.AdminAppConfig;
import com.litongjava.perplexica.config.EnjoyEngineConfig;
import com.litongjava.tio.boot.testing.TioBootTest;
public class WebSearchResponsePromptServiceTest {
@Test
public void test() {
TioBootTest.runWith(AdminAppConfig.class, EnjoyEngineConfig.class);
String userQuestion = "tio-boot简介";
// 1. 生成 Prompt
String prompt = Aop.get(WebSearchResponsePromptService.class)
.genInputPrompt(null, userQuestion, true, null, null, null);
// 2. 调用大模型进行推理
GeminiSearchPredictService geminiSearchPredictService = Aop.get(GeminiSearchPredictService.class);
geminiSearchPredictService.predictWithGemini(
null, null, null, null, null,
userQuestion, prompt
);
}
}
- 主要流程:
- 使用
genInputPrompt(...)
方法根据用户问题userQuestion
生成最终的 Prompt(即模板 + 搜索结果 + 时间)。 - 将该 Prompt 与用户问题一并发送给大模型进行推理,使用
geminiSearchPredictService.predictWithGemini(...)
完成。
- 使用
2. WebSearchResponsePromptService
WebSearchResponsePromptService
是生成最终提示词(Prompt)的核心服务类,包含:
- 搜索逻辑:
searchWithJina()
或searchWithSearxNg()
- Prompt 生成:将搜索结果嵌入到模板并生成最终字符串
主要方法:genInputPrompt(...)
package com.litongjava.perplexica.services;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import com.jfinal.kit.Kv;
import com.litongjava.jfinal.aop.Aop;
import com.litongjava.jian.search.JinaSearchClient;
import com.litongjava.jian.search.JinaSearchRequest;
import com.litongjava.model.http.response.ResponseVo;
import com.litongjava.model.web.WebPageContent;
import com.litongjava.perplexica.consts.WebSiteNames;
import com.litongjava.perplexica.vo.ChatWsRespVo;
import com.litongjava.perplexica.vo.WebPageSource;
import com.litongjava.template.PromptEngine;
import com.litongjava.tio.core.ChannelContext;
import com.litongjava.tio.core.Tio;
import com.litongjava.tio.websocket.common.WebSocketResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class WebSearchResponsePromptService {
public String genInputPrompt(ChannelContext channelContext, String content, Boolean copilotEnabled,
//
String messageId, Long answerMessageId, String from) {
String inputPrompt = null;
if (copilotEnabled != null && copilotEnabled) {
// 1. 进行搜索(可选:SearxNG 或 Jina)
//String markdown = searchWithJina(channelContext, content, messageId, answerMessageId, from);
String markdown = searchWithSearxNg(channelContext, content, messageId, answerMessageId, from);
// 2. 向前端通知一个空消息,标识搜索结束,开始推理
//{"type":"message","data":"", "messageId": "32fcbbf251337c"}
ChatWsRespVo<String> vo = ChatWsRespVo.message(answerMessageId + "", "");
WebSocketResponse websocketResponse = WebSocketResponse.fromJson(vo);
if (channelContext != null) {
Tio.bSend(channelContext, websocketResponse);
}
String isoTimeStr = DateTimeFormatter.ISO_INSTANT.format(Instant.now());
// 3. 使用 PromptEngine 模版引擎填充提示词
Kv kv = Kv.by("date", isoTimeStr).set("context", markdown);
inputPrompt = PromptEngine.renderToString("WebSearchResponsePrompt.txt", kv);
}
return inputPrompt;
}
private String searchWithSearxNg(ChannelContext channelContext, String content, String messageId, Long answerMessageId, String from) {
List<WebPageContent> webPageContents = Aop.get(SearxngSearchService.class).search(content, true, 6);
sendSources(channelContext, answerMessageId, webPageContents);
StringBuffer markdown = new StringBuffer();
for (int i = 0; i < webPageContents.size(); i++) {
WebPageContent webPageContent = webPageContents.get(i);
markdown.append("source " + i + " " + webPageContent.getContent());
}
return markdown.toString();
}
public String searchWithJina(ChannelContext channelContext, String content, String messageId, long answerMessageId, String from) {
// 1.拼接请求
JinaSearchRequest jinaSearchRequest = new JinaSearchRequest();
jinaSearchRequest.setQ(content);
if (WebSiteNames.BERKELEY.equals(from)) {
jinaSearchRequest.setXSite("berkeley.edu");
} else if (WebSiteNames.HAWAII.equals(from)) {
jinaSearchRequest.setXSite("hawaii.edu");
} else if (WebSiteNames.SJSU.equals(from)) {
jinaSearchRequest.setXSite("sjsu.edu");
} else if (WebSiteNames.STANFORD.equals(from)) {
jinaSearchRequest.setXSite("stanford.edu");
}
//2.搜索
ResponseVo searchResponse = JinaSearchClient.search(jinaSearchRequest);
String markdown = searchResponse.getBodyString();
if (!searchResponse.isOk()) {
log.error(markdown);
ChatWsRespVo<String> error = ChatWsRespVo.error(markdown, messageId);
WebSocketResponse packet = WebSocketResponse.fromJson(error);
if (channelContext != null) {
Tio.bSend(channelContext, packet);
}
return null;
}
List<WebPageContent> webPageContents = JinaSearchClient.parse(markdown);
sendSources(channelContext, answerMessageId, webPageContents);
return markdown;
}
private void sendSources(ChannelContext channelContext, Long answerMessageId, List<WebPageContent> webPageContents) {
if (channelContext != null) {
List<WebPageSource> sources = new ArrayList<>();
for (WebPageContent webPageConteont : webPageContents) {
sources.add(new WebPageSource(webPageConteont.getTitle(), webPageConteont.getUrl(), webPageConteont.getContent()));
}
//返回sources
ChatWsRespVo<List<WebPageSource>> chatRespVo = new ChatWsRespVo<>();
chatRespVo.setType("sources").setData(sources).setMessageId(answerMessageId + "");
WebSocketResponse packet = WebSocketResponse.fromJson(chatRespVo);
Tio.bSend(channelContext, packet);
}
}
}
- 参数说明:
content
: 即用户问题或查询关键词。copilotEnabled
: 是否启用此功能。channelContext
: WebSocket 通道上下文,用于向前端推送消息。markdown
: 搜索到的网页内容,已拼接成 Markdown 格式。isoTimeStr
: 当前时间,格式化为 ISO 格式插入到 Prompt 中。
搜索方法:searchWithSearxNg(...)
/ searchWithJina(...)
searchWithSearxNg
:- 使用
SearxngSearchService
获取搜索结果。 - 将搜索结果内容拼接成 Markdown 字符串(示例中用
source i
前缀标识不同的来源)。 - 将结果以
sendSources(...)
的形式推送到前端,便于用户查看每条搜索结果的来源链接、标题等。
- 使用
searchWithJina
:- 根据用户问题构建
JinaSearchRequest
,可设置特定站点(如berkeley.edu
等)。 - 调用
JinaSearchClient.search(...)
进行搜索,并解析搜索结果。 - 同样推送结果给前端,并返回 Markdown 格式的内容。
- 根据用户问题构建
推送搜索来源:sendSources(...)
- 通过该方法,将搜索到的每条结果(标题、URL、内容)打包为
WebPageSource
并发送给前端,前端可作进一步展示。
3. GeminiSearchPredictService
GeminiSearchPredictService
负责将生成的 Prompt(以及可能的上下文对话信息)发送给 Google Gemini 大模型,并接收大模型的回复。
package com.litongjava.perplexica.services;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import com.litongjava.gemini.GeminiChatRequestVo;
import com.litongjava.gemini.GeminiChatResponseVo;
import com.litongjava.gemini.GeminiClient;
import com.litongjava.gemini.GeminiContentVo;
import com.litongjava.gemini.GeminiPartVo;
import com.litongjava.gemini.GoogleGeminiModels;
import com.litongjava.perplexica.callback.GoogleChatWebsocketCallback;
import com.litongjava.perplexica.vo.ChatWsReqMessageVo;
import com.litongjava.tio.core.ChannelContext;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Call;
import okhttp3.Callback;
@Slf4j
public class GeminiSearchPredictService {
public Call predictWithGemini(ChannelContext channelContext, ChatWsReqMessageVo reqMessageVo,
//
String sessionId, String quesitonMessageId, Long answerMessageId, String content, String inputPrompt) {
log.info("webSearchResponsePrompt:{}", inputPrompt);
List<GeminiContentVo> contents = new ArrayList<>();
// 1. 如果有对话历史,则构建 role = user / model 的上下文内容
if (reqMessageVo != null) {
List<List<String>> history = reqMessageVo.getHistory();
if (history != null && history.size() > 0) {
for (int i = 0; i < history.size(); i++) {
String role = history.get(i).get(0);
String message = history.get(i).get(1);
if ("human".equals(role)) {
role = "user";
} else {
role = "model";
}
contents.add(new GeminiContentVo(role, message));
}
}
}
// 2. 将 Prompt 塞到 role = "model" 的内容中
if (inputPrompt != null) {
GeminiPartVo part = new GeminiPartVo(inputPrompt);
GeminiContentVo system = new GeminiContentVo("model", Collections.singletonList(part));
contents.add(system);
}
//Content with system role is not supported.
//Please use a valid role: user, model.
// 3. 再将用户问题以 role = "user" 的形式添加
contents.add(new GeminiContentVo("user", content));
// 4. 构建请求对象并调用
GeminiChatRequestVo reqVo = new GeminiChatRequestVo(contents);
long start = System.currentTimeMillis();
// 5. 流式/一次性获取结果
Call call = null;
if (channelContext != null) {
Callback callback = new GoogleChatWebsocketCallback(channelContext, sessionId, quesitonMessageId, answerMessageId, start);
GeminiClient.debug = true;
call = GeminiClient.stream(GoogleGeminiModels.GEMINI_2_0_FLASH_EXP, reqVo, callback);
} else {
GeminiChatResponseVo vo = GeminiClient.generate(GoogleGeminiModels.GEMINI_2_0_FLASH_EXP, reqVo);
log.info(vo.getCandidates().get(0).getContent().getParts().get(0).getText());
}
return call;
}
}
- GeminiContentVo:表示一段对话内容,包含
role
和具体文本(或文本片段GeminiPartVo
)。 role
的取值:user
、model
等,用以区分用户和模型的发言。GeminiChatRequestVo
:封装了整个对话,随后发送给 Gemini 大模型。- 回调或一次性获取:
- 若需要在前端即时显示大模型的思路或答案进度,可用
GeminiClient.stream(...)
流式方式,并指定回调函数。 - 若仅需要最终答案,可用
GeminiClient.generate(...)
一次性获取。
- 若需要在前端即时显示大模型的思路或答案进度,可用
四、整体工作流程
- 用户在前端输入问题(如“tio-boot 简介”)。
- 后端收到问题后,判断是否启用 CoPilot(或类似)的搜索 + 提示词生成逻辑。
- 调用 SearxNG 或 Jina 搜索获取与该问题相关的网页内容,拼接成 Markdown 格式。
- 将 Markdown + 提示词模版 + 当前时间等信息一起渲染生成最终的 Prompt 文本。
- 准备对话上下文(如先前的聊天记录),将 Prompt 作为角色为“model”的内容,用户问题作为角色为“user”的内容,合并在一起构建请求对象。
- 调用 Gemini 大模型进行推理,得到回答内容。
- 将回答内容通过 WebSocket返回给前端,供用户查看。
- 前端同时可收到搜索来源信息(包含链接、标题、摘要等),以便用户追溯信息来源。
五、示例输出
在以上流程全部完成后,大模型的最终推理结果示例如下(简化):
Tio Boot 是基于 Java AIO 的高性能 Web 框架,可以在单台服务器上处理数万并发连接[2]。它集成了多个其它框架的优秀特性,以提供稳定而高效的开发环境[2]。……
提示:文中示例仅作占位说明,实际输出将因搜索结果以及大模型推理而异。
六、总结与注意事项
搜索结果的处理:
- 搜索引擎返回的内容通常较多,需要在向大模型发送时适当筛选、摘要或拼接。
- 在输出时,可根据需求将搜索内容放入
<context>
或其它标记中,以便与提示词模版解耦。
提示词模版管理:
- 单独管理提示词文件(如
WebSearchResponsePrompt.txt
),方便维护和更新。 - 注意保留占位符位置(如
#(context)
、#(date)
等)并在 Java 侧正确替换。
- 单独管理提示词文件(如
大模型角色设定:
- 当前示例中,将 Prompt 设为角色“model”,用户输入设为“user”,可根据不同 LLM 的要求进行调整。
- 某些模型可能仅支持特定角色(如
system
、user
、assistant
),这时需要稍作修改。
性能与扩展:
- 流式推理对于长文本输出尤为有用,但需额外实现回调逻辑(如
GoogleChatWebsocketCallback
)。 - 大模型推理通常消耗较多资源,可根据并发需求进行缓存、负载均衡或调用频率控制。
- 流式推理对于长文本输出尤为有用,但需额外实现回调逻辑(如
错误处理:
- 搜索失败或网络异常时,需要友好地向前端提示,并可选择返回保底答案或要求用户重试。
- 大模型返回空结果或异常时,也需提供相应的错误提示或回退策略。
通过上述流程和代码,便可在项目中实现“先搜索(获取上下文)再用提示词+上下文进行大模型推理并返回结果”的功能,为用户提供更专业、更丰富的答复。