Repository: vespa-engine/vespa (language: java)

Issue:
[Schema streaming mode] dotProduct function is ignored in itemRawScore calulation
**Describe the bug**
`dotProduct` function is ignored in doc score calulation 

**To Reproduce**
Given schema mode ="streaming" and is defined as 
```
schema contact {
    document contact {
       field test_field type weightedset<string>  {
            indexing: summary | index
            weightedset {
                create-if-nonexistent
                remove-if-zero
            }
            match: exact
            rank: normal
        }
    }
    
    rank-profile contact_default inherits default {

        first-phase {
            expression {
                itemRawScore(test_field)
            }
        }
    }
}
```
And document indexed as :
```
{
    "fields": {
        "test_field": {"computer": 1000, "compute": 800}
    }
}
```
Then the following search query returns the doc with relevance=0:
```
   "yql": "select * from contact where [{'label': 'test_field'}] dotProduct(test_field, {'computer': 1})",
    "ranking.profile": "contact_default",
```   

**Expected behavior**
The doc above should be returned with relevance=1000 (as it behaves in "index" mode)



**Environment (please complete the following information):**
docker image: vespaengine/vespa:8.263.7

**Vespa version**
8.263.7

**Additional context**
Issues might be reproduced within the app package attached.
[vespa-streaming.zip](https://github.com/vespa-engine/vespa/files/13500571/vespa-streaming.zip)
Indexing request:
```
curl --location 'http://localhost:8080/document/v1/mp/contact/group/test_group/docid/555561' \
--header 'Content-Type: application/json' \
--data '{
    "fields": {
        "test_field": {"computer": 1000, "compute": 800}
    }
}
'
```
Search request:
```
curl --location 'http://localhost:8080/search/' \
--header 'Content-Type: application/json' \
--data '{
    "user": "ak",
     "yql": "select * from contact where [{'\''label'\'': '\''test_field'\''}] dotProduct(test_field, {'\''computer'\'': 1})",
     "ranking.profile": "contact_default",
     "streaming.groupname": "test_group"

}'
```


Discussion / hints:
@kkraune there was also reported  https://github.com/vespa-engine/vespa/issues/29514 in the context of "streaming search". It might be related. thx
Using `itemRawScore` together with the `dotProduct` operator is currently not supported in mode streaming (as you have observed). There is no quick fix for this, and requires some work on how this operator is implemented in mode streaming. 
Please consider using tensors for calculating dot products as a work-around for this issue. See for example https://docs.vespa.ai/en/tensor-examples.html#dot-product-between-query-and-document-vectors
@jobergum thx, the suggested approach works, but looks like it's limited to bounded tensors only.
In our case, `dotProduct`  should be applied to:
```
query(query_vector)  =  {{x:5}: 2}
document_vector = {{x:1}: 300, {x:5}: 1000}}
```
As a result, dotProduct should result into score/relevance = 2000.

Tried out the definition below, but it's not working.
Am I missing anything here?


```
schema example {
    document example {
        field document_vector type tensor<float>(x{}) {
            indexing: attribute | summary 
        }

    }

      rank-profile dot_product {
        inputs {
            query(query_vector) tensor<float>(x{})
        }
        first-phase {
            expression: sum(query(query_vector)*attribute(document_vector))
        }
    }

}
```
Not sure what you mean by bound tensors, but you can perform the sparse or dense dot product. Here using a mapped tensor. 

```
schema mail {
    document mail {
        field features type tensor<float>(x{}) {
            indexing: summary | attribute
        }
    }
    rank-profile dot {
      inputs {
        query(q_features) tensor<float>(x{})
      }
      first-phase {
        expression: sum(query(q_features) * attribute(features))
      }
    }
}
```

### feed

```json { "id": "id:mail:mail:g=bergum@vespa.ai:0", "fields": { "features": { "foo":1}}}```

### query 

```
vespa query 'yql=select * from sources mail where true' 'streaming.groupname=bergum@vespa.ai' 'input.query(q_features)={foo:2}' 'ranking=dot' 'presentation.format.tensors=short-value'   
{
    "root": {
        "id": "toplevel",
        "relevance": 1.0,
        "fields": {
            "totalCount": 1
        },
        "coverage": {
            "coverage": 100,
            "documents": 1,
            "full": true,
            "nodes": 1,
            "results": 1,
            "resultsFull": 1
        },
        "children": [
            {
                "id": "id:mail:mail:g=bergum@vespa.ai:0",
                "relevance": 2.0,
                "source": "assistant_content.mail",
                "fields": {
                    "sddocname": "mail",
                    "documentid": "id:mail:mail:g=bergum@vespa.ai:0",
                    "features": {
                        "foo": 1.0
                    }
                }
            }
        ]
    }
}
```
Confirmed. it works. Thank you!

Note: it was not working on my end due to    `ranking.listFeatures=true` parameter.
Try out:
```
vespa query 'yql=select * from  mail where true' 'streaming.groupname=test_group' 'ranking.features.query(q_features)={"foo": 2}' 'ranking.profile=dot' 'ranking.listFeatures=true'

{
    "root": {
        "id": "toplevel",
        "relevance": 1.0,
        "fields": {
            "totalCount": 0
        },
        "coverage": {
            "coverage": 100,
            "documents": 1,
            "full": true,
            "nodes": 1,
            "results": 1,
            "resultsFull": 1
        }
    }
}
```
while `ranking.listFeatures=false` (or skipped)  returns the response above



Thanks, created https://github.com/vespa-engine/vespa/issues/29588 on that. 

Fix patch:
diff --git a/searchlib/src/vespa/searchlib/query/streaming/CMakeLists.txt b/searchlib/src/vespa/searchlib/query/streaming/CMakeLists.txt
index 7f57f4d15d1e..0813292a9da9 100644
--- a/searchlib/src/vespa/searchlib/query/streaming/CMakeLists.txt
+++ b/searchlib/src/vespa/searchlib/query/streaming/CMakeLists.txt
@@ -1,6 +1,7 @@
 # Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
 vespa_add_library(searchlib_query_streaming OBJECT
     SOURCES
+    dot_product_term.cpp
     in_term.cpp
     multi_term.cpp
     nearest_neighbor_query_node.cpp
diff --git a/searchlib/src/vespa/searchlib/query/streaming/dot_product_term.cpp b/searchlib/src/vespa/searchlib/query/streaming/dot_product_term.cpp
new file mode 100644
index 000000000000..d2c1ba872f53
--- /dev/null
+++ b/searchlib/src/vespa/searchlib/query/streaming/dot_product_term.cpp
@@ -0,0 +1,46 @@
+// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+#include "dot_product_term.h"
+#include <vespa/searchlib/fef/itermdata.h>
+#include <vespa/searchlib/fef/matchdata.h>
+#include <vespa/vespalib/stllike/hash_map.h>
+
+using search::fef::ITermData;
+using search::fef::MatchData;
+
+namespace search::streaming {
+
+DotProductTerm::DotProductTerm(std::unique_ptr<QueryNodeResultBase> result_base, const string & index, uint32_t num_terms)
+    : MultiTerm(std::move(result_base), index, Type::WORD, num_terms)
+{
+}
+
+DotProductTerm::~DotProductTerm() = default;
+
+void
+DotProductTerm::unpack_match_data(uint32_t docid, const ITermData& td, MatchData& match_data)
+{
+    vespalib::hash_map<uint32_t,double> scores;
+    HitList hl_store;
+    for (const auto& term : _terms) {
+        auto& hl = term->evaluateHits(hl_store);
+        for (auto& hit : hl) {
+            scores[hit.context()] += term->weight().percent() * hit.weight();
+        }
+    }
+    auto num_fields = td.numFields();
+    for (uint32_t field_idx = 0; field_idx < num_fields; ++field_idx) {
+        auto& tfd = td.field(field_idx);
+        auto field_id = tfd.getFieldId();
+        if (scores.contains(field_id)) {
+            auto handle = tfd.getHandle();
+            if (handle != fef::IllegalHandle) {
+                auto tmd = match_data.resolveTermField(tfd.getHandle());
+                tmd->setFieldId(field_id);
+                tmd->setRawScore(docid, scores[field_id]);
+            }
+        }
+    }
+}
+
+}
diff --git a/searchlib/src/vespa/searchlib/query/streaming/dot_product_term.h b/searchlib/src/vespa/searchlib/query/streaming/dot_product_term.h
new file mode 100644
index 000000000000..77cac693781c
--- /dev/null
+++ b/searchlib/src/vespa/searchlib/query/streaming/dot_product_term.h
@@ -0,0 +1,19 @@
+// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+#pragma once
+
+#include "multi_term.h"
+
+namespace search::streaming {
+
+/*
+ * A dot product query term for streaming search.
+ */
+class DotProductTerm : public MultiTerm {
+public:
+    DotProductTerm(std::unique_ptr<QueryNodeResultBase> result_base, const string& index, uint32_t num_terms);
+    ~DotProductTerm() override;
+    void unpack_match_data(uint32_t docid, const fef::ITermData& td, fef::MatchData& match_data) override;
+};
+
+}
diff --git a/searchlib/src/vespa/searchlib/query/streaming/query.cpp b/searchlib/src/vespa/searchlib/query/streaming/query.cpp
index d1ab5ea24d6c..d2eee5d345fb 100644
--- a/searchlib/src/vespa/searchlib/query/streaming/query.cpp
+++ b/searchlib/src/vespa/searchlib/query/streaming/query.cpp
@@ -109,7 +109,6 @@ QueryConnector::create(ParseItem::ItemType type)
         case search::ParseItem::ITEM_WEAK_AND:     return std::make_unique<OrQueryNode>();
         case search::ParseItem::ITEM_EQUIV:        return std::make_unique<EquivQueryNode>();
         case search::ParseItem::ITEM_WEIGHTED_SET: return std::make_unique<EquivQueryNode>();
-        case search::ParseItem::ITEM_DOT_PRODUCT:  return std::make_unique<OrQueryNode>();
         case search::ParseItem::ITEM_WAND:         return std::make_unique<OrQueryNode>();
         case search::ParseItem::ITEM_NOT:          return std::make_unique<AndNotQueryNode>();
         case search::ParseItem::ITEM_PHRASE:       return std::make_unique<PhraseQueryNode>();
diff --git a/searchlib/src/vespa/searchlib/query/streaming/querynode.cpp b/searchlib/src/vespa/searchlib/query/streaming/querynode.cpp
index 7e7d1f1f2608..db0fbd5b98ed 100644
--- a/searchlib/src/vespa/searchlib/query/streaming/querynode.cpp
+++ b/searchlib/src/vespa/searchlib/query/streaming/querynode.cpp
@@ -3,6 +3,7 @@
 #include "query.h"
 #include "nearest_neighbor_query_node.h"
 #include <vespa/searchlib/parsequery/stackdumpiterator.h>
+#include <vespa/searchlib/query/streaming/dot_product_term.h>
 #include <vespa/searchlib/query/streaming/in_term.h>
 #include <vespa/searchlib/query/tree/term_vector.h>
 #include <charconv>
@@ -33,7 +34,6 @@ QueryNode::Build(const QueryNode * parent, const QueryNodeResultFactory & factor
     case ParseItem::ITEM_WEAK_AND:
     case ParseItem::ITEM_EQUIV:
     case ParseItem::ITEM_WEIGHTED_SET:
-    case ParseItem::ITEM_DOT_PRODUCT:
     case ParseItem::ITEM_WAND:
     case ParseItem::ITEM_NOT:
     case ParseItem::ITEM_PHRASE:
@@ -184,14 +184,12 @@ QueryNode::Build(const QueryNode * parent, const QueryNodeResultFactory & factor
     case ParseItem::ITEM_NUMERIC_IN:
         qn = std::make_unique<InTerm>(factory.create(), queryRep.getIndexName(), queryRep.get_terms());
         break;
+    case ParseItem::ITEM_DOT_PRODUCT:
+        qn = build_dot_product_term(factory, queryRep);
+        break;
     default:
-    {
-        for (uint32_t skipCount = arity; (skipCount > 0) && queryRep.next(); skipCount--) {
-            skipCount += queryRep.getArity();
-            LOG(warning, "Does not understand anything,.... skipping %d", type);
-        }
-    }
-    break;
+        skip_unknown(queryRep);
+        break;
     }
     return qn;
 }
@@ -219,4 +217,54 @@ QueryNode::build_nearest_neighbor_query_node(const QueryNodeResultFactory& facto
                                                       weight);
 }
 
+void
+QueryNode::populate_multi_term(MultiTerm& mt, SimpleQueryStackDumpIterator& queryRep)
+{
+    char buf[24];
+    vespalib::string subterm;
+    auto arity = queryRep.getArity();
+    for (size_t i = 0; i < arity && queryRep.next(); i++) {
+        std::unique_ptr<QueryTerm> term;
+        switch (queryRep.getType()) {
+        case ParseItem::ITEM_PURE_WEIGHTED_STRING:
+            term = std::make_unique<QueryTerm>(std::unique_ptr<QueryNodeResultBase>(), queryRep.getTerm(), "", QueryTermSimple::Type::WORD);
+            break;
+        case ParseItem::ITEM_PURE_WEIGHTED_LONG:
+        {
+            auto res = std::to_chars(buf, buf + sizeof(buf), queryRep.getIntergerTerm(), 10);
+            subterm.assign(buf, res.ptr - buf);
+            term = std::make_unique<QueryTerm>(std::unique_ptr<QueryNodeResultBase>(), subterm, "", QueryTermSimple::Type::WORD);
+        }
+        break;
+        default:
+            skip_unknown(queryRep);
+            break;
+        }
+        if (term) {
+            term->setWeight(queryRep.GetWeight());
+            mt.add_term(std::move(term));
+        }
+    }
+}
+
+std::unique_ptr<QueryNode>
+QueryNode::build_dot_product_term(const QueryNodeResultFactory& factory, SimpleQueryStackDumpIterator& queryRep)
+{
+    auto dp =std::make_unique<DotProductTerm>(factory.create(), queryRep.getIndexName(), queryRep.getArity());
+    dp->setWeight(queryRep.GetWeight());
+    dp->setUniqueId(queryRep.getUniqueId());
+    populate_multi_term(*dp, queryRep);
+    return dp;
+}
+
+void
+QueryNode::skip_unknown(SimpleQueryStackDumpIterator& queryRep)
+{
+    auto type = queryRep.getType();
+    for (uint32_t skipCount = queryRep.getArity(); (skipCount > 0) && queryRep.next(); skipCount--) {
+        skipCount += queryRep.getArity();
+        LOG(warning, "Does not understand anything,.... skipping %d", type);
+    }
+}
+
 }
diff --git a/searchlib/src/vespa/searchlib/query/streaming/querynode.h b/searchlib/src/vespa/searchlib/query/streaming/querynode.h
index 36abc2f7db53..09c44d951d39 100644
--- a/searchlib/src/vespa/searchlib/query/streaming/querynode.h
+++ b/searchlib/src/vespa/searchlib/query/streaming/querynode.h
@@ -9,6 +9,7 @@ namespace search { class SimpleQueryStackDumpIterator; }
 
 namespace search::streaming {
 
+class MultiTerm;
 class QueryTerm;
 class QueryNode;
 class QueryNodeResultFactory;
@@ -29,6 +30,9 @@ using ConstQueryTermList = std::vector<const QueryTerm *>;
 class QueryNode
 {
     static std::unique_ptr<QueryNode> build_nearest_neighbor_query_node(const QueryNodeResultFactory& factory, SimpleQueryStackDumpIterator& queryRep);
+    static void populate_multi_term(MultiTerm& mt, SimpleQueryStackDumpIterator& queryRep);
+    static std::unique_ptr<QueryNode> build_dot_product_term(const QueryNodeResultFactory& factory, SimpleQueryStackDumpIterator& queryRep);
+    static void skip_unknown(SimpleQueryStackDumpIterator& queryRep);
  public:
   using UP = std::unique_ptr<QueryNode>;
 
diff --git a/vespalib/src/vespa/vespalib/stllike/hash_map.cpp b/vespalib/src/vespa/vespalib/stllike/hash_map.cpp
index f1b659a08147..482deec31651 100644
--- a/vespalib/src/vespa/vespalib/stllike/hash_map.cpp
+++ b/vespalib/src/vespa/vespalib/stllike/hash_map.cpp
@@ -20,6 +20,7 @@ VESPALIB_HASH_MAP_INSTANTIATE(uint16_t, uint16_t);
 VESPALIB_HASH_MAP_INSTANTIATE(uint16_t, uint32_t);
 VESPALIB_HASH_MAP_INSTANTIATE(uint32_t, int32_t);
 VESPALIB_HASH_MAP_INSTANTIATE(uint32_t, uint32_t);
+VESPALIB_HASH_MAP_INSTANTIATE(uint32_t, double);
 VESPALIB_HASH_MAP_INSTANTIATE(uint64_t, uint32_t);
 VESPALIB_HASH_MAP_INSTANTIATE(uint64_t, uint64_t);
 VESPALIB_HASH_MAP_INSTANTIATE(uint64_t, bool);


Test patch:
diff --git a/searchlib/src/tests/query/streaming_query_test.cpp b/searchlib/src/tests/query/streaming_query_test.cpp
index 02416d0fa56e..306456518b76 100644
--- a/searchlib/src/tests/query/streaming_query_test.cpp
+++ b/searchlib/src/tests/query/streaming_query_test.cpp
@@ -2,6 +2,7 @@
 
 #include <vespa/searchlib/fef/simpletermdata.h>
 #include <vespa/searchlib/fef/matchdata.h>
+#include <vespa/searchlib/query/streaming/dot_product_term.h>
 #include <vespa/searchlib/query/streaming/in_term.h>
 #include <vespa/searchlib/query/streaming/query.h>
 #include <vespa/searchlib/query/streaming/nearest_neighbor_query_node.h>
@@ -897,6 +898,37 @@ TEST(StreamingQueryTest, test_in_term)
     EXPECT_EQ(23, tmd2->getDocId());
 }
 
+TEST(StreamingQueryTest, dot_product_term)
+{
+    search::streaming::DotProductTerm term({}, "index", 2);
+    term.add_term(std::make_unique<QueryTerm>(std::unique_ptr<QueryNodeResultBase>(), "7", "", QueryTermSimple::Type::WORD));
+    term.get_terms().back()->setWeight(Weight(27));
+    term.add_term(std::make_unique<QueryTerm>(std::unique_ptr<QueryNodeResultBase>(), "9", "", QueryTermSimple::Type::WORD));
+    term.get_terms().back()->setWeight(Weight(2));
+    EXPECT_EQ(2, term.get_terms().size());
+    SimpleTermData td;
+    td.addField(10);
+    td.addField(11);
+    td.addField(12);
+    td.lookupField(10)->setHandle(0);
+    td.lookupField(12)->setHandle(1);
+    EXPECT_FALSE(term.evaluate());
+    auto& q0 = *term.get_terms()[0];
+    q0.add(0, 11, 0, -13);
+    q0.add(0, 12, 0, -17);
+    auto& q1 = *term.get_terms()[1];
+    q1.add(0, 11, 0, 4);
+    q1.add(0, 12, 0, 9);
+    EXPECT_TRUE(term.evaluate());
+    MatchData md(MatchData::params().numTermFields(2));
+    term.unpack_match_data(23, td, md);
+    auto tmd0 = md.resolveTermField(0);
+    EXPECT_NE(23, tmd0->getDocId());
+    auto tmd1 = md.resolveTermField(1);
+    EXPECT_EQ(23, tmd1->getDocId());
+    EXPECT_EQ(-17 * 27 + 9 * 2, tmd1->getRawScore());
+}
+
 TEST(StreamingQueryTest, control_the_size_of_query_terms)
 {
     EXPECT_EQ(112u, sizeof(QueryTermSimple));