第 12 篇 /ai/chat 在检索时不按文档元数据过滤 (UserContext 为空时,AiConfig 里检索器的 dynamicFilter 返回 null,查的是整库)。
本篇:灌库时为每个文档片段打上 userId、sex;请求 /ai/chatByFilter 时先写入 UserContext,检索器只返回元数据匹配的片段。
先完成 12|HTTP 问答。
说明 :AiConfig 中的 ContentRetriever(dynamicFilter 读 UserContext)在第 9 篇已写好,本篇不重复贴该类,只核对逻辑一致即可。
本篇跟做涉及:
| 操作 | 路径 |
|---|---|
| 追加或覆盖配置 | src/main/resources/application.properties |
| 可选覆盖(关闭第 10 篇灌库) | src/main/java/com/ekko/gemini/init/EmbeddingInit.java |
| 覆盖 | src/main/java/com/ekko/gemini/common/UserContext.java |
| 覆盖 | src/main/java/com/ekko/gemini/assistant/KnowledgeAssistant.java |
| 覆盖 | src/main/java/com/ekko/gemini/service/DocumentIngestionService.java |
| 覆盖 | src/main/java/com/ekko/gemini/controller/ChatController.java |
前置:doc/ 下有可解析文档(如第 9 篇的 AI_RAG_Practical_Guide.md);pom.xml 含 langchain4j-document-parser-apache-tika。
步骤 1:application.properties(整段追加或合并)
路径:src/main/resources/application.properties
保留原有 spring.application.name、google.ai.api-key,增加一行(关闭第 10 篇那种无元数据灌库,避免与过滤检索混在一起):
properties
spring.application.name=gemini-demo
google.ai.api-key=${GOOGLE_AI_API_KEY:}
# false:禁用 EmbeddingInit;仅用带 userId/sex 标签的 DocumentIngestionService 灌库。
rag.ingest.embedding-init=false
若希望恢复第 10~12 篇行为,改为 true 或删掉本行(配合 EmbeddingInit 上 matchIfMissing 默认启用)。
步骤 2:EmbeddingInit.java(整文件复制)
路径:src/main/java/com/ekko/gemini/init/EmbeddingInit.java
仅当 rag.ingest.embedding-init=true 时注册并执行灌库;第 13 篇配置为 false 时本类不生效。
java
package com.ekko.gemini.init;
import java.nio.file.Paths;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.loader.FileSystemDocumentLoader;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.EmbeddingStoreIngestor;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
/**
* 无元数据灌库(第 10~12 篇)。第 13 篇起设 rag.ingest.embedding-init=false,
* 改用 DocumentIngestionService 带标签灌库。
*/
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "rag.ingest.embedding-init", havingValue = "true", matchIfMissing = true)
public class EmbeddingInit {
private final EmbeddingModel embeddingModel;
private final EmbeddingStore<TextSegment> embeddingStore;
@PostConstruct
public void importLocalDocs() {
Document document = FileSystemDocumentLoader.loadDocument(
Paths.get("doc/AI_RAG_Practical_Guide.md"));
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
.documentSplitter(DocumentSplitters.recursive(500, 50))
.embeddingModel(embeddingModel)
.embeddingStore(embeddingStore)
.build();
ingestor.ingest(document);
System.out.println("本地文档已成功转换为向量并存入数据库!");
}
}
步骤 3:UserContext.java(整文件复制)
路径:src/main/java/com/ekko/gemini/common/UserContext.java
java
package com.ekko.gemini.common;
/**
* 请求线程内暂存 userId、sex,供 AiConfig 中检索器的 dynamicFilter 读取。
* 请求结束必须 clear(),避免线程池复用串数据。
*/
public class UserContext {
private static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();
private static final ThreadLocal<Integer> CURRENT_SEX = new ThreadLocal<>();
public static void set(String userId, Integer sex) {
CURRENT_USER.set(userId);
CURRENT_SEX.set(sex);
}
public static String getUserId() {
return CURRENT_USER.get();
}
public static Integer getSex() {
return CURRENT_SEX.get();
}
public static void clear() {
CURRENT_USER.remove();
CURRENT_SEX.remove();
}
}
步骤 4:KnowledgeAssistant.java(整文件复制)
路径:src/main/java/com/ekko/gemini/assistant/KnowledgeAssistant.java
本篇只新增 一个方法 chatByFilter;chat 与第 11、12 篇相同。
java
package com.ekko.gemini.assistant;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.Result;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
public interface KnowledgeAssistant {
@SystemMessage("你是一个基于本地文档的知识助手。请仅根据提供的背景信息回答。")
Result<String> chat(@MemoryId String uid, @UserMessage String message);
@SystemMessage("你是一个基于本地文档的知识助手。请仅根据提供的背景信息回答。")
String chatByFilter(@MemoryId String uid, @UserMessage String message);
}
步骤 5:DocumentIngestionService.java(整文件复制)
路径:src/main/java/com/ekko/gemini/service/DocumentIngestionService.java
默认在应用就绪后把 doc/ 下文件灌库,并打上 userId=ekko、sex=1(与下面 curl 示例一致,可按需改常量)。
java
package com.ekko.gemini.service;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentParser;
import dev.langchain4j.data.document.Metadata;
import dev.langchain4j.data.document.loader.FileSystemDocumentLoader;
import dev.langchain4j.data.document.parser.apache.tika.ApacheTikaDocumentParser;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.EmbeddingStoreIngestor;
@Service
public class DocumentIngestionService {
private final EmbeddingStore<TextSegment> embeddingStore;
private final EmbeddingModel embeddingModel;
public DocumentIngestionService(
EmbeddingStore<TextSegment> embeddingStore,
EmbeddingModel embeddingModel) {
this.embeddingStore = embeddingStore;
this.embeddingModel = embeddingModel;
}
@EventListener(ApplicationReadyEvent.class)
public void autoIngestOnStart() {
System.out.println("🚀 DocumentIngestionService:带标签灌入 doc/ ...");
ingestWithFilters("ekko", 1);
}
public void ingestWithFilters(String userId, Integer sexValue) {
try {
Path path = Paths.get("doc/");
DocumentParser parser = new ApacheTikaDocumentParser();
System.out.println("📂 加载目录: " + path.toAbsolutePath());
List<Document> documents = FileSystemDocumentLoader.loadDocuments(path, parser);
if (documents.isEmpty()) {
System.out.println("⚠️ doc/ 下未发现文档");
return;
}
List<Document> taggedDocuments = new ArrayList<>();
for (Document doc : documents) {
Metadata metadata = new Metadata();
metadata.put("userId", userId);
metadata.put("sex", sexValue);
String fn = doc.metadata().getString("file_name");
if (fn != null) {
metadata.put("file_name", fn);
}
taggedDocuments.add(Document.from(doc.text(), metadata));
}
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
.documentSplitter(DocumentSplitters.recursive(500, 100))
.embeddingModel(embeddingModel)
.embeddingStore(embeddingStore)
.build();
System.out.println("🧠 向量化并灌库...");
ingestor.ingest(taggedDocuments);
System.out.println("✅ 灌入完成,文档数: " + taggedDocuments.size()
+ ",标签 userId=" + userId + ", sex=" + sexValue);
} catch (Exception e) {
System.err.println("❌ 灌入失败:");
e.printStackTrace();
}
}
}
步骤 6:ChatController.java(整文件复制)
路径:src/main/java/com/ekko/gemini/controller/ChatController.java
本篇在保留第 12 篇 GET /ai/chat 的基础上,新增 GET /ai/chatByFilter。
java
package com.ekko.gemini.controller;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ekko.gemini.assistant.KnowledgeAssistant;
import com.ekko.gemini.common.UserContext;
import dev.langchain4j.service.Result;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/ai")
@RequiredArgsConstructor
public class ChatController {
private final KnowledgeAssistant assistant;
@GetMapping("/chat")
public Map<String, Object> chat(
@RequestParam("userId") String userId,
@RequestParam("message") String message) {
Result<String> result = assistant.chat(userId, message);
result.sources().forEach(segment -> {
System.out.println("🚩 检索片段元数据: " + segment.metadata());
});
String answer = result.content();
List<String> sourceFiles = result.sources().stream()
.map(segment -> {
String fileName = (String) segment.metadata().get("file_name");
if (fileName == null) {
fileName = (String) segment.metadata().get("source");
}
return fileName;
})
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<String, Object> response = new HashMap<>();
response.put("answer", answer);
response.put("sources", sourceFiles);
return response;
}
@GetMapping("/chatByFilter")
public String chatByFilter(
@RequestParam String message,
@RequestParam String userId,
@RequestParam Integer sex) {
try {
UserContext.set(userId, sex);
return assistant.chatByFilter(userId, message);
} finally {
UserContext.clear();
}
}
}
核对 AiConfig(不贴全文)
打开 AiConfig 中 ContentRetriever:dynamicFilter 内应从 UserContext.getUserId()、getSex() 取值,两者任一为空时返回 null(不过滤)。与第 9 篇一致即可。
验收
启动:
bash
./mvnw spring-boot:run
日志中应出现 DocumentIngestionService 灌库成功;不应 再出现第 10 篇 EmbeddingInit 的「本地文档已成功转换...」(因已 rag.ingest.embedding-init=false)。
过滤接口(userId、sex 须与 DocumentIngestionService.ingestWithFilters 中一致,示例为 ekko、1):
bash
curl -G "http://127.0.0.1:8080/ai/chatByFilter" \
--data-urlencode "userId=ekko" \
--data-urlencode "sex=1" \
--data-urlencode "message=RAG 核心步骤是什么?"
返回为纯文本回答。故意传错 userId 时,检索可能命中为空,回答会与文档偏差或提示无依据。
常见问题
过滤不生效
确认 finally 里调用了 UserContext.clear();确认灌库标签键名与 MetadataFilterBuilder 一致(userId、sex)。
与第 12 篇行为不一致
/ai/chat 仍不按请求设置 UserContext,检索不带过滤;仅 /ai/chatByFilter 走过滤。
下一篇
14|Apache Tika 多格式文档 --- 进一步整理 doc/ 下 PDF/Word 与元数据中的 file_name。