Croway commented on code in PR #27814:
URL: https://github.com/apache/camel/pull/27814#discussion_r4269314210


##########
dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationDataSearchTest.java:
##########
@@ -55,6 +56,82 @@ void nonsenseQueryReturnsNoResults() {
         assertThat(results).isEmpty();
     }
 
+    @Test
+    void unrelatedOrStopWordsOnlyQueryReturnsNoResults() {
+        // "smoothie" is within typo distance of "smoothing", but an entry 
matching a third of the query is unrelated
+        assertThat(migrationData.searchGuides("banana smoothie recipe", 
3)).isEmpty();
+        assertThat(migrationData.searchGuides("camel migration upgrade guide", 
3)).isEmpty();
+    }
+
+    @Test
+    void entriesAreTopicsWithTheReleaseHeadingAsContext() {
+        assertThat(migrationData.getGuideIndex())
+                .anyMatch(s -> s.sectionTitle().equals("Upgrading Camel 4.20 
to 4.21 / camel-cxf"))
+                .allMatch(s -> s.content().lines().noneMatch(l -> 
l.startsWith("== ") || l.startsWith("=== ")));
+    }
+
+    @Test
+    void cxfServletFindsCamelCxfEntry() {
+        List<MigrationData.GuideSection> results = 
migrationData.searchGuides("cxf servlet", 3);
+
+        assertThat(results).isNotEmpty();
+        assertThat(results.get(0).sectionTitle()).endsWith("/ camel-cxf");
+        assertThat(results.get(0).content()).contains("camel-servlet");
+    }
+
+    @Test
+    void camelMailSubjectHeaderFindsCamelMailEntry() {
+        List<MigrationData.GuideSection> results = 
migrationData.searchGuides("camel-mail subject header", 3);
+
+        assertThat(results).isNotEmpty();
+        assertThat(results.get(0).sectionTitle()).contains("/ camel-mail");
+        assertThat(results.get(0).content()).contains("MAIL_SUBJECT");
+    }
+
+    @Test
+    void versionRangeRanksMajorMigrationGuidesFirst() {
+        List<MigrationData.SearchHit> hits
+                = migrationData.search("CXF SOAP web service migration Camel 2 
to 4", "2.25", "4.22", 5);
+
+        assertThat(hits).isNotEmpty();
+        assertThat(hits.get(0).section().guide()).isEqualTo("Camel 3 Migration 
Guide (2.x to 3.0)");
+        assertThat(hits.get(0).section().sectionTitle()).startsWith("Migrating 
Camel applications");
+        assertThat(hits.get(0).section().content()).contains("camel-cxf");
+    }
+
+    @Test
+    void versionRangeOmitsGuidesOutsideTheRange() {
+        List<MigrationData.SearchHit> hits = migrationData.search("cxf 
servlet", "4.10", "4.22", 20);
+
+        assertThat(hits).isNotEmpty();
+        assertThat(hits).extracting(h -> h.section().version())
+                .allMatch(v -> v.startsWith("4.") && 
Integer.parseInt(v.substring(2)) > 10
+                        && Integer.parseInt(v.substring(2)) <= 22);
+    }
+
+    @Test
+    void invalidVersionIsRejected() {
+        assertThatThrownBy(() -> migrationData.search("cxf", "four", null, 3))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("fromVersion");
+        assertThatThrownBy(() -> migrationData.search("cxf", "4.22", "3.20", 
3))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("is after toVersion");
+    }
+
+    @Test
+    void snippetShowsTheLinesAroundTheMatch() {
+        List<MigrationData.SearchHit> hits = migrationData.search("camel-mail 
subject header", null, null, 1);
+
+        assertThat(hits).hasSize(1);
+        String content = hits.get(0).section().content();
+        String snippet = migrationData.snippet(hits.get(0), 10);
+
+        assertThat(content.lines().count()).isGreaterThan(10);
+        assertThat(content.lines().limit(10)).noneMatch(l -> 
l.contains("MAIL_SUBJECT"));
+        assertThat(snippet).contains("MAIL_SUBJECT").contains("lines above");
+    }
+
     @Test
     void searchRespectsLimit() {
         List<MigrationData.GuideSection> results = 
migrationData.searchGuides("camel", 2);

Review Comment:
   Done: the test searches "cxf" with limit 2 and asserts `hasSize(2)`, so it 
fails if the limit is ignored.



##########
dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationTools.java:
##########
@@ -289,53 +290,61 @@ public MigrationRecipesResult camel_migration_recipes(
     }
 
     /**
-     * Search migration guides for a specific term.
+     * Search migration guides for a specific term, optionally scoped to the 
versions being migrated between.
      */
     @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint 
= false, openWorldHint = false),
           description = "Search Camel migration and upgrade guides for a 
specific term or component name. "
-                        + "Returns matching snippets from the official guides 
with version info and URLs. "
-                        + "Supports fuzzy matching for typo tolerance. "
+                        + "Each result is one topic entry of a release (e.g. 
'Upgrading Camel 4.20 to 4.21 / camel-cxf') "
+                        + "with the lines around the best match, version info 
and URL. "
+                        + "Pass fromVersion/toVersion (e.g. 2.25 and 4.22) to 
search only the guides for that "
+                        + "migration, with the 2.x to 3.0 and 3.x to 4.0 
migration guides ranked first. "
+                        + "Results are ranked by relevance with typo 
tolerance; generic words such as camel, "
+                        + "migration or upgrade are ignored, and no results 
are returned when nothing is relevant. "
                         + "Use this instead of web search when looking up 
migration-related changes, "
                         + "removed components, API renames, or breaking 
changes.")
     public GuideSearchResult camel_migration_guide_search(
             @ToolArg(description = "Search query — component name, API class, 
method, or keyword "
-                                   + "(e.g., direct-vm, getOut, camel-http4, 
ExchangePattern)") String query,
-            @ToolArg(description = "Maximum number of results to return 
(default: 3)", required = false) Integer limit) {
+                                   + "(e.g., direct-vm, getOut, camel-http4, 
cxf servlet, camel-mail subject header)") String query,
+            @ToolArg(description = "Maximum number of results to return 
(default: 3)", required = false) Integer limit,
+            @ToolArg(description = "Camel version migrating from (e.g., 2.25 
or 3.20.1). "
+                                   + "Guides for releases up to this version 
are omitted.",
+                     required = false) String fromVersion,

Review Comment:
   Renamed to `currentVersion` / `targetVersion` like 
`camel_migration_compatibility` and `camel_migration_recipes`: tool, 
description, hint, doc row and the migration prompt (f30cc1db87fe).



##########
dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationData.java:
##########
@@ -283,87 +290,310 @@ private static String getElementText(Element parent, 
String tagName) {
     private static final String GUIDES_RESOURCE_DIR = "migration-guides/";
     private static final String GUIDES_INDEX_FILE = GUIDES_RESOURCE_DIR + 
"index.txt";
 
-    private volatile List<GuideSection> guideIndex;
+    /**
+     * Words dropped from search queries: they appear in nearly every 
migration guide entry (camel, migration, upgrade,
+     * version, guide) or carry no meaning on their own (to, from, the, ...), 
so they only add noise to the ranking.
+     */
+    static final Set<String> STOP_WORDS = Set.of(
+            "camel", "apache", "migration", "migrate", "migrating", "upgrade", 
"upgrading", "upgraded", "version",
+            "versions", "guide", "guides", "release", "change", "changes", 
"changed",
+            "a", "an", "and", "are", "as", "at", "be", "by", "do", "does", 
"for", "from", "how", "in", "is", "it", "my",
+            "of", "on", "or", "the", "this", "to", "what", "when", "which", 
"with");
+
+    /** BM25 term frequency saturation. */
+    private static final double BM25_K1 = 1.2;
+    /** BM25 length normalisation: 0 ignores the entry length, 1 fully 
normalises by it. */
+    private static final double BM25_B = 0.5;
+    /** Extra weight, in units of the term idf, when a query term appears in 
the entry heading. */
+    private static final double TITLE_BOOST = 1.5;
+    /** Weight of a part of an unknown hyphenated query term, e.g. http4 for 
camel-http4. */
+    private static final double PART_WEIGHT = 0.7;
+    /** Weight of a vocabulary term matched by Levenshtein distance instead of 
exactly (typo tolerance). */
+    private static final double FUZZY_WEIGHT = 0.7;
+    /** Weight of a vocabulary term that only contains the query term as a 
substring. */
+    private static final double SUBSTRING_WEIGHT = 0.3;
+    /** Multiplier when the whole query appears verbatim in the entry. */
+    private static final double PHRASE_BOOST = 1.25;
+    /** Entries scoring below this are considered unrelated to the query and 
are not returned. */
+    static final double MIN_SCORE = 2.5;
+
+    private volatile GuideIndex guideIndex;
 
     /**
-     * Get the guide index, loading lazily on first access.
+     * Get the guide index entries (one per {@code ===} topic of each release 
section), loading lazily on first access.
      */
-    public synchronized List<GuideSection> getGuideIndex() {
+    public List<GuideSection> getGuideIndex() {
+        return index().sections();
+    }
+
+    private synchronized GuideIndex index() {
         if (guideIndex == null) {
-            guideIndex = loadGuideSections();
+            guideIndex = buildIndex(loadGuideSections());
         }
         return guideIndex;
     }
 
     /**
-     * Search migration guides for sections matching the query using fuzzy 
matching.
-     * <p>
-     * Scoring: +10 if the full query appears verbatim in the section content. 
Per query token: +3 exact token match, +2
-     * fuzzy match (Levenshtein distance &le; max(1, minTokenLen/4)), +1 
substring fallback. Results are sorted by
-     * descending score.
+     * Search all migration guides, see {@link #search(String, String, String, 
int)}.
      */
     public List<GuideSection> searchGuides(String query, int maxResults) {
-        List<String> queryTokens = tokenize(query);
-        String lowerQuery = query.toLowerCase(Locale.ROOT);
+        return search(query, null, null, 
maxResults).stream().map(SearchHit::section).toList();
+    }
 
-        List<GuideSection> index = getGuideIndex();
-        List<ScoredSection> scored = new ArrayList<>();
+    /**
+     * Search the migration guides for the entries matching the query.
+     * <p>
+     * Each entry is one {@code ===} topic (usually one component) of one 
release section of a guide. Entries are ranked
+     * with BM25 over the query terms (stop words such as camel, migration or 
upgrade removed), with a bonus for terms
+     * in the entry heading. A query term that does not occur verbatim also 
matches vocabulary terms within a small
+     * Levenshtein distance (typos) or containing it, at a reduced weight. 
Entries matching less than half of the query
+     * terms or scoring below {@link #MIN_SCORE} are dropped, so an unrelated 
query returns no results.
+     * <p>
+     * When {@code fromVersion} and/or {@code toVersion} are given, only the 
guides for the releases after
+     * {@code fromVersion} up to {@code toVersion} are searched, and the 
entries of the major migration guides (2.x to
+     * 3.0, 3.x to 4.0) in that range are listed before the entries of the 
minor upgrade guides.
+     *
+     * @param  query                    the search query
+     * @param  fromVersion              the Camel version migrated from (e.g. 
2.25 or 3.20.1), or null
+     * @param  toVersion                the Camel version migrated to (e.g. 
4.22), or null
+     * @param  maxResults               the maximum number of results
+     * @return                          the matching entries, best first
+     * @throws IllegalArgumentException if a version cannot be parsed or 
fromVersion is after toVersion
+     */
+    public List<SearchHit> search(String query, String fromVersion, String 
toVersion, int maxResults) {
+        int[] from = parseVersion(fromVersion, "fromVersion");
+        int[] to = parseVersion(toVersion, "toVersion");
+        if (from != null && to != null && compareMajorMinor(from, to) > 0) {
+            throw new IllegalArgumentException(
+                    "fromVersion " + fromVersion + " is after toVersion " + 
toVersion);
+        }
+        boolean ranged = from != null || to != null;
 
-        for (GuideSection section : index) {
-            double score = scoreSection(section, queryTokens, lowerQuery);
-            if (score > 0) {
-                scored.add(new ScoredSection(section, score));
-            }
+        List<String> queryTerms = queryTerms(query);
+        if (queryTerms.isEmpty() || maxResults <= 0) {
+            return List.of();
         }
 
-        
scored.sort(Comparator.comparingDouble(ScoredSection::score).reversed());
+        GuideIndex index = index();
+        List<Map<String, Double>> expansions = new ArrayList<>();
+        for (String term : queryTerms) {
+            expansions.add(expandTerm(term, index));
+        }
+        String phrase = query.toLowerCase(Locale.ROOT).trim();
 
-        return scored.stream()
-                .limit(maxResults)
-                .map(ScoredSection::section)
-                .collect(Collectors.toList());
+        List<SearchHit> hits = new ArrayList<>();
+        for (GuideSection section : index.sections()) {
+            if (ranged && !inRange(section, from, to)) {
+                continue;
+            }
+            Set<String> matched = new LinkedHashSet<>();
+            double score = 0;
+            int matchedTerms = 0;
+            for (Map<String, Double> expansion : expansions) {
+                double best = 0;
+                String bestTerm = null;
+                for (Map.Entry<String, Double> variant : expansion.entrySet()) 
{
+                    double s = scoreTerm(variant.getKey(), section, index) * 
variant.getValue();
+                    if (s > best) {
+                        best = s;
+                        bestTerm = variant.getKey();
+                    }
+                }
+                if (bestTerm != null) {
+                    matched.add(bestTerm);
+                    matchedTerms++;
+                    score += best;
+                }
+            }
+            if (matchedTerms == 0 || matchedTerms * 2 < expansions.size()) {
+                // an entry matching less than half of the query is about 
something else
+                continue;
+            }
+            // favour entries covering more of the query
+            score *= 0.5 + 0.5 * matchedTerms / expansions.size();
+            if (queryTerms.size() > 1 && 
section.content().toLowerCase(Locale.ROOT).contains(phrase)) {
+                score *= PHRASE_BOOST;
+            }
+            if (score >= MIN_SCORE) {
+                hits.add(new SearchHit(section, score, List.copyOf(matched)));
+            }
+        }
+
+        Comparator<SearchHit> byScore = 
Comparator.comparingDouble(SearchHit::score).reversed();
+        if (ranged) {
+            // migrating across a major version: the 2.x to 3.0 and 3.x to 4.0 
migration guides come first
+            hits.sort(Comparator.comparing((SearchHit h) -> 
!h.section().majorMigration()).thenComparing(byScore));

Review Comment:
   Good catch, and "cxf servlet" with 2.25/4.22 is now a test. The partition is 
replaced by a score multiplier for the major-guide entries. The factor is 1.85, 
not 1.5: for the third JIRA query the 2.x-to-3.0 "Migrating Camel applications" 
entry scores 9.41 against 17.03 for the 4.21 camel-cxf note, so the break-even 
is 1.81. That margin is thin, and a later edit of the 4.21 note could flip that 
order; if you would rather drop the "major guides first" expectation and keep a 
plain 1.5, say so and I will.



##########
dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/MigrationData.java:
##########
@@ -283,87 +290,310 @@ private static String getElementText(Element parent, 
String tagName) {
     private static final String GUIDES_RESOURCE_DIR = "migration-guides/";
     private static final String GUIDES_INDEX_FILE = GUIDES_RESOURCE_DIR + 
"index.txt";
 
-    private volatile List<GuideSection> guideIndex;
+    /**
+     * Words dropped from search queries: they appear in nearly every 
migration guide entry (camel, migration, upgrade,
+     * version, guide) or carry no meaning on their own (to, from, the, ...), 
so they only add noise to the ranking.
+     */
+    static final Set<String> STOP_WORDS = Set.of(
+            "camel", "apache", "migration", "migrate", "migrating", "upgrade", 
"upgrading", "upgraded", "version",
+            "versions", "guide", "guides", "release", "change", "changes", 
"changed",
+            "a", "an", "and", "are", "as", "at", "be", "by", "do", "does", 
"for", "from", "how", "in", "is", "it", "my",
+            "of", "on", "or", "the", "this", "to", "what", "when", "which", 
"with");
+
+    /** BM25 term frequency saturation. */
+    private static final double BM25_K1 = 1.2;
+    /** BM25 length normalisation: 0 ignores the entry length, 1 fully 
normalises by it. */
+    private static final double BM25_B = 0.5;
+    /** Extra weight, in units of the term idf, when a query term appears in 
the entry heading. */
+    private static final double TITLE_BOOST = 1.5;
+    /** Weight of a part of an unknown hyphenated query term, e.g. http4 for 
camel-http4. */
+    private static final double PART_WEIGHT = 0.7;
+    /** Weight of a vocabulary term matched by Levenshtein distance instead of 
exactly (typo tolerance). */
+    private static final double FUZZY_WEIGHT = 0.7;
+    /** Weight of a vocabulary term that only contains the query term as a 
substring. */
+    private static final double SUBSTRING_WEIGHT = 0.3;
+    /** Multiplier when the whole query appears verbatim in the entry. */
+    private static final double PHRASE_BOOST = 1.25;
+    /** Entries scoring below this are considered unrelated to the query and 
are not returned. */
+    static final double MIN_SCORE = 2.5;
+
+    private volatile GuideIndex guideIndex;
 
     /**
-     * Get the guide index, loading lazily on first access.
+     * Get the guide index entries (one per {@code ===} topic of each release 
section), loading lazily on first access.
      */
-    public synchronized List<GuideSection> getGuideIndex() {
+    public List<GuideSection> getGuideIndex() {
+        return index().sections();
+    }
+
+    private synchronized GuideIndex index() {
         if (guideIndex == null) {
-            guideIndex = loadGuideSections();
+            guideIndex = buildIndex(loadGuideSections());
         }
         return guideIndex;
     }
 
     /**
-     * Search migration guides for sections matching the query using fuzzy 
matching.
-     * <p>
-     * Scoring: +10 if the full query appears verbatim in the section content. 
Per query token: +3 exact token match, +2
-     * fuzzy match (Levenshtein distance &le; max(1, minTokenLen/4)), +1 
substring fallback. Results are sorted by
-     * descending score.
+     * Search all migration guides, see {@link #search(String, String, String, 
int)}.
      */
     public List<GuideSection> searchGuides(String query, int maxResults) {
-        List<String> queryTokens = tokenize(query);
-        String lowerQuery = query.toLowerCase(Locale.ROOT);
+        return search(query, null, null, 
maxResults).stream().map(SearchHit::section).toList();
+    }
 
-        List<GuideSection> index = getGuideIndex();
-        List<ScoredSection> scored = new ArrayList<>();
+    /**
+     * Search the migration guides for the entries matching the query.
+     * <p>
+     * Each entry is one {@code ===} topic (usually one component) of one 
release section of a guide. Entries are ranked
+     * with BM25 over the query terms (stop words such as camel, migration or 
upgrade removed), with a bonus for terms
+     * in the entry heading. A query term that does not occur verbatim also 
matches vocabulary terms within a small
+     * Levenshtein distance (typos) or containing it, at a reduced weight. 
Entries matching less than half of the query
+     * terms or scoring below {@link #MIN_SCORE} are dropped, so an unrelated 
query returns no results.
+     * <p>
+     * When {@code fromVersion} and/or {@code toVersion} are given, only the 
guides for the releases after
+     * {@code fromVersion} up to {@code toVersion} are searched, and the 
entries of the major migration guides (2.x to
+     * 3.0, 3.x to 4.0) in that range are listed before the entries of the 
minor upgrade guides.
+     *
+     * @param  query                    the search query
+     * @param  fromVersion              the Camel version migrated from (e.g. 
2.25 or 3.20.1), or null
+     * @param  toVersion                the Camel version migrated to (e.g. 
4.22), or null
+     * @param  maxResults               the maximum number of results
+     * @return                          the matching entries, best first
+     * @throws IllegalArgumentException if a version cannot be parsed or 
fromVersion is after toVersion
+     */
+    public List<SearchHit> search(String query, String fromVersion, String 
toVersion, int maxResults) {
+        int[] from = parseVersion(fromVersion, "fromVersion");
+        int[] to = parseVersion(toVersion, "toVersion");
+        if (from != null && to != null && compareMajorMinor(from, to) > 0) {
+            throw new IllegalArgumentException(
+                    "fromVersion " + fromVersion + " is after toVersion " + 
toVersion);
+        }
+        boolean ranged = from != null || to != null;
 
-        for (GuideSection section : index) {
-            double score = scoreSection(section, queryTokens, lowerQuery);
-            if (score > 0) {
-                scored.add(new ScoredSection(section, score));
-            }
+        List<String> queryTerms = queryTerms(query);
+        if (queryTerms.isEmpty() || maxResults <= 0) {
+            return List.of();
         }
 
-        
scored.sort(Comparator.comparingDouble(ScoredSection::score).reversed());
+        GuideIndex index = index();
+        List<Map<String, Double>> expansions = new ArrayList<>();
+        for (String term : queryTerms) {
+            expansions.add(expandTerm(term, index));
+        }
+        String phrase = query.toLowerCase(Locale.ROOT).trim();
 
-        return scored.stream()
-                .limit(maxResults)
-                .map(ScoredSection::section)
-                .collect(Collectors.toList());
+        List<SearchHit> hits = new ArrayList<>();
+        for (GuideSection section : index.sections()) {
+            if (ranged && !inRange(section, from, to)) {
+                continue;
+            }
+            Set<String> matched = new LinkedHashSet<>();
+            double score = 0;
+            int matchedTerms = 0;
+            for (Map<String, Double> expansion : expansions) {
+                double best = 0;
+                String bestTerm = null;
+                for (Map.Entry<String, Double> variant : expansion.entrySet()) 
{
+                    double s = scoreTerm(variant.getKey(), section, index) * 
variant.getValue();
+                    if (s > best) {
+                        best = s;
+                        bestTerm = variant.getKey();
+                    }
+                }
+                if (bestTerm != null) {
+                    matched.add(bestTerm);
+                    matchedTerms++;
+                    score += best;
+                }
+            }
+            if (matchedTerms == 0 || matchedTerms * 2 < expansions.size()) {
+                // an entry matching less than half of the query is about 
something else
+                continue;
+            }
+            // favour entries covering more of the query
+            score *= 0.5 + 0.5 * matchedTerms / expansions.size();
+            if (queryTerms.size() > 1 && 
section.content().toLowerCase(Locale.ROOT).contains(phrase)) {
+                score *= PHRASE_BOOST;
+            }
+            if (score >= MIN_SCORE) {
+                hits.add(new SearchHit(section, score, List.copyOf(matched)));
+            }
+        }
+
+        Comparator<SearchHit> byScore = 
Comparator.comparingDouble(SearchHit::score).reversed();
+        if (ranged) {
+            // migrating across a major version: the 2.x to 3.0 and 3.x to 4.0 
migration guides come first
+            hits.sort(Comparator.comparing((SearchHit h) -> 
!h.section().majorMigration()).thenComparing(byScore));
+        } else {
+            hits.sort(byScore);
+        }
+        return hits.size() > maxResults ? List.copyOf(hits.subList(0, 
maxResults)) : hits;
     }
 
-    private double scoreSection(GuideSection section, List<String> 
queryTokens, String lowerQuery) {
-        double score = 0;
-        String lowerContent = section.content().toLowerCase(Locale.ROOT);
+    /**
+     * Return the lines of the entry around its best match: the line 
containing the most (idf weighted) matched terms,
+     * with some lines of context before it, and at most {@code maxLines} 
lines.
+     */
+    public String snippet(SearchHit hit, int maxLines) {
+        String[] lines = hit.section().content().split("\n");
+        if (lines.length <= maxLines) {
+            return hit.section().content();
+        }
+        GuideIndex index = index();
+        int bestLine = 0;
+        double bestScore = 0;
+        for (int i = 0; i < lines.length; i++) {
+            Set<String> lineTerms = new HashSet<>(tokenize(lines[i]));
+            double s = 0;
+            for (String term : hit.matchedTerms()) {
+                if (lineTerms.contains(term)) {
+                    s += idf(term, index);
+                }
+            }
+            if (s > bestScore) {
+                bestScore = s;
+                bestLine = i;
+            }
+        }
+        int start = Math.max(0, bestLine - maxLines / 4);
+        int end = Math.min(lines.length, start + maxLines);
+        start = Math.max(0, end - maxLines);
 
-        // Bonus for exact substring match of the full query
-        if (lowerContent.contains(lowerQuery)) {
-            score += 10;
+        StringBuilder sb = new StringBuilder();
+        if (start > 0) {
+            sb.append("... (").append(start).append(" lines above)\n");
+        }
+        for (int i = start; i < end; i++) {
+            sb.append(lines[i]).append("\n");
+        }
+        if (end < lines.length) {
+            sb.append("... (truncated, ").append(lines.length - end).append(" 
more lines)");
+        }
+        return sb.toString();
+    }
+
+    private static double scoreTerm(String term, GuideSection section, 
GuideIndex index) {
+        Integer tf = section.termFrequencies().get(term);
+        if (tf == null) {
+            return 0;
+        }
+        double idf = idf(term, index);
+        double norm = BM25_K1 * (1 - BM25_B + BM25_B * section.length() / 
index.averageLength());
+        double score = idf * tf * (BM25_K1 + 1) / (tf + norm);
+        if (section.titleTerms().contains(term)) {
+            score += idf * TITLE_BOOST;
         }
+        return score;
+    }
 
-        // Token-level scoring
-        for (String qt : queryTokens) {
-            boolean exactFound = false;
-            boolean fuzzyFound = false;
-            boolean substringFound = false;
+    private static double idf(String term, GuideIndex index) {
+        int df = index.documentFrequencies().getOrDefault(term, 0);
+        int n = index.sections().size();
+        return Math.log(1 + (n - df + 0.5) / (df + 0.5));
+    }
 
-            for (String dt : section.tokens()) {
-                if (dt.equals(qt)) {
-                    exactFound = true;
-                    break;
+    /**
+     * The vocabulary terms a query term matches, with their weight: the term 
itself and the terms containing it. When
+     * the term itself does not occur in the guides, also the parts of a 
hyphenated term (http4 for camel-http4) or else
+     * the terms within a small Levenshtein distance (typos, e.g. directvm for 
direct-vm).
+     */
+    private static Map<String, Double> expandTerm(String term, GuideIndex 
index) {
+        Set<String> vocabulary = index.documentFrequencies().keySet();
+        Map<String, Double> variants = new HashMap<>();
+        if (vocabulary.contains(term)) {
+            variants.put(term, 1.0);
+        }
+        if (variants.isEmpty() && term.indexOf('-') > 0) {
+            for (String part : term.split("-+")) {
+                if (part.length() > 1 && !STOP_WORDS.contains(part) && 
vocabulary.contains(part)) {
+                    variants.putIfAbsent(part, PART_WEIGHT);
                 }
-                int maxDist = Math.max(1, Math.min(qt.length(), dt.length()) / 
4);
-                if (levenshteinDistance(qt, dt) <= maxDist) {
-                    fuzzyFound = true;
+            }
+        }
+        if (term.length() >= 4) {
+            boolean known = !variants.isEmpty();
+            for (String candidate : vocabulary) {
+                if (candidate.equals(term)) {
+                    continue;
+                }
+                int maxDist = Math.max(1, Math.min(term.length(), 
candidate.length()) / 4);
+                if (!known && Math.abs(candidate.length() - term.length()) <= 
maxDist
+                        && levenshteinDistance(term, candidate) <= maxDist) {
+                    variants.putIfAbsent(candidate, FUZZY_WEIGHT);
+                } else if (candidate.contains(term)) {
+                    variants.putIfAbsent(candidate, SUBSTRING_WEIGHT);
                 }
             }
+        }
+        return variants;
+    }
 
-            if (!exactFound && !fuzzyFound && lowerContent.contains(qt)) {
-                substringFound = true;
+    /**
+     * The distinct search terms of a query: its words without stop words and 
bare numbers. A hyphenated word stays one
+     * term, its parts are matched as variants of it.
+     */
+    static List<String> queryTerms(String query) {
+        if (query == null) {
+            return List.of();
+        }
+        Set<String> terms = new LinkedHashSet<>();
+        for (String token : words(query)) {
+            if (!STOP_WORDS.contains(token) && 
!token.chars().allMatch(Character::isDigit)) {
+                terms.add(token);
             }
+        }
+        return new ArrayList<>(terms);
+    }
 
-            if (exactFound) {
-                score += 3;
-            } else if (fuzzyFound) {
-                score += 2;
-            } else if (substringFound) {
-                score += 1;
+    private static boolean inRange(GuideSection section, int[] from, int[] to) 
{
+        int[] version = parseVersion(section.version(), "guide version");

Review Comment:
   Fixed: a guide version that does not parse is skipped (`inRange` returns 
false) instead of failing every ranged search.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to