第1884篇:把一个遗留Java项目接入AI的真实过程——比想象中复杂
第1884篇:把一个遗留Java项目接入AI的真实过程——比想象中复杂
接到这个任务的时候,产品给我的描述是:"把AI接进去,让它能回答用户关于我们系统的问题"。
听起来很简单,不就是个RAG嘛。
然后我看了一眼那个项目的代码,沉默了大概三秒钟。
遗留项目的真实面貌
这是一个大概有十年历史的Java EE项目,用的是Spring 4.x,部署在JBoss上,数据库是Oracle 11g,构建工具是Maven但pom文件有将近600行。整个项目大概50万行代码,没有任何测试。
当然,这不是重点。重点是这个项目的代码里有大量的"历史沉淀":
- 配置文件混合了XML、properties和部分硬编码
- 业务逻辑和数据库访问逻辑严重耦合,很多地方直接写SQL
- 没有任何API层,所有对外接口都是JSP表单提交
- 各种自研的工具类,功能跟Apache Commons的高度重合但又不完全一样
这样的项目,要接入AI能力,有几种思路:
思路A:直接在现有项目里改。 最保守,风险最低,但受限于Spring 4.x和JDK 8,很多现代AI SDK用不了。
思路B:单独起一个AI服务,通过HTTP接口通信。 解耦,但要解决认证、数据共享的问题。
思路C:升级现有项目到Spring Boot后再接。 一步做两件事,风险最高。
我们选了思路B,这也是我推荐的做法。
第一关:数据准备——把非结构化的知识变成可检索的内容
用户要"问关于系统的问题",首先得有知识库。
这个遗留项目的"知识"分布在几个地方:
- Word格式的操作手册,大概200份,总计几千页
- 业务规则配置表(在Oracle数据库里)
- 代码里的注释和部分JavaDoc(质量参差不齐)
- 已归档的工单系统里的历史问题解答
每种来源的处理方式都不一样,这是大多数RAG教程不会告诉你的部分。
处理Word文档
Word文档最麻烦的地方不是读取,是清洗。这些文档是十年间不同的人写的,格式极其不统一:有人用表格描述流程,有人用截图代替文字,有人在一个段落里混合了三种字体。
@Service
public class WordDocumentProcessor {
/**
* 提取Word文档的纯文本,处理各种格式异常
*/
public String extractText(File docFile) throws IOException {
try (XWPFDocument doc = new XWPFDocument(new FileInputStream(docFile))) {
StringBuilder sb = new StringBuilder();
for (IBodyElement element : doc.getBodyElements()) {
if (element instanceof XWPFParagraph) {
XWPFParagraph para = (XWPFParagraph) element;
String text = para.getText().trim();
// 跳过明显的格式行(全是符号或者很短的标题)
if (text.isEmpty() || isFormatLine(text)) continue;
sb.append(text).append("\n");
} else if (element instanceof XWPFTable) {
// 表格转换为文字描述
sb.append(tableToText((XWPFTable) element));
}
}
return cleanText(sb.toString());
}
}
private String tableToText(XWPFTable table) {
StringBuilder sb = new StringBuilder();
// 如果第一行是表头,生成"字段名:值"格式的文字
List<XWPFTableRow> rows = table.getRows();
if (rows.isEmpty()) return "";
List<String> headers = rows.get(0).getTableCells().stream()
.map(cell -> cell.getText().trim())
.collect(Collectors.toList());
for (int i = 1; i < rows.size(); i++) {
List<XWPFTableCell> cells = rows.get(i).getTableCells();
for (int j = 0; j < Math.min(headers.size(), cells.size()); j++) {
String header = headers.get(j);
String value = cells.get(j).getText().trim();
if (!header.isEmpty() && !value.isEmpty()) {
sb.append(header).append(":").append(value).append(";");
}
}
sb.append("\n");
}
return sb.toString();
}
private String cleanText(String text) {
// 去掉多余空行
text = text.replaceAll("\n{3,}", "\n\n");
// 去掉只有数字或字母的短行(页码、章节号等)
text = text.replaceAll("(?m)^[\\d\\s.]+$\n", "");
return text.trim();
}
private boolean isFormatLine(String text) {
// 全是分隔符或者长度小于3的行
return text.matches("[\\-=_*#\\s]+") || text.length() < 3;
}
}分块策略:这里有很多坑
文档处理完之后,要切分成chunk。我踩过的最大的坑是:按固定长度切分往往会破坏语义完整性。
比如有一段文字是"当用户选择'批量审批'操作时,系统会弹出确认窗口,点击确认后...",如果正好在"确认窗口"这里被截断,前半段的chunk就失去了后半段的关键信息。
我们最终用的是基于语义的分块策略:
@Service
public class SemanticChunker {
private static final int MAX_CHUNK_SIZE = 800; // 字符数
private static final int MIN_CHUNK_SIZE = 200;
private static final int OVERLAP_SIZE = 100; // 相邻chunk的重叠字符数
/**
* 按语义边界切分文本
* 优先在段落边界、句号处切分,而不是固定字符数截断
*/
public List<TextChunk> chunk(String text, String docId, String docTitle) {
List<TextChunk> chunks = new ArrayList<>();
// 先按段落分割
String[] paragraphs = text.split("\n\n+");
StringBuilder currentChunk = new StringBuilder();
int chunkIndex = 0;
for (String para : paragraphs) {
para = para.trim();
if (para.isEmpty()) continue;
// 如果加上当前段落会超过最大大小
if (currentChunk.length() + para.length() > MAX_CHUNK_SIZE
&& currentChunk.length() >= MIN_CHUNK_SIZE) {
// 保存当前chunk
chunks.add(new TextChunk(
docId + "_" + chunkIndex++,
currentChunk.toString().trim(),
docId,
docTitle
));
// 新chunk从当前chunk的结尾部分开始(overlap)
String overlap = getOverlapText(currentChunk.toString(), OVERLAP_SIZE);
currentChunk = new StringBuilder(overlap);
}
currentChunk.append(para).append("\n\n");
}
// 不要丢掉最后一个chunk
if (currentChunk.length() >= MIN_CHUNK_SIZE) {
chunks.add(new TextChunk(
docId + "_" + chunkIndex,
currentChunk.toString().trim(),
docId,
docTitle
));
}
return chunks;
}
private String getOverlapText(String text, int targetLength) {
if (text.length() <= targetLength) return text;
// 从末尾取targetLength个字符,从句号处开始
String tail = text.substring(text.length() - targetLength);
int sentenceStart = tail.indexOf('。');
if (sentenceStart != -1 && sentenceStart < targetLength / 2) {
return tail.substring(sentenceStart + 1);
}
return tail;
}
}处理数据库里的业务规则
这部分是我没想到的难点。Oracle数据库里有大量配置型的数据,比如:
-- 业务规则表的结构大概是这样
CREATE TABLE BIZ_RULE (
RULE_CODE VARCHAR2(50),
RULE_NAME VARCHAR2(200),
CONDITION_DESC VARCHAR2(2000), -- 条件描述,自然语言
ACTION_DESC VARCHAR2(2000), -- 执行动作描述
ENABLE_FLAG CHAR(1)
);这些结构化数据里包含了大量业务知识,但直接把表格塞进向量数据库效果很差。我们需要先把它转成自然语言格式:
@Service
public class BusinessRuleToTextConverter {
public String convertToNaturalLanguage(BizRule rule) {
return String.format(
"业务规则「%s」(规则编码:%s):\n" +
"触发条件:%s\n" +
"处理动作:%s\n" +
"当前状态:%s",
rule.getRuleName(),
rule.getRuleCode(),
rule.getConditionDesc(),
rule.getActionDesc(),
"1".equals(rule.getEnableFlag()) ? "已启用" : "已停用"
);
}
}第二关:独立AI服务的设计
AI服务用Spring Boot 3.x + JDK 21独立部署,通过内网HTTP接口跟遗留系统通信。
遗留系统这边的改动被我们刻意控制到最小:只需要在几个关键页面加一个"智能助手"入口,点击后通过AJAX调用AI服务。
这样的好处是:遗留系统几乎不用改动,风险可控;AI服务可以独立迭代,不受遗留系统的各种约束。
AI服务的核心检索逻辑
@Service
public class RagService {
@Autowired
private EmbeddingService embeddingService;
@Autowired
private VectorStoreClient vectorStore;
@Autowired
private LlmClient llmClient;
@Autowired
private ConversationHistoryService historyService;
public AiAnswer ask(AiQuestion question) {
// 1. 问题改写:考虑上下文,把指代词还原
String rewrittenQuestion = rewriteWithContext(
question.getQuestion(),
historyService.getRecentTurns(question.getSessionId(), 3)
);
// 2. 多路召回:用原始问题和改写后的问题分别召回,合并去重
float[] vector = embeddingService.embed(rewrittenQuestion);
List<RetrievedDoc> docs = vectorStore.search(
KNOWLEDGE_BASE_COLLECTION, vector, 8, null
);
// 3. 重排序:用Cross-Encoder对召回结果重新排序
List<RetrievedDoc> reranked = rerankService.rerank(rewrittenQuestion, docs, 4);
// 4. 构建Prompt
String prompt = buildPrompt(rewrittenQuestion, reranked,
historyService.getRecentTurns(question.getSessionId(), 3));
// 5. 调用LLM
String rawAnswer = llmClient.complete(prompt);
// 6. 保存对话历史
historyService.save(question.getSessionId(),
question.getQuestion(), rawAnswer);
return AiAnswer.builder()
.answer(rawAnswer)
.sources(reranked.stream().map(RetrievedDoc::getSourceInfo).collect(Collectors.toList()))
.sessionId(question.getSessionId())
.build();
}
private String rewriteWithContext(String question, List<ConversationTurn> history) {
if (history.isEmpty()) return question;
// 如果问题里有指代词,借助LLM改写
boolean hasReference = question.contains("这个") || question.contains("它")
|| question.contains("上面") || question.contains("刚才");
if (!hasReference) return question;
String historyContext = history.stream()
.map(t -> "Q: " + t.getQuestion() + "\nA: " + t.getAnswer())
.collect(Collectors.joining("\n\n"));
String rewritePrompt = String.format(
"根据以下对话历史,将用户的新问题改写为完整独立的问题,去掉指代词。\n\n" +
"对话历史:\n%s\n\n" +
"新问题:%s\n\n" +
"改写后的完整问题(只输出改写结果,不要解释):",
historyContext, question
);
return llmClient.complete(rewritePrompt);
}
private String buildPrompt(String question, List<RetrievedDoc> docs,
List<ConversationTurn> history) {
String contextText = docs.stream()
.map(d -> "【" + d.getDocTitle() + "】\n" + d.getContent())
.collect(Collectors.joining("\n\n---\n\n"));
String historyText = history.isEmpty() ? "" :
history.stream()
.map(t -> "用户:" + t.getQuestion() + "\n助手:" + t.getAnswer())
.collect(Collectors.joining("\n\n"));
return String.format(
"你是该系统的智能助手,请根据以下参考资料回答用户的问题。\n" +
"如果参考资料中没有相关信息,请直接说明,不要编造答案。\n" +
"回答要简洁准确,必要时可以列举操作步骤。\n\n" +
"%s" +
"参考资料:\n%s\n\n" +
"用户问题:%s\n\n" +
"助手回答:",
history.isEmpty() ? "" : "历史对话:\n" + historyText + "\n\n",
contextText,
question
);
}
}第三关:遗留系统的认证集成
这是我花时间最多的地方,没有之一。
遗留系统用的是自研的Session认证,Session存在JBoss的内存里(没有做外部化)。AI服务需要知道"是哪个用户在问问题",以便做权限过滤——不同角色的用户能看到的文档范围不同。
我们想到了几个方案:
方案A:调用遗留系统的验证接口。 用户每次请求AI服务时,AI服务回调遗留系统验证Token有效性。问题是遗留系统没有现成的Token验证接口,需要改代码。
方案B:遗留系统在转发请求时带上用户信息。 遗留系统的后端在调用AI服务时,把当前用户的角色信息作为HTTP Header传过去。AI服务信任这个Header。
方案C:共享Session存储。 把Session迁到Redis,让两个服务共享。工作量最大,但最彻底。
我们先用了方案B,因为遗留系统的后端(Servlet层)确实能拿到当前用户的Session信息,改动很小。
// 遗留系统的Servlet,在转发请求给AI服务时带上用户信息
// (这段代码是在遗留的JBoss项目里)
@WebServlet("/aiproxy")
public class AiProxyServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
// 从当前Session获取用户信息
HttpSession session = req.getSession(false);
if (session == null || session.getAttribute("currentUser") == null) {
resp.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
UserInfo currentUser = (UserInfo) session.getAttribute("currentUser");
// 读取请求体
String requestBody = req.getReader().lines().collect(Collectors.joining());
// 转发给AI服务,带上用户信息Header
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest aiRequest = HttpRequest.newBuilder()
.uri(URI.create(AI_SERVICE_URL + "/ai/ask"))
.header("Content-Type", "application/json")
.header("X-User-Id", currentUser.getUserId())
.header("X-User-Role", currentUser.getRole())
.header("X-Internal-Token", INTERNAL_SERVICE_TOKEN) // 内部服务调用的共享密钥
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> aiResponse = httpClient.send(
aiRequest, HttpResponse.BodyHandlers.ofString()
);
resp.setContentType("application/json;charset=UTF-8");
resp.getWriter().write(aiResponse.body());
}
}最难的部分:答案质量调优
技术上跑通之后,真正难的才开始:答案质量不好。
上线初期,用户的主要抱怨是:
- 回答有时候答非所问
- 有时候给出的操作步骤是错的
- 有时候说"文档中没有相关信息",但明明文档里有
这三个问题,分别对应了召回问题、文档质量问题和分块问题。
问题一的解决:召回增强
"答非所问"通常是因为召回的文档跟问题不相关。我们加了关键词过滤:在向量召回之后,再做一轮基于BM25的关键词匹配,只保留两种方法都认为相关的文档。
public List<RetrievedDoc> hybridRetrieve(String question, int topK) {
// 向量召回
float[] vector = embeddingService.embed(question);
List<RetrievedDoc> vectorResults = vectorStore.search(COLLECTION, vector, topK * 2, null);
// BM25关键词召回(使用Lucene实现)
List<RetrievedDoc> bm25Results = luceneSearcher.search(question, topK * 2);
// 合并:两种方法都召回到的优先,只有一种召回到的次之
Map<String, RetrievedDoc> vectorMap = vectorResults.stream()
.collect(Collectors.toMap(RetrievedDoc::getId, d -> d));
Map<String, RetrievedDoc> bm25Map = bm25Results.stream()
.collect(Collectors.toMap(RetrievedDoc::getId, d -> d));
List<RetrievedDoc> both = vectorMap.keySet().stream()
.filter(bm25Map::containsKey)
.map(vectorMap::get)
.collect(Collectors.toList());
List<RetrievedDoc> onlyVector = vectorResults.stream()
.filter(d -> !bm25Map.containsKey(d.getId()))
.collect(Collectors.toList());
// 优先返回两种方法都召回到的
List<RetrievedDoc> merged = new ArrayList<>(both);
merged.addAll(onlyVector);
return merged.subList(0, Math.min(topK, merged.size()));
}这个改动让"答非所问"的比例从20%降到了7%左右。
把遗留系统接入AI,远不是"接个SDK跑通示例"那么简单。数据清洗、格式转换、权限集成、答案质量调优——每一步都比预期复杂。
但当第一个用户说"这比翻文档快多了"的时候,那种感觉还是很好的。
