This is an automated email from the ASF dual-hosted git repository.

epugh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-mcp.git


The following commit(s) were added to refs/heads/main by this push:
     new 3adac6e9 perf(search): request score and keep Solr's document and 
facet order (#234)
3adac6e9 is described below

commit 3adac6e9bec7087df3072f8c8ce3d9a5b0d85400
Author: Aditya Parikh <[email protected]>
AuthorDate: Tue Oct 6 14:41:33 2026 -0400

    perf(search): request score and keep Solr's document and facet order (#234)
    
    * perf(search): request score and keep Solr's document and facet order
    
    search never asked Solr for the relevance score, so maxScore was always
    null and no document carried a score, although SearchResponse and the
    search-collection prompt both describe them. The query now sets
    fl=score,* (setIncludeScore); the tool takes no field list, so nothing
    caller-supplied is overridden.
    
    getDocs copied every SolrDocument into a new HashMap, an extra copy per
    hit that also scrambled field order. SolrDocument already implements
    Map<String, Object> over a LinkedHashMap, so the documents are returned
    as they are.
    
    getFacets built its buckets in HashMaps, discarding the count-sorted
    order Solr returns before it reached the caller. It now uses
    LinkedHashMap.
    
    JsonResponseParser's javadoc said JSON decimals become Double; they
    become Float, deliberately, because SolrJ response classes cast to it
    (SchemaResponse reads the schema version as a Float). The javadoc now
    says so.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Aditya Parikh <[email protected]>
    
    * refactor(search): return an unmodifiable view of the documents, not a copy
    
    SolrDocument is already a Map<String, Object>, so the result list is
    exposed through Collections.unmodifiableList instead of copied, and
    the getDocs helper goes.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Aditya Parikh <[email protected]>
    
    ---------
    
    Signed-off-by: Aditya Parikh <[email protected]>
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../solr/mcp/server/config/JsonResponseParser.java |  4 +-
 .../solr/mcp/server/search/SearchService.java      | 67 +++++-----------------
 .../search/SearchServiceIntegrationTest.java       | 19 ++++++
 .../solr/mcp/server/search/SearchServiceTest.java  | 67 ++++++++++++++++++++++
 4 files changed, 102 insertions(+), 55 deletions(-)

diff --git 
a/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java 
b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java
index dec1dcab..a2a005db 100644
--- a/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java
+++ b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java
@@ -54,7 +54,9 @@ import org.springframework.http.MediaType;
  * {@link NamedList} (Solr's {@code json.nl=flat} facet encoding)</li>
  * <li>All other JSON arrays → {@link List}</li>
  * <li>JSON integers → {@link Integer} or {@link Long} (by value size)</li>
- * <li>JSON decimals → {@link Double}</li>
+ * <li>JSON decimals → {@link Float}, matching the JavaBin codec; SolrJ 
response
+ * classes cast to it (e.g. {@code SchemaResponse} reads the schema version as 
a
+ * {@code Float})</li>
  * <li>JSON booleans → {@link Boolean}</li>
  * <li>JSON strings → {@link String}</li>
  * </ul>
diff --git a/src/main/java/org/apache/solr/mcp/server/search/SearchService.java 
b/src/main/java/org/apache/solr/mcp/server/search/SearchService.java
index c9dda135..ac1b2f37 100644
--- a/src/main/java/org/apache/solr/mcp/server/search/SearchService.java
+++ b/src/main/java/org/apache/solr/mcp/server/search/SearchService.java
@@ -18,8 +18,8 @@ package org.apache.solr.mcp.server.search;
 
 import io.micrometer.observation.annotation.Observed;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
+import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -28,7 +28,6 @@ import org.apache.solr.client.solrj.SolrServerException;
 import org.apache.solr.client.solrj.request.SolrQuery;
 import org.apache.solr.client.solrj.response.FacetField;
 import org.apache.solr.client.solrj.response.QueryResponse;
-import org.apache.solr.common.SolrDocument;
 import org.apache.solr.common.SolrDocumentList;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.params.FacetParams;
@@ -161,64 +160,19 @@ public class SearchService {
                this.solrClient = solrClient;
        }
 
-       /**
-        * Converts a SolrDocumentList to a List of Maps for optimized JSON
-        * serialization.
-        *
-        * <p>
-        * This method transforms Solr's native document format into a 
structure that
-        * can be easily serialized to JSON and consumed by MCP clients. Each 
document
-        * becomes a flat map of field names to field values, preserving all 
data types.
-        *
-        * <p>
-        * <strong>Conversion Process:</strong>
-        *
-        * <ul>
-        * <li>Iterates through each SolrDocument in the list
-        * <li>Extracts all field names and their corresponding values
-        * <li>Creates a HashMap for each document with field-value pairs
-        * <li>Preserves original data types (strings, numbers, dates, arrays)
-        * </ul>
-        *
-        * <p>
-        * <strong>Performance Optimization:</strong>
-        *
-        * <p>
-        * Pre-allocates the ArrayList with the known document count to 
minimize memory
-        * allocations and improve conversion performance for large result sets.
-        *
-        * @param documents
-        *            the SolrDocumentList to convert from Solr's native format
-        * @return a List of Maps where each Map represents a document with 
field names
-        *         as keys
-        * @see SolrDocument
-        * @see SolrDocumentList
-        */
-       private static List<Map<String, Object>> getDocs(SolrDocumentList 
documents) {
-               List<Map<String, Object>> docs = new 
ArrayList<>(documents.size());
-               documents.forEach(doc -> {
-                       Map<String, Object> docMap = new HashMap<>();
-                       for (String fieldName : doc.getFieldNames()) {
-                               docMap.put(fieldName, 
doc.getFieldValue(fieldName));
-                       }
-                       docs.add(docMap);
-               });
-               return docs;
-       }
-
        /**
         * Extracts facet information from a QueryResponse.
         *
         * @param queryResponse
         *            The QueryResponse containing facet results
         * @return A Map where keys are facet field names and values are Maps 
of facet
-        *         values to counts
+        *         values to counts, both in Solr's original (count-sorted) 
order
         */
        private static Map<String, Map<String, Long>> getFacets(QueryResponse 
queryResponse) {
-               Map<String, Map<String, Long>> facets = new HashMap<>();
+               Map<String, Map<String, Long>> facets = new LinkedHashMap<>();
                if (queryResponse.getFacetFields() != null && 
!queryResponse.getFacetFields().isEmpty()) {
                        queryResponse.getFacetFields().forEach(facetField -> {
-                               Map<String, Long> facetValues = new HashMap<>();
+                               Map<String, Long> facetValues = new 
LinkedHashMap<>();
                                for (FacetField.Count count : 
facetField.getValues()) {
                                        facetValues.put(count.getName(), 
count.getCount());
                                }
@@ -313,6 +267,9 @@ public class SearchService {
                        solrQuery.setQuery(query);
                }
 
+               // fl=score,* — Solr only returns maxScore and per-document 
score when asked
+               solrQuery.setIncludeScore(true);
+
                // filter queries
                if (!CollectionUtils.isEmpty(filterQueries)) {
                        solrQuery.setFilterQueries(filterQueries.toArray(new 
String[0]));
@@ -351,7 +308,8 @@ public class SearchService {
                final SolrDocumentList documents = queryResponse.getResults();
 
                // Convert SolrDocuments to Maps
-               final var docs = getDocs(documents);
+               // SolrDocument is a Map in Solr's field order, so no 
per-document copy
+               final List<Map<String, Object>> docs = 
Collections.unmodifiableList(documents);
 
                // Add facets if present
                final var facets = getFacets(queryResponse);
@@ -464,8 +422,9 @@ public class SearchService {
                                                 * Many results: add a 
`filterQueries` constraint to narrow, or pass
                                                   `facetFields` on a relevant 
`string`/`strings` field to surface the distribution
                                                   and pick a sharper filter.
-                                  - Inspect `documents` for the actual 
content. The response includes `maxScore` when
-                                    the query is not `*:*`; use it as a 
relative confidence signal across queries.
+                                  - Inspect `documents` for the actual 
content. Each document carries a relevance
+                                    `score`, and the response's `maxScore` is 
the top score among them; both are most
+                                    meaningful as a relative confidence signal 
for non-`*:*` queries.
 
                                5. Summarize.
                                   - Answer the user's question grounded in the 
documents found, citing concrete field
diff --git 
a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java
 
b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java
index c7f7d7c0..55c1aabf 100644
--- 
a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java
+++ 
b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java
@@ -297,6 +297,25 @@ class SearchServiceIntegrationTest {
                assertFalse(e.getMessage().contains("Hint:"), () -> "expected 
no hint, got: " + e.getMessage());
        }
 
+       /**
+        * The search tool always requests the relevance score (fl=score,*), so 
a
+        * non-{@code *:*} query — which actually produces a meaningful ranking 
— must
+        * come back with a non-null {@code maxScore} and a {@code score} on 
every
+        * document. Both were silently dropped before this fix: Solr only 
populates
+        * them when {@code score} is in the field list, which this service 
never set.
+        */
+       @Test
+       void testSearchWithQueryPopulatesMaxScoreAndDocumentScore() throws 
SolrServerException, IOException {
+               SearchResponse result = searchService.search(COLLECTION_NAME, 
"genre_s:fantasy", null, null, null, null, null);
+               assertNotNull(result);
+               assertNotNull(result.maxScore(), "maxScore must be populated 
for a non-*:* query");
+               assertTrue(result.maxScore() > 0, "maxScore should be a 
positive relevance score");
+               assertFalse(result.documents().isEmpty());
+               for (Map<String, Object> book : result.documents()) {
+                       assertNotNull(book.get("score"), "each document should 
carry its relevance score");
+               }
+       }
+
        @Test
        void testSearchWithQuery() throws SolrServerException, IOException {
                SearchResponse result = searchService.search(COLLECTION_NAME, 
"name:\"Game of Thrones\"", null, null, null,
diff --git 
a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java 
b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java
index ed80b17b..28f10856 100644
--- a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java
+++ b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java
@@ -130,6 +130,30 @@ class SearchServiceTest {
                assertNotNull(result);
        }
 
+       /**
+        * The score must be requested on every search so Solr populates {@code 
score}
+        * on each document and {@code maxScore} on the response — otherwise 
both stay
+        * {@code null}/absent even though {@link SearchResponse}'s javadoc and 
the
+        * search-collection prompt promise them.
+        */
+       @Test
+       void search_ShouldRequestScoreInFieldList() throws Exception {
+               SolrClient mockClient = mock(SolrClient.class);
+               QueryResponse mockResponse = mock(QueryResponse.class);
+               SolrDocumentList mockDocuments = createMockDocumentList();
+               when(mockResponse.getResults()).thenReturn(mockDocuments);
+               when(mockResponse.getFacetFields()).thenReturn(null);
+               when(mockClient.query(eq("test_collection"), 
any(SolrQuery.class))).thenAnswer(invocation -> {
+                       SolrQuery q = invocation.getArgument(1);
+                       
assertTrue(List.of(q.getFields().split(",")).contains("score"),
+                                       () -> "expected fl to request score, 
got: " + q.getFields());
+                       return mockResponse;
+               });
+               SearchService localService = new SearchService(mockClient);
+               SearchResponse result = localService.search("test_collection", 
"name:foo", null, null, null, null, null);
+               assertNotNull(result);
+       }
+
        @Test
        void search_WithCustomQuery_ShouldUseProvidedQuery() throws Exception {
                SolrClient mockClient = mock(SolrClient.class);
@@ -334,6 +358,49 @@ class SearchServiceTest {
                assertFalse(result.facets().isEmpty());
        }
 
+       /**
+        * Documents come back as the raw {@link SolrDocument} (which implements
+        * {@code Map<String, Object>}, backed by a {@link 
java.util.LinkedHashMap}), so
+        * field order must survive unchanged rather than being scrambled by a
+        * {@link java.util.HashMap} copy.
+        */
+       @Test
+       void searchResponse_ShouldPreserveDocumentFieldOrder() throws Exception 
{
+               SolrClient mockClient = mock(SolrClient.class);
+               QueryResponse mockResponse = mock(QueryResponse.class);
+               SolrDocumentList mockDocuments = 
createMockDocumentListWithData();
+               when(mockResponse.getResults()).thenReturn(mockDocuments);
+               when(mockResponse.getFacetFields()).thenReturn(null);
+               when(mockClient.query(eq("test_collection"), 
any(SolrQuery.class))).thenReturn(mockResponse);
+               SearchService localService = new SearchService(mockClient);
+               SearchResponse result = localService.search("test_collection", 
null, null, null, null, null, null);
+               var firstDoc = result.documents().getFirst();
+               assertEquals(List.of("id", "name", "author_ss", "price", 
"genre_s"), List.copyOf(firstDoc.keySet()),
+                               "document field order must match Solr's 
original order");
+       }
+
+       /**
+        * Solr already sorts facet buckets by count ({@code facet.sort=count}, 
the
+        * default this service sets); a {@link java.util.HashMap} would 
discard that
+        * order before the LLM ever sees it.
+        */
+       @Test
+       void searchResponse_ShouldPreserveFacetOrder() throws Exception {
+               SolrClient mockClient = mock(SolrClient.class);
+               QueryResponse mockResponse = mock(QueryResponse.class);
+               SolrDocumentList mockDocuments = createMockDocumentList();
+               when(mockResponse.getResults()).thenReturn(mockDocuments);
+               
when(mockResponse.getFacetFields()).thenReturn(createMockFacetFields());
+               when(mockClient.query(eq("test_collection"), 
any(SolrQuery.class))).thenReturn(mockResponse);
+               SearchService localService = new SearchService(mockClient);
+               SearchResponse result = localService.search("test_collection", 
null, null, List.of("genre_s", "author_ss"),
+                               null, null, null);
+               assertEquals(List.of("genre_s", "author_ss"), 
List.copyOf(result.facets().keySet()),
+                               "facet field order must match Solr's response 
order");
+               assertEquals(List.of("technology", "fiction"), 
List.copyOf(result.facets().get("genre_s").keySet()),
+                               "facet bucket order (count-sorted by Solr) must 
be preserved");
+       }
+
        private SolrDocumentList createMockDocumentList() {
                SolrDocumentList documents = new SolrDocumentList();
                documents.setNumFound(0);

Reply via email to