Java 实现嵌入式向量存储和混合检索,不依赖外部服务
很多团队的技术栈仍以传统关系型数据库或国产数据库为主这些数据库本身并不方便扩展向量能力。想在现有基础设施上平滑引入语义检索既不动数据库选型又不新增外部服务本质上需要的是一个 SQLite 级别的向量数据库——进程内嵌入运行一个 Jar 搞定重启自动恢复。RogueMemory 就是做这件事的。RogueMemory 是什么RogueMemory 是 RogueMap 生态里的 AI 记忆层定位是嵌入式 Java 向量存储。数据用 mmap 写在堆外文件里不占 JVM 堆。内置 HNSW 向量索引和 BM25 倒排索引两条检索通道的结果通过 RRF 融合排序。mmap 意味着进程崩了也不怕文件还在下次启动自动恢复。HNSW 负责语义搜索BM25 负责关键词匹配。RRFReciprocal Rank Fusion的逻辑是在向量排名和关键词排名里都靠前的文档综合得分最高比单走一条通道召回质量高一截。Maven 依赖只需要两个dependency groupIdcom.yomahub/groupId artifactIdroguemap-core/artifactId version1.1.1/version /dependency dependency groupIdcom.yomahub/groupId artifactIdroguemap-memory/artifactId version1.1.1/version /dependencyroguemap-memory会自动拉roguemap-embedding作为传递依赖不用手动加。快速上手初始化RogueMemory mem RogueMemory.mmap() .persistent(data/mem) // 持久化目录 .searchMode(SearchMode.HYBRID) // 混合检索默认值 .embeddingProvider(new UniversalEmbeddingProvider(apiKey)) .autoCheckpoint(5, TimeUnit.MINUTES) // 每5分钟写盘一次 .autoCheckpoint(500) // 每写入500条写盘一次 .build();autoCheckpoint支持两种触发方式定时 定量双保险。三种检索模式模式说明需要 Embedding APIHYBRID向量 BM25 混合推荐需要VECTOR_ONLY纯语义搜索需要KEYWORD_ONLY纯关键词匹配不需要KEYWORD_ONLY有个妙用内网环境访问不了外部 Embedding API直接用它完全本地化运行。基础 CRUD// 写入支持命名空间 元数据 mem.add(Spring Boot 自动装配原理, Map.of(source, docs), java-basics); mem.add(Redis 缓存穿透解决方案, Map.of(source, blog), middleware); // 检索 ListMemoryResult results mem.search(Spring 如何加载 Bean, 5); for (MemoryResult r : results) { System.out.println(r.getContent() score r.getScore()); } // 按命名空间过滤 ListMemoryResult filtered mem.search(缓存, 5, SearchOptions.builder().namespace(middleware).build()); // 删除 mem.delete(id); // 关闭自动写盘 mem.close();命名空间相当于轻量级的集合隔离同一个 RogueMemory 实例下不同命名空间的数据互不干扰。RRF 常数调优HYBRID 模式下RRF 公式有个常数 C默认 60。C 越小头部结果越突出C 越大排名越平滑。ListMemoryResult results mem.search(查询词, 5, SearchOptions.builder() .rrfConstant(30) // 调小让最相关的结果更突出 .build());大多数场景用默认值就够如果发现前几条结果分数差距太小可以试着把 C 调到 30~40。接入 LangChain4j EmbeddingStore真实项目通常已经是 LangChain4j 的那套 RAG pipeline。LangChain4j 定义了EmbeddingStoreTextSegment接口实现它就行Slf4j public class PigAIRogueMemoryEmbeddingStore implements EmbeddingStoreTextSegment { private static final String META_LC4J_ID lc4jId; private final RogueMemory rogueMemory; public PigAIRogueMemoryEmbeddingStore(String dataPath, String collectionName, EmbeddingModel embeddingModel) { validateCollectionName(collectionName); Path storePath Paths.get(dataPath).resolve(collectionName).normalize(); Files.createDirectories(storePath); // 桥接 LangChain4j EmbeddingModel → RogueMemory EmbeddingProvider EmbeddingProvider provider new EmbeddingProvider() { Override public float[] embed(String text) { return embeddingModel.embed(TextSegment.from(text)).content().vector(); } Override public int getDimension() { return embeddingModel.embed(TextSegment.from(dim)).content().vector().length; } }; this.rogueMemory RogueMemory.mmap() .persistent(storePath.toString()) .searchMode(SearchMode.HYBRID) .embeddingProvider(provider) .autoCheckpoint(5, TimeUnit.MINUTES) .autoCheckpoint(500) .build(); } Override public String add(Embedding embedding, TextSegment textSegment) { String id String.valueOf(IdUtil.getSnowflakeNextId()); rogueMemory.add(textSegment.text(), Map.of(META_LC4J_ID, id), default); return id; } Override public ListString addAll(ListEmbedding embeddings, ListTextSegment textSegments) { return embeddings.stream().map(e - { String id String.valueOf(IdUtil.getSnowflakeNextId()); rogueMemory.add(textSegments.get(embeddings.indexOf(e)).text(), Map.of(META_LC4J_ID, id), default); return id; }).toList(); } Override public EmbeddingSearchResultTextSegment search(EmbeddingSearchRequest request) { ListMemoryResult results rogueMemory.search( request.query(), request.maxResults()); ListEmbeddingMatchTextSegment matches results.stream() .map(r - new EmbeddingMatch( (double) r.getScore(), r.getId(), null, // 向量由 RogueMemory 内部管理 TextSegment.from(r.getContent()))) .toList(); return new EmbeddingSearchResult(matches); } public void remove(String id) { findRogueIdByLc4jId(id).forEach(rogueMemory::delete); } private ListString findRogueIdByLc4jId(String lc4jId) { return rogueMemory.search(lc4jId, 100).stream() .filter(r - lc4jId.equals( r.getMetadata() ! null ? r.getMetadata().get(META_LC4J_ID) : null)) .map(MemoryResult::getId) .toList(); } }LangChain4j 的EmbeddingModel和 RogueMemory 的EmbeddingProvider接口签名不同所以用匿名类做了桥接。好处是项目里已有的任何EmbeddingModelOpenAI、Ollama、本地模型都能直接用不用动别的代码。search返回的EmbeddingMatch里 embedding 字段传了 null。向量由 RogueMemory 内部管理没必要再序列化一遍传出来。删除逻辑稍微绕一下LangChain4j 的 ID 和 RogueMemory 内部 ID 是两套体系删除时先通过元数据里的lc4jId反查 rogueId再调用 delete。Spring Boot 配置Configuration public class RagConfig { Bean public EmbeddingStoreTextSegment embeddingStore(EmbeddingModel embeddingModel) { return new PigAIRogueMemoryEmbeddingStore( data/rogue-memory, knowledge-base, embeddingModel ); } }完整 RAG pipelineService RequiredArgsConstructor public class KnowledgeService { private final EmbeddingStoreTextSegment embeddingStore; private final EmbeddingModel embeddingModel; private final ChatLanguageModel chatModel; // 文档入库 public void ingest(String text) { ListTextSegment segments new DocumentSplitter() .split(Document.from(text)); ListEmbedding embeddings embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments); } // 问答 public String ask(String question) { return AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .contentRetriever(EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) .build()) .build() .answer(question); } }从InMemoryEmbeddingStore切换到PigAIRogueMemoryEmbeddingStore只改 Bean 配置那一行pipeline 其余部分不用动。接入 Spring AI VectorStoreSpring AI 的接口是VectorStore核心方法add(ListDocument)和similaritySearch(SearchRequest)和 LangChain4j 那套签名不一样单独适配一个Component public class PigAIRogueMemoryVectorStore implements VectorStore { private final RogueMemory rogueMemory; private final EmbeddingModel embeddingModel; public PigAIRogueMemoryVectorStore( Value(${rogue.data-path:data/spring-ai}) String dataPath, EmbeddingModel embeddingModel) { this.embeddingModel embeddingModel; EmbeddingProvider provider text - embeddingModel .embed(List.of(text)).getResults().get(0).getOutput(); this.rogueMemory RogueMemory.mmap() .persistent(dataPath) .searchMode(SearchMode.HYBRID) .embeddingProvider(provider) .autoCheckpoint(5, TimeUnit.MINUTES) .build(); } Override public void add(ListDocument documents) { documents.forEach(doc - rogueMemory.add( doc.getText(), doc.getMetadata() ! null ? doc.getMetadata() : Map.of(), default ) ); } Override public ListDocument similaritySearch(SearchRequest request) { return rogueMemory.search(request.getQuery(), request.getTopK()) .stream() .map(r - new Document(r.getContent(), r.getMetadata())) .toList(); } Override public boolean delete(ListString ids) { ids.forEach(rogueMemory::delete); return true; } }和 LangChain4j 适配层比类型名变了Spring AI 用DocumentgetText()LangChain4j 用TextSegmenttext()。方法名也不同getQuery()/getTopK()对应 LangChain4j 的query()/maxResults()。Spring AI 的delete返回 booleanLangChain4j 的remove是 void。接上QuestionAnswerAdvisor就能用了Bean public ChatClient chatClient(ChatModel chatModel, VectorStore vectorStore) { return ChatClient.builder(chatModel) .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore)) .build(); }然后直接问String answer chatClient.prompt() .user(Spring Boot 自动装配的原理是什么) .call() .content();写在最后就像 SQLite 让你不需要为一个小项目专门搭 MySQL 服务器RogueMemory 让你不需要为 RAG 专门搭 Milvus。嵌入式不代表将就。HNSW BM25 RRF 这套组合各自都是成熟的技术只是现在打包成一个 Jar 了。对于大多数中小规模的知识库场景可以来试试这个方案。数据量和准确性到了撑不住的地步再考虑迁 Milvus因为 LangChain4j / Spring AI 的接口是标准的换存储只改一个 Bean。