diff --git a/ZeroLevel.Qdrant/DataAttributes/FloatAttribute.cs b/ZeroLevel.Qdrant/DataAttributes/FloatAttribute.cs new file mode 100644 index 0000000..c9c0534 --- /dev/null +++ b/ZeroLevel.Qdrant/DataAttributes/FloatAttribute.cs @@ -0,0 +1,10 @@ +namespace ZeroLevel.Qdrant.DataAttributes +{ + /// + /// Attribute for directly specifying the 'float' data type of qdrant + /// + public class FloatAttribute + : QdrantAttribute + { + } +} diff --git a/ZeroLevel.Qdrant/DataAttributes/GeoAttribute.cs b/ZeroLevel.Qdrant/DataAttributes/GeoAttribute.cs new file mode 100644 index 0000000..85fa4f4 --- /dev/null +++ b/ZeroLevel.Qdrant/DataAttributes/GeoAttribute.cs @@ -0,0 +1,10 @@ +namespace ZeroLevel.Qdrant.DataAttributes +{ + /// + /// Attribute for directly specifying the 'geo' data type of qdrant + /// + public class GeoAttribute + : QdrantAttribute + { + } +} diff --git a/ZeroLevel.Qdrant/DataAttributes/IntegerAttribute.cs b/ZeroLevel.Qdrant/DataAttributes/IntegerAttribute.cs new file mode 100644 index 0000000..5087161 --- /dev/null +++ b/ZeroLevel.Qdrant/DataAttributes/IntegerAttribute.cs @@ -0,0 +1,10 @@ +namespace ZeroLevel.Qdrant.DataAttributes +{ + /// + /// Attribute for directly specifying the 'integer' data type of qdrant + /// + public class IntegerAttribute + : QdrantAttribute + { + } +} diff --git a/ZeroLevel.Qdrant/DataAttributes/KeywordAttribute.cs b/ZeroLevel.Qdrant/DataAttributes/KeywordAttribute.cs new file mode 100644 index 0000000..c65f10d --- /dev/null +++ b/ZeroLevel.Qdrant/DataAttributes/KeywordAttribute.cs @@ -0,0 +1,10 @@ +namespace ZeroLevel.Qdrant.DataAttributes +{ + /// + /// Attribute for directly specifying the 'keyword' data type of qdrant + /// + public class KeywordAttribute + : QdrantAttribute + { + } +} diff --git a/ZeroLevel.Qdrant/DataAttributes/QdrantAttribute.cs b/ZeroLevel.Qdrant/DataAttributes/QdrantAttribute.cs new file mode 100644 index 0000000..a464f15 --- /dev/null +++ b/ZeroLevel.Qdrant/DataAttributes/QdrantAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace ZeroLevel.Qdrant.DataAttributes +{ + /// + /// Attribute for directly specifying the data type of qdrant + /// + public abstract class QdrantAttribute + : Attribute + { + } +} diff --git a/ZeroLevel.Qdrant/DoubleExtensions.cs b/ZeroLevel.Qdrant/DoubleExtensions.cs new file mode 100644 index 0000000..1062815 --- /dev/null +++ b/ZeroLevel.Qdrant/DoubleExtensions.cs @@ -0,0 +1,10 @@ +namespace ZeroLevel.Qdrant +{ + public static class DoubleExtensions + { + public static string ConvertToString(this double num) + { + return num.ToString().Replace(',', '.'); + } + } +} diff --git a/ZeroLevel.Qdrant/Models/Filters/Condition.cs b/ZeroLevel.Qdrant/Models/Filters/Condition.cs new file mode 100644 index 0000000..4a6d865 --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Filters/Condition.cs @@ -0,0 +1,81 @@ +namespace ZeroLevel.Qdrant.Models.Filters +{ + /// + /// Condition for qdrant filters + /// + public class Condition + : Operand + { + /* + integer - 64-bit integer in the range -9223372036854775808 to 9223372036854775807. array of long + float - 64-bit floating point number. array of double + keyword - string value. array of strings + geo - Geographical coordinates. Example: { "lon": 52.5200, "lat": 13.4050 } array of lon&lat of double + */ + public string Json { get; set; } + + public static Condition Ids(long[] values) + { + return new Condition + { + Json = $"{{ \"has_id\": [{string.Join(",", values)}] }}" + }; + } + + public static Condition IntegerMatch(string name, long value) + { + return new Condition + { + Json = $"{{ \"key\": \"{name}\", \"match\": {{ \"integer\": \"{value}\" }} }}" + }; + } + + public static Condition IntegerRange(string name, long left, long rigth, bool include_left, bool include_right) + { + var left_cond = include_left ? $"\"lt\": null,\"lte\": {left}" : $"\"lt\": {left},\"lte\": null"; + var right_cond = include_right ? $"\"gt\": null,\"gte\": {rigth}" : $"\"gt\": {rigth},\"gte\": null"; + return new Condition + { + Json = $"{{ \"key\": \"{name}\", \"range\": {{ {right_cond}, {left_cond} }} }}" + }; + } + + public static Condition FloatRange(string name, double left, double rigth, bool include_left, bool include_right) + { + var left_cond = include_left ? $"\"lt\": null,\"lte\": {left.ConvertToString()}" : $"\"lt\": {left.ConvertToString()},\"lte\": null"; + var right_cond = include_right ? $"\"gt\": null,\"gte\": {rigth.ConvertToString()}" : $"\"gt\": {rigth.ConvertToString()},\"gte\": null"; + return new Condition + { + Json = $"{{ \"key\": \"{name}\", \"range\": {{ {left_cond}, {right_cond} }} }}" + }; + } + + public static Condition KeywordMatch(string name, string value) + { + return new Condition + { + Json = $"{{ \"key\": \"{name}\", \"match\": {{ \"keyword\": \"{value}\" }} }}" + }; + } + + public static Condition GeoBox(string name, Location top_left, Location bottom_right) + { + return new Condition + { + Json = $"{{ \"key\": \"{name}\", \"geo_bounding_box\": {{ \"bottom_right\": {{ \"lat\": {bottom_right.lat.ConvertToString()}, \"lon\": {bottom_right.lon.ConvertToString()} }}, \"top_left\": {{ \"lat\": {top_left.lat.ConvertToString()}, \"lon\": {top_left.lon.ConvertToString()} }} }} }}" + }; + } + + public static Condition GeoRadius(string name, Location location, double radius) + { + return new Condition + { + Json = $"{{\"key\": \"{name}\", \"geo_radius\": {{\"center\": {{ \"lat\": {location.lat.ConvertToString()}, \"lon\": {location.lon.ConvertToString()} }}, \"radius\": {radius.ConvertToString()} }} }}" + }; + } + public override string ToJSON() + { + return Json; + } + } +} diff --git a/ZeroLevel.Qdrant/Models/Filters/Filter.cs b/ZeroLevel.Qdrant/Models/Filters/Filter.cs new file mode 100644 index 0000000..b09a1e5 --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Filters/Filter.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace ZeroLevel.Qdrant.Models.Filters +{ + /// + /// Filter for search in qdrant + /// + public class Filter + { + private List _groups = new List(); + + public bool IsEmpty => _groups?.Count == 0; + + public Group AppendGroup(GroupOperator op) + { + var g = new Group(op); + _groups.Add(g); + return g; + } + + public string ToJSON() + { + var json = new StringBuilder(); + json.Append("\"filter\": {"); + json.Append(string.Join(",", _groups.Select(g => g.ToJSON()))); + json.Append("}"); + return json.ToString(); + } + } +} diff --git a/ZeroLevel.Qdrant/Models/Filters/Group.cs b/ZeroLevel.Qdrant/Models/Filters/Group.cs new file mode 100644 index 0000000..a03a9be --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Filters/Group.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; + +namespace ZeroLevel.Qdrant.Models.Filters +{ + public class Group + : Operand + { + private List _items = new List(); + public GroupOperator Operator { get; private set; } + public Group(GroupOperator op) + { + Operator = op; + } + + public Group AppendGroup(GroupOperator op) + { + var g = new Group(op); + _items.Add(g); + return g; + } + + public Group AppendCondition(Condition condition) + { + _items.Add(condition); + return this; + } + + public override string ToJSON() + { + string op; + switch (Operator) + { + case GroupOperator.MustNot: op = "must_not"; break; + case GroupOperator.Must: op = "must"; break; + default: op = "mushould"; break; + } + return $"\"{op}\": [{string.Join(",", _items.Select(i => i.ToJSON()))}]"; + } + } +} diff --git a/ZeroLevel.Qdrant/Models/Filters/GroupOperator.cs b/ZeroLevel.Qdrant/Models/Filters/GroupOperator.cs new file mode 100644 index 0000000..29008cd --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Filters/GroupOperator.cs @@ -0,0 +1,9 @@ +namespace ZeroLevel.Qdrant.Models.Filters +{ + public enum GroupOperator + { + Must, + Should, + MustNot + } +} diff --git a/ZeroLevel.Qdrant/Models/Filters/Operand.cs b/ZeroLevel.Qdrant/Models/Filters/Operand.cs new file mode 100644 index 0000000..b468880 --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Filters/Operand.cs @@ -0,0 +1,7 @@ +namespace ZeroLevel.Qdrant.Models.Filters +{ + public abstract class Operand + { + public abstract string ToJSON(); + } +} diff --git a/ZeroLevel.Qdrant/Models/Location.cs b/ZeroLevel.Qdrant/Models/Location.cs new file mode 100644 index 0000000..3b28a7d --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Location.cs @@ -0,0 +1,8 @@ +namespace ZeroLevel.Qdrant.Models +{ + public class Location + { + public double lon { get; set; } + public double lat { get; set; } + } +} diff --git a/ZeroLevel.Qdrant/Models/Requests/CreateCollectionReqeust.cs b/ZeroLevel.Qdrant/Models/Requests/CreateCollectionReqeust.cs new file mode 100644 index 0000000..9214647 --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Requests/CreateCollectionReqeust.cs @@ -0,0 +1,23 @@ +namespace ZeroLevel.Qdrant.Models.Requests +{ + internal sealed class CreateCollectionParameters + { + public string name { get; set; } + public string distance { get; set; } + public int vector_size { get; set; } + } + internal sealed class CreateCollectionReqeust + { + public CreateCollectionParameters create_collection { get; set; } + + public CreateCollectionReqeust(string name, string distance, int vector_size) + { + create_collection = new CreateCollectionParameters + { + name = name, + distance = distance, + vector_size = vector_size + }; + } + } +} diff --git a/ZeroLevel.Qdrant/Models/Requests/CreateIndexRequest.cs b/ZeroLevel.Qdrant/Models/Requests/CreateIndexRequest.cs new file mode 100644 index 0000000..663dc63 --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Requests/CreateIndexRequest.cs @@ -0,0 +1,8 @@ +namespace ZeroLevel.Qdrant.Models.Requests +{ + internal sealed class CreateIndexRequest + { + public CreateIndexRequest(string name) => create_index = name; + public string create_index { get; set; } + } +} diff --git a/ZeroLevel.Qdrant/Models/Requests/DeleteCollectionRequest.cs b/ZeroLevel.Qdrant/Models/Requests/DeleteCollectionRequest.cs new file mode 100644 index 0000000..4cfe690 --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Requests/DeleteCollectionRequest.cs @@ -0,0 +1,8 @@ +namespace ZeroLevel.Qdrant.Models.Responces +{ + internal sealed class DeleteCollectionRequest + { + public DeleteCollectionRequest(string name) => delete_collection = name; + public string delete_collection { get; set; } + } +} diff --git a/ZeroLevel.Qdrant/Models/Requests/DeletePointsRequest.cs b/ZeroLevel.Qdrant/Models/Requests/DeletePointsRequest.cs new file mode 100644 index 0000000..cfb899f --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Requests/DeletePointsRequest.cs @@ -0,0 +1,11 @@ +namespace ZeroLevel.Qdrant.Models.Requests +{ + internal sealed class DeletePoints + { + public long[] ids { get; set; } + } + internal sealed class DeletePointsRequest + { + public DeletePoints delete_points { get; set; } + } +} diff --git a/ZeroLevel.Qdrant/Models/Requests/PointsRequest.cs b/ZeroLevel.Qdrant/Models/Requests/PointsRequest.cs new file mode 100644 index 0000000..9e5cd00 --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Requests/PointsRequest.cs @@ -0,0 +1,7 @@ +namespace ZeroLevel.Qdrant.Models.Requests +{ + internal sealed class PointsRequest + { + public long[] ids { get; set; } + } +} diff --git a/ZeroLevel.Qdrant/Models/Requests/PointsUploadRequest.cs b/ZeroLevel.Qdrant/Models/Requests/PointsUploadRequest.cs new file mode 100644 index 0000000..b4b55a1 --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Requests/PointsUploadRequest.cs @@ -0,0 +1,88 @@ +using System.Linq; +using System.Text; +using ZeroLevel.Qdrant.Services; +using ZeroLevel.Services.Collections; + +namespace ZeroLevel.Qdrant.Models.Requests +{ + /* + integer - 64-bit integer in the range -9223372036854775808 to 9223372036854775807. array of long + float - 64-bit floating point number. array of double + keyword - string value. array of strings + geo - Geographical coordinates. Example: { "lon": 52.5200, "lat": 13.4050 } array of lon&lat of double + */ + + public sealed class UpsertPoint + { + public long? id { get; set; } = null; + public T payload { get; set; } + public double[] vector { get; set; } + } + public sealed class UpsertPoints + { + public UpsertPoint[] points { get; set; } + } + public sealed class PointsUploadRequest + { + private static IEverythingStorage _cachee = EverythingStorage.Create(); + public UpsertPoints upsert_points { get; set; } + + public string ToJSON() + { + if (!_cachee.ContainsKey>("converter")) + { + _cachee.Add("converter", new QdrantJsonConverter()); + } + var converter = _cachee.Get>("converter"); + + var json = new StringBuilder(); + json.Append("{"); + json.Append("\"upsert_points\": {"); + json.Append("\"points\":[ {"); + json.Append(string.Join("},{", upsert_points.points.Select(p => $"\"id\": {p.id}, \"payload\": {{ {converter.ToJson(p.payload)} }}, \"vector\": [{ string.Join(",", p.vector.Select(f => f.ConvertToString()))}]"))); + json.Append("}]"); + json.Append("}"); + json.Append("}"); + return json.ToString(); + } + } + + public sealed class ColumnPoints + { + public long[] ids { get; set; } + public T[] payloads { get; set; } + public double[,] vectors { get; set; } + } + + public sealed class UpsertColumnPoints + { + public ColumnPoints batch { get; set; } + } + + public sealed class PointsColumnUploadRequest + { + private static IEverythingStorage _cachee = EverythingStorage.Create(); + public UpsertColumnPoints upsert_points { get; set; } + + public string ToJSON() + { + if (!_cachee.ContainsKey>("converter")) + { + _cachee.Add("converter", new QdrantJsonConverter()); + } + var converter = _cachee.Get>("converter"); + + var json = new StringBuilder(); + json.Append("{"); + json.Append("\"upsert_points\": {"); + json.Append("\"batch\": {"); + json.Append($"\"ids\": [{string.Join(",", upsert_points.batch.ids)}], "); + json.Append($"\"payloads\": [ {{ {string.Join("} ,{ ", upsert_points.batch.payloads.Select(payload => converter.ToJson(payload)))} }} ], "); + json.Append($"\"vectors\": [{string.Join(",", Enumerable.Range(0, upsert_points.batch.vectors.GetLength(0)).Select(row => "[" + string.Join(",", ArrayExtensions.GetRow(upsert_points.batch.vectors, row).Select(f => f.ConvertToString())) + "]"))}]"); + json.Append("}"); + json.Append("}"); + json.Append("}"); + return json.ToString(); + } + } +} diff --git a/ZeroLevel.Qdrant/Models/Requests/SearchRequest.cs b/ZeroLevel.Qdrant/Models/Requests/SearchRequest.cs new file mode 100644 index 0000000..0df42de --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Requests/SearchRequest.cs @@ -0,0 +1,84 @@ +using ZeroLevel.Qdrant.Models.Filters; +using System; +using System.Linq; +using System.Text; + +namespace ZeroLevel.Qdrant.Models.Requests +{ + internal sealed class SearchRequest + { + /// + /// Look only for points which satisfies this conditions + /// + public Filter Filter { get; set; } + /// + /// Look for vectors closest to this + /// + public double[] FloatVector { get; set; } + public long[] IntegerVector { get; set; } + /// + /// Max number of result to return + /// + public uint Top { get; set; } + /// + /// Params relevant to HNSW index /// Size of the beam in a beam-search. Larger the value - more accurate the result, more time required for search. + /// + public uint? HNSW { get; set; } = null; + + + /* + +{ + "filter": { + "must": [ + { + "key": "city", + "match": { + "keyword": "London" + } + } + ] + }, + "params": { + "hnsw_ef": 128 + }, + "vector": [0.2, 0.1, 0.9, 0.7], + "top": 3 +} + + */ + public string ToJson() + { + var json = new StringBuilder(); + json.Append("{"); + if (Filter == null || Filter.IsEmpty) + { + json.Append("\"filter\": null,"); + } + else + { + json.Append(Filter.ToJSON()); + json.Append(','); + } + if (HNSW != null) + { + json.Append($"\"params\": {{ \"hnsw_ef\": {HNSW.Value} }},"); + } + if (FloatVector != null) + { + json.Append($"\"vector\": [{string.Join(",", FloatVector.Select(f => f.ConvertToString()))}],"); + } + else if (IntegerVector != null) + { + json.Append($"\"vector\": [{string.Join(",", IntegerVector)}],"); + } + else + { + throw new ArgumentException("No one vectors is set"); + } + json.Append($"\"top\": {Top}"); + json.Append("}"); + return json.ToString(); + } + } +} diff --git a/ZeroLevel.Qdrant/Models/Responces/CreateIndexResponse.cs b/ZeroLevel.Qdrant/Models/Responces/CreateIndexResponse.cs new file mode 100644 index 0000000..87df28b --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Responces/CreateIndexResponse.cs @@ -0,0 +1,15 @@ +namespace ZeroLevel.Qdrant.Models.Responces +{ + public sealed class IndexOperation + { + public long operation_id { get; set; } + public string status { get; set; } + } + + public sealed class CreateIndexResponse + { + public IndexOperation result { get; set; } + public string status { get; set; } + public float time { get; set; } + } +} diff --git a/ZeroLevel.Qdrant/Models/Responces/OperationResponse.cs b/ZeroLevel.Qdrant/Models/Responces/OperationResponse.cs new file mode 100644 index 0000000..db0520e --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Responces/OperationResponse.cs @@ -0,0 +1,9 @@ +namespace ZeroLevel.Qdrant.Models.Responces +{ + public sealed class OperationResponse + { + public bool result { get; set; } + public string status { get; set; } + public float time { get; set; } + } +} diff --git a/ZeroLevel.Qdrant/Models/Responces/PointResponse.cs b/ZeroLevel.Qdrant/Models/Responces/PointResponse.cs new file mode 100644 index 0000000..b932fb1 --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Responces/PointResponse.cs @@ -0,0 +1,16 @@ +namespace ZeroLevel.Qdrant.Models.Responces +{ + public sealed class Point + { + public long id { get; set; } + public dynamic payload; + public double[] vector; + } + + public sealed class PointResponse + { + public Point[] result { get; set; } + public string status { get; set; } + public float time { get; set; } + } +} diff --git a/ZeroLevel.Qdrant/Models/Responces/PointsOperationResponse.cs b/ZeroLevel.Qdrant/Models/Responces/PointsOperationResponse.cs new file mode 100644 index 0000000..b43df81 --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Responces/PointsOperationResponse.cs @@ -0,0 +1,16 @@ +namespace ZeroLevel.Qdrant.Models.Responces +{ + public sealed class PointsOperationResult + { + public long operation_id { get; set; } + public string status { get; set; } + + } + + public sealed class PointsOperationResponse + { + public PointsOperationResult result { get; set; } + public string status { get; set; } + public float time { get; set; } + } +} diff --git a/ZeroLevel.Qdrant/Models/Responces/SearchResponse.cs b/ZeroLevel.Qdrant/Models/Responces/SearchResponse.cs new file mode 100644 index 0000000..efcb437 --- /dev/null +++ b/ZeroLevel.Qdrant/Models/Responces/SearchResponse.cs @@ -0,0 +1,14 @@ +namespace ZeroLevel.Qdrant.Models.Responces +{ + public sealed class ScoredPoint + { + public long id { get; set; } + public double score { get; set; } + } + public sealed class SearchResponse + { + public ScoredPoint[] result { get; set; } + public string status { get; set; } + public float time { get; set; } + } +} diff --git a/ZeroLevel.Qdrant/QdrantClient.cs b/ZeroLevel.Qdrant/QdrantClient.cs new file mode 100644 index 0000000..0e69c40 --- /dev/null +++ b/ZeroLevel.Qdrant/QdrantClient.cs @@ -0,0 +1,277 @@ +using Newtonsoft.Json; +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using ZeroLevel.Models; +using ZeroLevel.Qdrant.Models.Filters; +using ZeroLevel.Qdrant.Models.Requests; +using ZeroLevel.Qdrant.Models.Responces; + +namespace ZeroLevel.Qdrant +{ + /* + https://qdrant.github.io/qdrant/redoc/index.html#operation/search_points + https://qdrant.tech/documentation/search/ + */ + /// + /// Client for Qdrant API + /// + public class QdrantClient + { + private HttpClient CreateClient() + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => { return true; } + }; + handler.DefaultProxyCredentials = CredentialCache.DefaultCredentials; + return new HttpClient(handler) + { + BaseAddress = _serverUri, + Timeout = TimeSpan.FromMinutes(5) + }; + } + private readonly Uri _serverUri; + public QdrantClient(string host = "localhost", int port = 6333) + { + _serverUri = new Uri($"{host}:{port}"); + } + + #region API + + #region Collection https://qdrant.tech/documentation/collections/ + /// + /// Create collection + /// + /// Collection name + /// Cosine or Dot or Euclid + /// Count of elements in vectors + /// + public async Task> CreateCollection(string name, string distance, int vector_size) + { + try + { + var collection = new CreateCollectionReqeust(name, distance, vector_size); + var json = JsonConvert.SerializeObject(collection); + var data = new StringContent(json, Encoding.UTF8, "application/json"); + var url = $"/collections"; + + var response = await _request(url, new HttpMethod("POST"), data); + return InvokeResult.Succeeding(response); + } + catch (Exception ex) + { + Log.Error(ex, $"[QdrantClient.CreateCollection] Name: {name}. Distance: {distance}. Vector size: {vector_size}"); + return InvokeResult.Fault($"[QdrantClient.CreateCollection] Name: {name}\r\n{ex.ToString()}"); + } + } + /// + /// Delete collection by name + /// + /// Collection name + public async Task> DeleteCollection(string name) + { + try + { + var collection = new DeleteCollectionRequest(name); + var json = JsonConvert.SerializeObject(collection); + var data = new StringContent(json, Encoding.UTF8, "application/json"); + var url = $"/collections"; + + var response = await _request(url, new HttpMethod("POST"), data); + return InvokeResult.Succeeding(response); + } + catch (Exception ex) + { + Log.Error(ex, $"[QdrantClient.DeleteCollection] Name: {name}."); + return InvokeResult.Fault($"[QdrantClient.DeleteCollection] Name: {name}\r\n{ex.ToString()}"); + } + } + #endregion + + #region Indexes https://qdrant.tech/documentation/indexing/ + /// + /// For indexing, it is recommended to choose the field that limits the search result the most. As a rule, the more different values a payload value has, the more efficient the index will be used. You should not create an index for Boolean fields and fields with only a few possible values. + /// + public async Task> CreateIndex(string collection_name, string field_name) + { + try + { + var index = new CreateIndexRequest(field_name); + var json = JsonConvert.SerializeObject(index); + var data = new StringContent(json, Encoding.UTF8, "application/json"); + var url = $"/collections/{collection_name}"; + + var response = await _request(url, new HttpMethod("POST"), data); + return InvokeResult.Succeeding(response); + } + catch (Exception ex) + { + Log.Error(ex, $"[QdrantClient.CreateIndex] Collection name: {collection_name}. Field name: {field_name}"); + return InvokeResult.Fault($"[QdrantClient.CreateIndex] Collection name: {collection_name}. Field name: {field_name}\r\n{ex.ToString()}"); + } + } + #endregion + + #region Search https://qdrant.tech/documentation/search/ + /// + /// Searching for the nearest vectors + /// + public async Task> Search(string collection_name, double[] vector, uint top, Filter filter = null) + { + try + { + var search = new SearchRequest { FloatVector = vector, Top = top, Filter = filter }; + var json = search.ToJson(); + var data = new StringContent(json, Encoding.UTF8, "application/json"); + var url = $"/collections/{collection_name}/points/search"; + + var response = await _request(url, new HttpMethod("POST"), data); + return InvokeResult.Succeeding(response); + } + catch (Exception ex) + { + Log.Error(ex, $"[QdrantClient.Search] Collection name: {collection_name}."); + return InvokeResult.Fault($"[QdrantClient.Search] Collection name: {collection_name}.\r\n{ex.ToString()}"); + } + } + /// + /// Searching for the nearest vectors + /// + public async Task> Search(string collection_name, long[] vector, uint top, Filter filter = null) + { + try + { + var search = new SearchRequest { IntegerVector = vector, Top = top, Filter = filter }; + var json = search.ToJson(); + var data = new StringContent(json, Encoding.UTF8, "application/json"); + var url = $"/collections/{collection_name}/points/search"; + + var response = await _request(url, new HttpMethod("POST"), data); + return InvokeResult.Succeeding(response); + } + catch (Exception ex) + { + Log.Error(ex, $"[QdrantClient.Search] Collection name: {collection_name}."); + return InvokeResult.Fault($"[QdrantClient.Search] Collection name: {collection_name}.\r\n{ex.ToString()}"); + } + } + #endregion + + #region Points https://qdrant.tech/documentation/points/ + /// + /// There is a method for retrieving points by their ids. + /// + public async Task> Points(string collection_name, long[] ids) + { + try + { + var points = new PointsRequest { ids = ids }; + var json = JsonConvert.SerializeObject(points); + var data = new StringContent(json, Encoding.UTF8, "application/json"); + var url = $"/collections/{collection_name}/points"; + + var response = await _request(url, new HttpMethod("POST"), data); + return InvokeResult.Succeeding(response); + } + catch (Exception ex) + { + Log.Error(ex, $"[QdrantClient.Points] Collection name: {collection_name}."); + return InvokeResult.Fault($"[QdrantClient.Points] Collection name: {collection_name}.\r\n{ex.ToString()}"); + } + } + /// + /// Record-oriented of creating batches + /// + public async Task> PointsUpload(string collection_name, UpsertPoint[] points) + { + try + { + var points_request = new PointsUploadRequest { upsert_points = new UpsertPoints { points = points } }; + var json = points_request.ToJSON(); + var data = new StringContent(json, Encoding.UTF8, "application/json"); + var url = $"/collections/{collection_name}"; + + var response = await _request(url, new HttpMethod("POST"), data); + return InvokeResult.Succeeding(response); + } + catch (Exception ex) + { + Log.Error(ex, $"[QdrantClient.Points] Collection name: {collection_name}."); + return InvokeResult.Fault($"[QdrantClient.Points] Collection name: {collection_name}.\r\n{ex.ToString()}"); + } + } + /// + /// Column-oriented of creating batches + /// + public async Task> PointsColumnUpload(string collection_name, ColumnPoints points) + { + try + { + var points_request = new PointsColumnUploadRequest { upsert_points = new UpsertColumnPoints { batch = points } }; + var json = points_request.ToJSON(); + var data = new StringContent(json, Encoding.UTF8, "application/json"); + var url = $"/collections/{collection_name}"; + + var response = await _request(url, new HttpMethod("POST"), data); + return InvokeResult.Succeeding(response); + } + catch (Exception ex) + { + Log.Error(ex, $"[QdrantClient.Points] Collection name: {collection_name}."); + return InvokeResult.Fault($"[QdrantClient.Points] Collection name: {collection_name}.\r\n{ex.ToString()}"); + } + } + /// + /// Delete points by their ids. + /// + public async Task> DeletePoints(string collection_name, long[] ids) + { + try + { + var points = new DeletePointsRequest { delete_points = new DeletePoints { ids = ids } }; + var json = JsonConvert.SerializeObject(points); + var data = new StringContent(json, Encoding.UTF8, "application/json"); + var url = $"/collections/{collection_name}"; + + var response = await _request(url, new HttpMethod("POST"), data); + return InvokeResult.Succeeding(response); + } + catch (Exception ex) + { + Log.Error(ex, $"[QdrantClient.DeleteCollection] Name: {collection_name}."); + return InvokeResult.Fault($"[QdrantClient.DeleteCollection] Name: {collection_name}\r\n{ex.ToString()}"); + } + } + #endregion + + #endregion + + #region Private + private async Task _request(string url, HttpMethod method, HttpContent content = null) + { + var json = await _request(url, method, content); + return JsonConvert.DeserializeObject(json); + } + + private async Task _request(string url, HttpMethod method, HttpContent content = null) + { + var fullUrl = new Uri(_serverUri, url); + var message = new HttpRequestMessage(method, fullUrl) { Content = content }; + using (var client = CreateClient()) + { + var response = await client.SendAsync(message); + var result = await response.Content.ReadAsStringAsync(); + var jsonPrint = result?.Length >= 5000 ? "" : result; + if (response.IsSuccessStatusCode == false) + { + throw new Exception($"Not SuccessStatusCode {method} {fullUrl}. Status: {response.StatusCode} {response.ReasonPhrase}. Content: {jsonPrint}"); + } + return result; + } + } + #endregion + } +} diff --git a/ZeroLevel.Qdrant/Services/QdrantJsonConverter.cs b/ZeroLevel.Qdrant/Services/QdrantJsonConverter.cs new file mode 100644 index 0000000..f17035e --- /dev/null +++ b/ZeroLevel.Qdrant/Services/QdrantJsonConverter.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using ZeroLevel.Qdrant.DataAttributes; +using ZeroLevel.Qdrant.Models; +using ZeroLevel.Services.ObjectMapping; +using ZeroLevel.Services.Reflection; +using ZeroLevel.Services.Serialization; + +namespace ZeroLevel.Qdrant.Services +{ + public class QdrantJsonConverter + { + private static string KeywordToString(IMemberInfo member, object v) + { + var text = TypeHelpers.IsString(member.ClrType) ? v as string : v.ToString(); + if (string.IsNullOrEmpty(text)) + { + return "null"; + } + else + { + return $"\"{JsonEscaper.EscapeString(text)}\""; + } + } + + /* + integer - 64-bit integer in the range -9223372036854775808 to 9223372036854775807. array of long + float - 64-bit floating point number. array of double + keyword - string value. array of strings + geo - Geographical coordinates. Example: { "lon": 52.5200, "lat": 13.4050 } array of lon&lat of double + */ + private const string KYEWORD_TYPE = "keyword"; + private const string GEO_TYPE = "geo"; + private const string FLOAT_TYPE = "float"; + private const string INTEGER_TYPE = "integer"; + public string ToJson(T value) + { + var json = new StringBuilder(); + + var map = TypeMapper.Create(); + foreach (var member in map.Members) + { + var val = member.Getter(value); + var type = KYEWORD_TYPE; + + var attributes = member.Original.GetCustomAttributes(typeof(QdrantAttribute), true); + if (attributes != null && attributes.Any()) + { + var dataAttribute = attributes[0]; + if (dataAttribute is KeywordAttribute) + { + type = KYEWORD_TYPE; + } + else if (dataAttribute is FloatAttribute) + { + type = FLOAT_TYPE; + } + else if (dataAttribute is IntegerAttribute) + { + type = INTEGER_TYPE; + } + else if (dataAttribute is GeoAttribute) + { + type = GEO_TYPE; + } + } + else + { + var item_type = member.ClrType; + // autodetect type + if (TypeHelpers.IsArray(item_type)) + { + item_type = item_type.GetElementType(); + } + else if (TypeHelpers.IsEnumerable(item_type)) + { + item_type = TypeHelpers.GetElementTypeOfEnumerable(item_type); + } + if (item_type == typeof(float) || item_type == typeof(double) || item_type == typeof(decimal)) + { + type = FLOAT_TYPE; + } + else if (item_type == typeof(int) || item_type == typeof(long) || item_type == typeof(byte) || + item_type == typeof(short) || item_type == typeof(uint) || item_type == typeof(ulong) || + item_type == typeof(ushort)) + { + type = INTEGER_TYPE; + } + else if (item_type == typeof(Location)) + { + type = GEO_TYPE; + } + } + switch (type) + { + case KYEWORD_TYPE: + if (TypeHelpers.IsEnumerable(member.ClrType) && TypeHelpers.IsString(member.ClrType) == false) + { + var arr = val as IEnumerable; + json.Append($"\"{member.Name}\": {{ \"type\": \"keyword\", \"value\": [ {string.Join(", ", E(arr).Select(v => KeywordToString(member, v)))}] }},"); + } + else + { + json.Append($"\"{member.Name}\": {{ \"type\": \"keyword\", \"value\":{KeywordToString(member, val)} }},"); + } + break; + case GEO_TYPE: + if (TypeHelpers.IsEnumerable(member.ClrType) && TypeHelpers.IsString(member.ClrType) == false) + { + var arr = val as IEnumerable; + json.Append($"\"{member.Name}\": {{ \"type\": \"geo\", \"value\": [ {string.Join(",", E(arr).Select(v => v as Location).Where(l => l != null).Select(l => $" {{ \"lon\":{l.lon.ConvertToString()}, \"lat\":{l.lat.ConvertToString()} }}"))}] }},"); + } + else + { + Location l = val as Location; + if (l != null) + { + json.Append($"\"{member.Name}\": {{ \"type\": \"geo\", \"value\": {{ \"lon\":{l.lon.ConvertToString()}, \"lat\":{l.lat.ConvertToString()} }} }},"); + } + } + break; + case FLOAT_TYPE: + if (TypeHelpers.IsEnumerable(member.ClrType) && TypeHelpers.IsString(member.ClrType) == false) + { + var arr = val as IEnumerable; + json.Append($"\"{member.Name}\": {{ \"type\": \"float\", \"value\": [ {string.Join(",", E(arr).Select(v => Convert.ToDouble(v).ConvertToString()))}] }},"); + } + else + { + + json.Append($"\"{member.Name}\": {{ \"type\": \"float\", \"value\": {Convert.ToDouble(val).ConvertToString()} }},"); + } + break; + case INTEGER_TYPE: + if (TypeHelpers.IsEnumerable(member.ClrType) && TypeHelpers.IsString(member.ClrType) == false) + { + var arr = val as IEnumerable; + json.Append($"\"{member.Name}\": {{ \"type\": \"integer\", \"value\": [ {string.Join(",", E(arr).Select(v => Convert.ToInt64(v)))}] }},"); + } + else + { + json.Append($"\"{member.Name}\": {{ \"type\": \"integer\", \"value\": {Convert.ToInt64(val)} }},"); + } + break; + } + } + if (json[json.Length - 1] == ',') + { + json.Remove(json.Length - 1, 1); + } + return json.ToString(); + } + + public IEnumerable E(IEnumerable e) + { + if (e != null) + { + foreach (var i in e) + { + yield return i; + } + } + } + } +} diff --git a/ZeroLevel.Qdrant/ZeroLevel.Qdrant.csproj b/ZeroLevel.Qdrant/ZeroLevel.Qdrant.csproj new file mode 100644 index 0000000..6eb3b75 --- /dev/null +++ b/ZeroLevel.Qdrant/ZeroLevel.Qdrant.csproj @@ -0,0 +1,29 @@ + + + + netstandard2.1 + AnyCPU;x64;x86 + 1.0.0.0 + ogoun + Copyright Ogoun 2021 + https://github.com/ogoun/Zero + zero.png + + + + + + + + + + + + + + True + + + + + diff --git a/ZeroLevel.sln b/ZeroLevel.sln index 79474d0..6534974 100644 --- a/ZeroLevel.sln +++ b/ZeroLevel.sln @@ -57,6 +57,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Client", "ConnectionTest\Cl EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Server", "ConnectionTest\Server\Server.csproj", "{3496A688-0749-48C2-BD60-ABB42A5C17C9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ZeroLevel.Qdrant", "ZeroLevel.Qdrant\ZeroLevel.Qdrant.csproj", "{7188B89E-96EB-4EFB-AAFB-D0A823031F99}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -271,6 +273,18 @@ Global {3496A688-0749-48C2-BD60-ABB42A5C17C9}.Release|x64.Build.0 = Release|x64 {3496A688-0749-48C2-BD60-ABB42A5C17C9}.Release|x86.ActiveCfg = Release|x86 {3496A688-0749-48C2-BD60-ABB42A5C17C9}.Release|x86.Build.0 = Release|x86 + {7188B89E-96EB-4EFB-AAFB-D0A823031F99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7188B89E-96EB-4EFB-AAFB-D0A823031F99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7188B89E-96EB-4EFB-AAFB-D0A823031F99}.Debug|x64.ActiveCfg = Debug|x64 + {7188B89E-96EB-4EFB-AAFB-D0A823031F99}.Debug|x64.Build.0 = Debug|x64 + {7188B89E-96EB-4EFB-AAFB-D0A823031F99}.Debug|x86.ActiveCfg = Debug|x86 + {7188B89E-96EB-4EFB-AAFB-D0A823031F99}.Debug|x86.Build.0 = Debug|x86 + {7188B89E-96EB-4EFB-AAFB-D0A823031F99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7188B89E-96EB-4EFB-AAFB-D0A823031F99}.Release|Any CPU.Build.0 = Release|Any CPU + {7188B89E-96EB-4EFB-AAFB-D0A823031F99}.Release|x64.ActiveCfg = Release|x64 + {7188B89E-96EB-4EFB-AAFB-D0A823031F99}.Release|x64.Build.0 = Release|x64 + {7188B89E-96EB-4EFB-AAFB-D0A823031F99}.Release|x86.ActiveCfg = Release|x86 + {7188B89E-96EB-4EFB-AAFB-D0A823031F99}.Release|x86.Build.0 = Release|x86 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE