Java ile Gerçek Dünya Projesi — E-Commerce Search
Giriş — Teoriyi Pratiğe Dönüştürme Zamanı
8 bölüm boyunca Elasticsearch'ün her yönünü öğrendiniz. Ama tüm parçalar ayrı ayrı öğrenildi. Bu derste sıfırdan bir e-ticaret arama API'si kuracağız — multi-field arama, faceted filtering, autocomplete, sorting, pagination hepsi bir arada, tam çalışan bir proje.
1. Maven Bağımlılıkları
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.2</version>
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.12.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>jakarta.json</groupId>
<artifactId>jakarta.json-api</artifactId>
<version>2.1.3</version>
</dependency>
<dependency>
<groupId>org.eclipse.parsson</groupId>
<artifactId>parsson</artifactId>
<version>1.1.5</version>
</dependency>
</dependencies>2. Elasticsearch Konfigürasyonu
package com.example.search.config;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ElasticsearchConfig {
@Value("${elasticsearch.host:localhost}")
private String host;
@Value("${elasticsearch.port:9200}")
private int port;
@Bean
public RestClient restClient() {
return RestClient.builder(new HttpHost(host, port, "http"))
.setRequestConfigCallback(c -> c.setConnectTimeout(5000).setSocketTimeout(30000))
.build();
}
@Bean
public ElasticsearchClient elasticsearchClient(RestClient restClient) {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return new ElasticsearchClient(
new RestClientTransport(restClient, new JacksonJsonpMapper(mapper))
);
}
}3. Product Entity
package com.example.search.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@JsonIgnoreProperties(ignoreUnknown = true)
public class Product {
private String id;
private String title;
private String description;
private String brand;
private String category;
@JsonProperty("sub_category") private String subCategory;
private double price;
@JsonProperty("original_price") private double originalPrice;
private double rating;
@JsonProperty("review_count") private int reviewCount;
@JsonProperty("in_stock") private boolean inStock;
private List<String> tags;
private List<String> colors;
@JsonProperty("image_url") private String imageUrl;
@JsonProperty("created_at") private LocalDateTime createdAt;
private Map<String, Object> suggest;
public Product() {}
// Tüm field'lar için getter/setter — IDE ile otomatik oluşturun
// IntelliJ: Alt+Insert → Getter and Setter → Select All
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String t) { this.title = t; }
public String getBrand() { return brand; }
public void setBrand(String b) { this.brand = b; }
public String getCategory() { return category; }
public void setCategory(String c) { this.category = c; }
public double getPrice() { return price; }
public void setPrice(double p) { this.price = p; }
public double getRating() { return rating; }
public void setRating(double r) { this.rating = r; }
public boolean isInStock() { return inStock; }
public void setInStock(boolean s) { this.inStock = s; }
// ... diğer getter/setter'lar: description, subCategory, originalPrice,
// reviewCount, tags, colors, imageUrl, createdAt, suggest
}4. Index Oluşturma Servisi
package com.example.search.service;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import com.example.search.model.Product;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.util.List;
@Service
public class ProductIndexService {
private static final String INDEX = "products_v1";
private final ElasticsearchClient client;
public ProductIndexService(ElasticsearchClient client) {
this.client = client;
}
@PostConstruct
public void initIndex() throws IOException {
if (!client.indices().exists(e -> e.index(INDEX)).value()) {
createIndex();
}
}
public void createIndex() throws IOException {
client.indices().create(c -> c
.index(INDEX)
.settings(s -> s
.numberOfShards("3").numberOfReplicas("1")
.analysis(a -> a
.charFilter("diacritics_map", cf -> cf.definition(d -> d
.mapping(m -> m.mappings(
"ş => s", "Ş => S", "ç => c", "Ç => C",
"ğ => g", "Ğ => G", "ü => u", "Ü => U",
"ö => o", "Ö => O", "ı => i"
))
))
.filter("tr_lower", f -> f.definition(d -> d
.lowercase(lc -> lc.language("turkish"))))
.filter("tr_stop", f -> f.definition(d -> d
.stop(st -> st.stopwords("_turkish_"))))
.filter("tr_stem", f -> f.definition(d -> d
.stemmer(st -> st.language("turkish"))))
.filter("apostrophe", f -> f.definition(d -> d
.apostrophe(ap -> ap)))
.filter("autocomplete_ngram", f -> f.definition(d -> d
.edgeNgram(e -> e.minGram(2).maxGram(15))))
.analyzer("tr_index", an -> an.custom(cu -> cu
.tokenizer("standard")
.filter("apostrophe", "tr_lower", "tr_stop", "tr_stem")))
.analyzer("tr_search", an -> an.custom(cu -> cu
.tokenizer("standard")
.filter("apostrophe", "tr_lower", "tr_stop", "tr_stem")))
.analyzer("folded", an -> an.custom(cu -> cu
.tokenizer("standard").charFilter("diacritics_map")
.filter("lowercase", "tr_stop", "tr_stem")))
.analyzer("auto_index", an -> an.custom(cu -> cu
.tokenizer("standard")
.filter("tr_lower", "autocomplete_ngram")))
.analyzer("auto_search", an -> an.custom(cu -> cu
.tokenizer("standard").filter("tr_lower")))
)
)
.mappings(m -> m
.properties("title", p -> p.text(t -> t
.analyzer("tr_index").searchAnalyzer("tr_search")
.fields("folded", f -> f.text(tx -> tx.analyzer("folded")))
.fields("autocomplete", f -> f.text(tx -> tx
.analyzer("auto_index").searchAnalyzer("auto_search")))
.fields("keyword", f -> f.keyword(k -> k))
))
.properties("description", p -> p.text(t -> t
.analyzer("tr_index").searchAnalyzer("tr_search")
.fields("folded", f -> f.text(tx -> tx.analyzer("folded")))
))
.properties("brand", p -> p.text(t -> t.analyzer("tr_index")
.fields("keyword", f -> f.keyword(k -> k))))
.properties("category", p -> p.keyword(k -> k))
.properties("sub_category", p -> p.keyword(k -> k))
.properties("price", p -> p.float_(f -> f))
.properties("original_price", p -> p.float_(f -> f))
.properties("rating", p -> p.float_(f -> f))
.properties("review_count", p -> p.integer(i -> i))
.properties("in_stock", p -> p.boolean_(b -> b))
.properties("tags", p -> p.keyword(k -> k))
.properties("colors", p -> p.keyword(k -> k))
.properties("image_url", p -> p.keyword(k -> k.index(false)))
.properties("created_at", p -> p.date(d -> d))
.properties("suggest", p -> p.completion(comp -> comp
.analyzer("auto_search")))
)
);
}
public void bulkIndex(List<Product> products) throws IOException {
var builder = new co.elastic.clients.elasticsearch.core.BulkRequest.Builder();
for (Product p : products) {
builder.operations(op -> op.index(idx -> idx
.index(INDEX).id(p.getId()).document(p)));
}
var resp = client.bulk(builder.build());
if (resp.errors()) {
resp.items().stream().filter(i -> i.error() != null)
.forEach(i -> System.err.printf("Hata [%s]: %s%n",
i.id(), i.error().reason()));
}
}
}5. DTO Sınıfları
5.1 Search Request
package com.example.search.dto;
import java.util.List;
public class ProductSearchRequest {
private String query;
private String category;
private String brand;
private Double minPrice, maxPrice, minRating;
private Boolean inStock;
private List<String> colors;
private String sortBy = "relevance"; // relevance, price_asc, price_desc, rating, newest
private int page = 0;
private int size = 20;
private List<String> searchAfter;
private boolean includeFacets = true;
// Tüm field'lar için getter/setter — IDE ile oluşturun
// Önemli: setSize() içinde Math.min(s, 100) ile max 100 sınırlaması
public String getQuery() { return query; }
public void setQuery(String q) { this.query = q; }
public String getCategory() { return category; }
public void setCategory(String c) { this.category = c; }
public String getBrand() { return brand; }
public void setBrand(String b) { this.brand = b; }
public Double getMinPrice() { return minPrice; }
public void setMinPrice(Double p) { this.minPrice = p; }
public Double getMaxPrice() { return maxPrice; }
public void setMaxPrice(Double p) { this.maxPrice = p; }
public Double getMinRating() { return minRating; }
public void setMinRating(Double r) { this.minRating = r; }
public Boolean getInStock() { return inStock; }
public void setInStock(Boolean s) { this.inStock = s; }
public List<String> getColors() { return colors; }
public void setColors(List<String> c) { this.colors = c; }
public String getSortBy() { return sortBy; }
public void setSortBy(String s) { this.sortBy = s; }
public int getPage() { return page; }
public void setPage(int p) { this.page = p; }
public int getSize() { return size; }
public void setSize(int s) { this.size = Math.min(s, 100); }
// searchAfter, includeFacets için de getter/setter ekleyin
public List<String> getSearchAfter() { return searchAfter; }
public void setSearchAfter(List<String> sa) { this.searchAfter = sa; }
public boolean isIncludeFacets() { return includeFacets; }
public void setIncludeFacets(boolean f) { this.includeFacets = f; }
}5.2 Search Response
package com.example.search.dto;
import com.example.search.model.Product;
import java.util.List;
import java.util.Map;
public class ProductSearchResponse {
private long totalHits;
private List<ProductHit> products;
private Map<String, List<FacetBucket>> facets;
private PriceStats priceStats;
private List<String> searchAfter; // Sonraki sayfa için
// Inner classes
public static class ProductHit {
private Product product; private double score;
private Map<String, List<String>> highlights; private List<String> sortValues;
public ProductHit(Product p, double s, Map<String, List<String>> h, List<String> sv) {
this.product = p; this.score = s; this.highlights = h; this.sortValues = sv;
}
public Product getProduct() { return product; }
public double getScore() { return score; }
public Map<String, List<String>> getHighlights() { return highlights; }
public List<String> getSortValues() { return sortValues; }
}
public static class FacetBucket {
private String key; private long count;
public FacetBucket(String k, long c) { this.key = k; this.count = c; }
public String getKey() { return key; }
public long getCount() { return count; }
}
public static class PriceStats {
private double min, max, avg;
public PriceStats(double min, double max, double avg) {
this.min = min; this.max = max; this.avg = avg;
}
public double getMin() { return min; }
public double getMax() { return max; }
public double getAvg() { return avg; }
}
// Getter/Setter — tüm field'lar için (IDE ile oluşturun)
public long getTotalHits() { return totalHits; }
public void setTotalHits(long t) { this.totalHits = t; }
public List<ProductHit> getProducts() { return products; }
public void setProducts(List<ProductHit> p) { this.products = p; }
public Map<String, List<FacetBucket>> getFacets() { return facets; }
public void setFacets(Map<String, List<FacetBucket>> f) { this.facets = f; }
public PriceStats getPriceStats() { return priceStats; }
public void setPriceStats(PriceStats ps) { this.priceStats = ps; }
public List<String> getSearchAfter() { return searchAfter; }
public void setSearchAfter(List<String> sa) { this.searchAfter = sa; }
}6. Search Service — Projenin Kalbi
package com.example.search.service;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.FieldValue;
import co.elastic.clients.elasticsearch._types.SortOrder;
import co.elastic.clients.elasticsearch._types.aggregations.*;
import co.elastic.clients.elasticsearch._types.query_dsl.*;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import co.elastic.clients.elasticsearch.core.search.CompletionSuggestOption;
import com.example.search.dto.*;
import com.example.search.dto.ProductSearchResponse.*;
import com.example.search.model.Product;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class ProductSearchService {
private static final String INDEX = "products_v1";
private final ElasticsearchClient client;
public ProductSearchService(ElasticsearchClient client) {
this.client = client;
}
// ============= ANA ARAMA =============
public ProductSearchResponse search(ProductSearchRequest req) throws IOException {
SearchResponse<Product> response = client.search(s -> {
var b = s.index(INDEX).size(req.getSize());
b.query(buildQuery(req));
addSorting(b, req);
// Pagination
if (req.getSearchAfter() != null && !req.getSearchAfter().isEmpty()) {
b.searchAfter(req.getSearchAfter().stream()
.map(FieldValue::of).collect(Collectors.toList()));
} else {
b.from(req.getPage() * req.getSize());
}
// Highlighting
b.highlight(h -> h
.fields("title", hf -> hf.numberOfFragments(0))
.fields("description", hf -> hf.fragmentSize(150).numberOfFragments(2))
.preTags("<mark>").postTags("</mark>"));
// Facets
if (req.isIncludeFacets()) addFacets(b);
b.source(src -> src.filter(f -> f.excludes("suggest")));
return b;
}, Product.class);
return buildResponse(response, req);
}
// ============= QUERY BUILDER =============
private Query buildQuery(ProductSearchRequest req) {
var bool = new BoolQuery.Builder();
// Text search — multi-field with boosting
if (req.getQuery() != null && !req.getQuery().isBlank()) {
bool.must(m -> m.multiMatch(mm -> mm
.query(req.getQuery())
.fields("title^3", "title.folded^1.5", "description^2",
"description.folded", "brand^2", "tags^1.5")
.type(TextQueryType.BestFields)
.fuzziness("AUTO")
.minimumShouldMatch("75%")
));
} else {
bool.must(m -> m.matchAll(ma -> ma));
}
// Filters (filter context — cached, no scoring)
if (req.getCategory() != null)
bool.filter(f -> f.term(t -> t.field("category").value(req.getCategory())));
if (req.getBrand() != null)
bool.filter(f -> f.term(t -> t.field("brand.keyword").value(req.getBrand())));
if (req.getMinPrice() != null || req.getMaxPrice() != null) {
bool.filter(f -> f.range(r -> r.number(n -> {
var nb = n.field("price");
if (req.getMinPrice() != null) nb.gte(req.getMinPrice());
if (req.getMaxPrice() != null) nb.lte(req.getMaxPrice());
return nb;
})));
}
if (req.getMinRating() != null)
bool.filter(f -> f.range(r -> r.number(n ->
n.field("rating").gte(req.getMinRating()))));
if (req.getInStock() != null && req.getInStock())
bool.filter(f -> f.term(t -> t.field("in_stock").value(true)));
if (req.getColors() != null && !req.getColors().isEmpty())
bool.filter(f -> f.terms(t -> t.field("colors")
.terms(tv -> tv.value(req.getColors().stream()
.map(FieldValue::of).collect(Collectors.toList())))));
return Query.of(q -> q.bool(bool.build()));
}
// ============= SORTING =============
private void addSorting(
co.elastic.clients.elasticsearch.core.SearchRequest.Builder b,
ProductSearchRequest req
) {
switch (req.getSortBy() != null ? req.getSortBy() : "relevance") {
case "price_asc" ->
b.sort(so -> so.field(f -> f.field("price").order(SortOrder.Asc)));
case "price_desc" ->
b.sort(so -> so.field(f -> f.field("price").order(SortOrder.Desc)));
case "rating" -> {
b.sort(so -> so.field(f -> f.field("rating").order(SortOrder.Desc)));
b.sort(so -> so.field(f -> f.field("review_count").order(SortOrder.Desc)));
}
case "newest" ->
b.sort(so -> so.field(f -> f.field("created_at").order(SortOrder.Desc)));
default ->
b.sort(so -> so.score(sc -> sc.order(SortOrder.Desc)));
}
// Tiebreaker — search_after için gerekli
b.sort(so -> so.field(f -> f.field("_id").order(SortOrder.Asc)));
}
// ============= FACETS =============
private void addFacets(
co.elastic.clients.elasticsearch.core.SearchRequest.Builder b
) {
b.aggregations("categories", a -> a.terms(t -> t.field("category").size(20)));
b.aggregations("brands", a -> a.terms(t -> t.field("brand.keyword").size(30)));
b.aggregations("colors", a -> a.terms(t -> t.field("colors").size(15)));
b.aggregations("price_ranges", a -> a.range(r -> r.field("price")
.ranges(rr -> rr.key("0-1000").to("1000"))
.ranges(rr -> rr.key("1000-5000").from("1000").to("5000"))
.ranges(rr -> rr.key("5000-10000").from("5000").to("10000"))
.ranges(rr -> rr.key("10000-25000").from("10000").to("25000"))
.ranges(rr -> rr.key("25000+").from("25000"))
));
b.aggregations("rating_ranges", a -> a.range(r -> r.field("rating")
.ranges(rr -> rr.key("4+").from("4"))
.ranges(rr -> rr.key("3-4").from("3").to("4"))
.ranges(rr -> rr.key("3 altı").to("3"))
));
b.aggregations("price_stats", a -> a.stats(st -> st.field("price")));
}
// ============= RESPONSE BUILDER =============
private ProductSearchResponse buildResponse(
SearchResponse<Product> response, ProductSearchRequest req
) {
var result = new ProductSearchResponse();
result.setTotalHits(response.hits().total().value());
// Products + highlights
List<ProductHit> products = new ArrayList<>();
for (Hit<Product> hit : response.hits().hits()) {
Map<String, List<String>> hl = hit.highlight() != null
? hit.highlight() : Collections.emptyMap();
List<String> sortVals = hit.sort().stream()
.map(FieldValue::_toJsonString).collect(Collectors.toList());
products.add(new ProductHit(hit.source(),
hit.score() != null ? hit.score() : 0, hl, sortVals));
}
result.setProducts(products);
// search_after for next page
if (!products.isEmpty())
result.setSearchAfter(products.get(products.size() - 1).getSortValues());
// Parse facets
if (req.isIncludeFacets() && response.aggregations() != null) {
Map<String, List<FacetBucket>> facets = new HashMap<>();
facets.put("categories", parseTerms(response, "categories"));
facets.put("brands", parseTerms(response, "brands"));
facets.put("colors", parseTerms(response, "colors"));
facets.put("price_ranges", parseRange(response, "price_ranges"));
facets.put("rating_ranges", parseRange(response, "rating_ranges"));
result.setFacets(facets);
StatsAggregate stats = response.aggregations().get("price_stats").stats();
result.setPriceStats(new PriceStats(stats.min(), stats.max(), stats.avg()));
}
return result;
}
private List<FacetBucket> parseTerms(SearchResponse<Product> r, String name) {
return r.aggregations().get(name).sterms().buckets().array().stream()
.map(b -> new FacetBucket(b.key().stringValue(), b.docCount()))
.collect(Collectors.toList());
}
private List<FacetBucket> parseRange(SearchResponse<Product> r, String name) {
return r.aggregations().get(name).range().buckets().array().stream()
.map(b -> new FacetBucket(b.key(), b.docCount()))
.collect(Collectors.toList());
}
// ============= AUTOCOMPLETE =============
public List<String> autocomplete(String prefix) throws IOException {
var response = client.search(s -> s
.index(INDEX).size(0)
.suggest(sg -> sg.suggesters("product_suggest", su -> su
.prefix(prefix)
.completion(c -> c.field("suggest").size(8)
.fuzzy(f -> f.fuzziness("1")))
)),
Product.class
);
var suggestions = response.suggest().get("product_suggest");
if (suggestions == null || suggestions.isEmpty()) return Collections.emptyList();
return suggestions.get(0).completion().options().stream()
.map(CompletionSuggestOption::text)
.collect(Collectors.toList());
}
}7. REST Controller
package com.example.search.controller;
import com.example.search.dto.ProductSearchRequest;
import com.example.search.dto.ProductSearchResponse;
import com.example.search.service.ProductSearchService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/products")
public class ProductSearchController {
private final ProductSearchService searchService;
public ProductSearchController(ProductSearchService searchService) {
this.searchService = searchService;
}
@GetMapping("/search")
public ResponseEntity<ProductSearchResponse> search(
@RequestParam(required = false) String query,
@RequestParam(required = false) String category,
@RequestParam(required = false) String brand,
@RequestParam(required = false) Double minPrice,
@RequestParam(required = false) Double maxPrice,
@RequestParam(required = false) Double minRating,
@RequestParam(required = false) Boolean inStock,
@RequestParam(required = false) List<String> colors,
@RequestParam(defaultValue = "relevance") String sortBy,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "true") boolean includeFacets
) throws IOException {
var request = new ProductSearchRequest();
request.setQuery(query);
request.setCategory(category);
request.setBrand(brand);
request.setMinPrice(minPrice);
request.setMaxPrice(maxPrice);
request.setMinRating(minRating);
request.setInStock(inStock);
request.setColors(colors);
request.setSortBy(sortBy);
request.setPage(page);
request.setSize(size);
request.setIncludeFacets(includeFacets);
return ResponseEntity.ok(searchService.search(request));
}
@GetMapping("/autocomplete")
public ResponseEntity<List<String>> autocomplete(
@RequestParam String prefix
) throws IOException {
return ResponseEntity.ok(searchService.autocomplete(prefix));
}
}8. Kullanım Örnekleri
# Basit arama
curl "localhost:8080/api/products/search?query=samsung+telefon"
# Filtrelemeli + sıralamalı
curl "localhost:8080/api/products/search?query=telefon&category=Telefon&minPrice=10000&sortBy=price_asc"
# Autocomplete
curl "localhost:8080/api/products/autocomplete?prefix=sam"
# Facet'siz sayfalama (daha hızlı)
curl "localhost:8080/api/products/search?query=telefon&page=2&size=10&includeFacets=false"9. Production Notları
| Konu | Tavsiye |
|---|---|
| Index aliasing | products alias → zero-downtime reindex |
| Error handling | @ExceptionHandler, retry geçici hatalar için |
| Caching | Facet sonuçlarını Redis'te 1-5dk cache |
| Validation | size max 100, query min 2 karakter |
| Scaling | App 2-3 replika, ES 3+ data node, 10-50GB/shard |
Özet
Proje yapısı: Config → Model → DTO → Service → Controller katmanları — Spring Boot best practice
Multi-field search:
title^3,title.folded^1.5,description^2ile ağırlıklı arama + fuzzinessFaceted filtering: categories, brands, colors, price_ranges, rating_ranges — tek sorguda
Autocomplete: Completion suggester + fuzzy toleransı
Sorting: relevance, price_asc/desc, rating, newest +
_idtiebreakerPagination:
from/size(basit) veyasearch_after(ölçeklenebilir)Highlighting:
<mark>etiketleri ile eşleşen terimleri vurgulamaTürkçe destek: Turkish lowercase, apostrophe, stemmer, diacritics folding — mapping'de
Production-ready: Tüm kodu kopyalayıp projenize uyarlayabilirsiniz
AI Asistan
Sorularını yanıtlamaya hazır