diff --git a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java index 4eae298a86d..a0ec93e0bd4 100644 --- a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java +++ b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java @@ -17,6 +17,7 @@ package org.springframework.ai.vectorstore; import java.util.Objects; +import java.util.Map; import org.springframework.ai.document.Document; import org.springframework.ai.vectorstore.filter.Filter; @@ -59,6 +60,8 @@ public class SearchRequest { @Nullable private Filter.Expression filterExpression; + private static final Map ESCAPE_TEXT = Map.of('\\', "\\\\", '.', "\\."); + /** * Copy an existing {@link SearchRequest.Builder} instance. * @param originalSearchRequest {@link SearchRequest} instance to copy. @@ -191,7 +194,6 @@ public Builder similarityThresholdAll() { /** * Retrieves documents by query embedding similarity and matching the filters. * Value of 'null' means that no metadata filters will be applied to the search. - * * For example if the {@link Document#getMetadata()} schema is: * *
{@code
@@ -283,10 +285,32 @@ public Builder filterExpression(@Nullable Filter.Expression expression) {
 		 */
 		public Builder filterExpression(@Nullable String textExpression) {
 			this.searchRequest.filterExpression = (textExpression != null)
-					? new FilterExpressionTextParser().parse(textExpression) : null;
+					? new FilterExpressionTextParser().parse(escapeTextExpression(textExpression)) : null;
 			return this;
 		}
 
+		private String escapeTextExpression(String expression) {
+			StringBuilder sb = new StringBuilder(expression.length() + 8);
+			boolean inQuote = false;
+
+			for (int i = 0; i < expression.length(); i++) {
+				char ch = expression.charAt(i);
+
+				if (ch == '\'') {
+					inQuote = !inQuote;
+					sb.append(ch);
+				}
+				else if (inQuote) {
+					sb.append(ESCAPE_TEXT.getOrDefault(ch, String.valueOf(ch)));
+				}
+				else {
+					sb.append(ch);
+				}
+			}
+
+			return sb.toString();
+		}
+
 		public SearchRequest build() {
 			return this.searchRequest;
 		}
diff --git a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/filter/SearchRequestTests.java b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/filter/SearchRequestTests.java
index 44861840fff..e7c2797f168 100644
--- a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/filter/SearchRequestTests.java
+++ b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/filter/SearchRequestTests.java
@@ -138,6 +138,61 @@ public void filterExpression() {
 
 	}
 
+	@Test
+	public void filterExpressionWithDotAndBackslashIsEscaped() {
+		var request = SearchRequest.builder()
+			.query("Test")
+			.filterExpression("file_name == 'clean.code.pdf' && path == 'C:\\docs\\files'")
+			.build();
+
+		var expression = request.getFilterExpression();
+
+		assertThat(expression.toString()).contains("clean\\.code\\.pdf");
+		assertThat(expression.toString()).contains("C:\\\\docs\\\\files");
+	}
+
+	@Test
+	public void filterExpressionWithMultipleLiteralsIsEscapedIndependently() {
+		var request = SearchRequest.builder()
+			.query("Test")
+			.filterExpression("file_name == 'a.b.c' && author == 'me.you'")
+			.build();
+
+		var expression = request.getFilterExpression();
+
+		assertThat(expression.toString()).contains("a\\.b\\.c");
+		assertThat(expression.toString()).contains("me\\.you");
+	}
+
+	@Test
+	public void filterExpressionWithVariousFileExtensionsIsEscaped() {
+		var request = SearchRequest.builder()
+			.query("Test")
+			.filterExpression(
+					"file_name == 'summary.epub' || file_name == 'lecture_notes.md' || file_name == 'slides.pptx'")
+			.build();
+
+		String expression = request.getFilterExpression().toString();
+
+		assertThat(expression).contains("summary\\.epub");
+		assertThat(expression).contains("lecture_notes\\.md");
+		assertThat(expression).contains("slides\\.pptx");
+	}
+
+	@Test
+	public void filterExpressionWithInListIsEscaped() {
+		var request = SearchRequest.builder()
+			.query("Test")
+			.filterExpression("file_name IN ['a.pdf', 'b.txt', 'final.report.docx']")
+			.build();
+
+		String expression = request.getFilterExpression().toString();
+
+		assertThat(expression).contains("a\\.pdf");
+		assertThat(expression).contains("b\\.txt");
+		assertThat(expression).contains("final\\.report\\.docx");
+	}
+
 	private void checkDefaults(SearchRequest request) {
 		assertThat(request.getFilterExpression()).isNull();
 		assertThat(request.getSimilarityThreshold()).isEqualTo(SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL);