13|元数据过滤检索:UserContext 与按用户查知识

第 12 篇 /ai/chat 在检索时不按文档元数据过滤UserContext 为空时,AiConfig 里检索器的 dynamicFilter 返回 null,查的是整库)。

本篇:灌库时为每个文档片段打上 userIdsex;请求 /ai/chatByFilter 时先写入 UserContext,检索器只返回元数据匹配的片段。

先完成 12|HTTP 问答

说明AiConfig 中的 ContentRetrieverdynamicFilterUserContext)在第 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.xmllangchain4j-document-parser-apache-tika

步骤 1:application.properties(整段追加或合并)

路径:src/main/resources/application.properties

保留原有 spring.application.namegoogle.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 或删掉本行(配合 EmbeddingInitmatchIfMissing 默认启用)。

步骤 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

本篇只新增 一个方法 chatByFilterchat 与第 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=ekkosex=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(不贴全文)

打开 AiConfigContentRetrieverdynamicFilter 内应从 UserContext.getUserId()getSex() 取值,两者任一为空时返回 null(不过滤)。与第 9 篇一致即可。

验收

启动:

bash 复制代码
./mvnw spring-boot:run

日志中应出现 DocumentIngestionService 灌库成功;不应 再出现第 10 篇 EmbeddingInit 的「本地文档已成功转换...」(因已 rag.ingest.embedding-init=false)。

过滤接口(userIdsex 须与 DocumentIngestionService.ingestWithFilters 中一致,示例为 ekko1):

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 一致(userIdsex)。

与第 12 篇行为不一致

/ai/chat 仍不按请求设置 UserContext,检索不带过滤;仅 /ai/chatByFilter 走过滤。

下一篇

14|Apache Tika 多格式文档 --- 进一步整理 doc/ 下 PDF/Word 与元数据中的 file_name

相关推荐
阿维的博客日记1 小时前
Spring Cloud 为什么需要服务注册与发现中心这些东西?
后端·spring·spring cloud
用户095367515831 小时前
Go:浮点数如何进行比较?
后端·go
Zeus_1 小时前
如何更好的创建skill
后端
千云1 小时前
AI Coding 落地探索日志 · 初篇 · 启程记
后端·ai编程
子兮曰2 小时前
whisper.cpp 深度解析:从边缘设备到实时语音识别
前端·c++·后端
子兮曰2 小时前
Ruflo 深度解析:49K Stars 的 AI Agent 编排平台 — 给 Claude Code 装上分布式神经系统
前端·后端·ai编程
代码丰2 小时前
大模型 + RAG 幻觉治理方案总结
后端
小村儿2 小时前
(译文)重温:Karpathy 的 4 条 CLAUDE.md 规则将 Claude 错误率从 41% 降至 11%——历经 30 个代码库后,我又加了 8 条
前端·后端·ai编程
user69600737566172 小时前
3个前端性能优化技巧,我用后页面加载快了80%
后端