Last week, I pointed to an article by William Denton, "How to make a Faceted Collection and put it on the Web", where he describes what facets are and how to build up a faceted collection of data. The example he provides uses a relational database to store the information. For this article, I took the dataset that he used and built up a small web application that provides faceted search results using Lucene as the datastore. I continue to hold the facet metadata in a relational database, however. While this implementation is a first cut, and does not address issues of performance or maintainability (more on this later), I believe that this implementation will resonate better with web developers, given the popularity of Lucene to build search applications.
Tools/Framework used
One application that specifically addresses faceted searching with Lucene is Apache-Solr, and I briefly considered using their classes to drive my application. However, the impression I got (and I could be wrong) was that Solr is very tightly integrated around the webservices architecture, leveraging it to provide facet metadata caching, etc. This would not work so well for me on my resource constrained laptop, so I decided to start from scratch, using Lucene's BooleanQuery and QueryFilters for my implementation.
I did, however, want to use Spring MVC and Dependency injection, so I used the Lucene module from the SpringModules project. I discovered that the current version (0.7) did not work with Lucene 2.0 (which I was using) due to some non-backward compatible changes made to Lucene between 1.4 and 2.0, so I fixed it locally and provided a patch so it can be integrated into future versions.
Screenshots
But first, some mandatory screenshots to grab your interest. As you can see, I am not much of a front-end web developer, but these should give you an idea of what the application does.
The Indexer
To build the index, I first copied (by hand) the dish soaps data from William Denton's article into a semicolon-separated file. The first few lines of the file are shown below:
1 2 3 4 | #name;agent;form;brand;scent;effect
Cascade Pure Rinse Formula;dishwasher;liquid;Cascade; ;antibacterial;
Elactrasol lemon gel;dishwasher;liquid;Electrasol;lemon; ;
...
|
I then created a table to hold the facet metadata. The Spring configuration for the indexer and its associated Dao (to populate the facet metadata) is shown below. The dataSource is a reference to a Spring DriverManagerDataSource connecting to my local PostgreSQL database.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | <!-- Lucene index datasource configuration -->
<bean id="fsDirectory" class="org.springmodules.lucene.index.support.FSDirectoryFactoryBean">
<property name="location" value="file:/tmp/soapindex" />
<property name="create" value="true" />
</bean>
<bean id="indexFactory" class="org.springmodules.lucene.index.support.SimpleIndexFactoryBean">
<property name="directory" ref="fsDirectory" />
<property name="analyzer">
<bean class="org.apache.lucene.analysis.SimpleAnalyzer" />
</property>
</bean>
<!-- IndexBuilder -->
<bean id="facetsDao" class="net.soapmarket.db.FacetsDao">
<property name="dataSource" ref="dataSource" />
</bean>
<bean id="soapIndexBuilder" class="net.soapmarket.index.SoapIndexBuilder">
<property name="indexFactory" ref="indexFactory" />
<property name="analyzer">
<bean class="org.apache.lucene.analysis.SimpleAnalyzer" />
</property>
<property name="facetsDao" ref="facetsDao" />
</bean>
|
and the code for the Indexer
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | public class SoapIndexBuilder extends LuceneIndexSupport {
private FacetsDao facetsDao;
private String[] fieldsMeta;
private Map<String,Set<String>> facets;
public void setFacetsDao(FacetsDao facetsDao) {
this.facetsDao = facetsDao;
}
public void buildIndex(String inputFileName) throws Exception {
facets = new HashMap<String,Set<String>>();
BufferedReader reader = new BufferedReader(new InputStreamReader(
new FileInputStream(inputFileName)));
String line = null;
while ((line = reader.readLine()) != null) {
if (line.startsWith("#")) {
fieldsMeta = (line.substring(1)).split(";");
continue;
}
addDocument(line);
}
reader.close();
facetsDao.saveFacetMap(facets);
}
public void addDocument(final String text) {
getTemplate().addDocument(new DocumentCreator() {
public Document createDocument() throws Exception {
Document doc = new Document();
String[] fields = text.split(";");
int fieldIndex = 0;
for (String fieldMetadata : fieldsMeta) {
if (fieldIndex == 0) {
doc.add(new Field(fieldMetadata, fields[fieldIndex], Field.Store.YES,
Field.Index.TOKENIZED));
} else {
Set<String> facetValues = facets.get(fieldMetadata);
if (facetValues == null) {
facetValues = new HashSet<String>();
}
if (fields[fieldIndex].indexOf(',') > -1) {
String[] multiValues = fields[fieldIndex].split("\\s*,\\s*");
for (String multiValue : multiValues) {
doc.add(new Field(fieldMetadata, multiValue, Field.Store.NO,
Field.Index.UN_TOKENIZED));
if (StringUtils.isNotBlank(multiValue)) {
facetValues.add(multiValue);
}
}
} else {
doc.add(new Field(fieldMetadata, fields[fieldIndex], Field.Store.NO,
Field.Index.UN_TOKENIZED));
if (StringUtils.isNotBlank(fields[fieldIndex])) {
facetValues.add(fields[fieldIndex]);
}
}
facets.put(fieldMetadata, facetValues);
}
fieldIndex++;
}
// finally add our hardcoded category (for testing)
doc.add(new Field("category", "dish-soap", Field.Store.NO, Field.Index.UN_TOKENIZED));
return doc;
}
});
}
}
|
Facet metadata
The Facet metadata is dumped by the IndexBuilder into a single table. This works fine for a tiny dataset such as ours, but when our dataset becomes larger, it may be good to normalize the data into two separate tables. Here is a partial listing of our facets data.
1 2 3 4 5 6 7 8 9 10 11 12 13 | postgresql=# select * from facets;
facet_name | facet_value
------------+---------------------
brand | Sunlight
brand | Generic
brand | Cascade
brand | President's Choice
brand | Electrasol
brand | Palmolive
brand | Ivory
agent | dishwasher
agent | hand
...
|
Here is the code for the FacetDao, which returns information from the facets table. Only the saveFacetMap() method is used by the Indexer, all the other methods are used by the Searcher.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | public class FacetsDao extends JdbcDaoSupport {
public void saveFacetMap(Map<String,Set<String>> facetMap) {
getJdbcTemplate().update("delete from facets where 1=1");
for (String facetName : facetMap.keySet()) {
Set<String> facetValues = facetMap.get(facetName);
for (String facetValue : facetValues) {
getJdbcTemplate().update("insert into facets(facet_name, facet_value) values (?, ?)",
new String[] {facetName, facetValue});
}
}
}
@SuppressWarnings("unchecked")
public List<String> getAllFacetNames() {
List<Map<String,String>> rows = getJdbcTemplate().queryForList(
"select facet_name from facets group by facet_name");
List<String> facetNames = new ArrayList<String>();
for (Map<String,String> row : rows) {
facetNames.add(row.get("FACET_NAME"));
}
return facetNames;
}
@SuppressWarnings("unchecked")
public List<String> getFacetValues(String facetName) {
List<Map<String,String>> rows = getJdbcTemplate().queryForList(
"select facet_value from facets where facet_name = ?",
new String[] {facetName});
List<String> facetValues = new ArrayList<String>();
for (Map<String,String> row : rows) {
facetValues.add(row.get("FACET_VALUE"));
}
return facetValues;
}
}
|
The Searcher
The Searcher is coupled with the controller via the request parameter map. Notice how the facets and their values (in the screenshots above) are really request parameter name-value pairs. The Searcher provides methods to convert the parameter values into corresponding Lucene queries. Notice also, that each page is built from a single Lucene query to show the current dataset, and a set of Lucene queries to build up the facet hit counts on the left navigation toolbar.
The Spring configuration for the Searcher is shown below. Notice that we reuse the FacetsDao and the fsDirectory has its create property commented out. The latter is because Spring will delete your index on startup if create=true is set. In the real world, the Indexer and Searcher applications are usually separate, so this is not an issue. But here we comment out the create property after we are done building our index.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | <!-- Lucene index datasource configuration -->
<bean id="fsDirectory" class="org.springmodules.lucene.index.support.FSDirectoryFactoryBean">
<property name="location" value="file:/tmp/soapindex" />
<!--<property name="create" value="true" />-->
</bean>
<bean id="searcherFactory" class="org.springmodules.lucene.search.factory.SimpleSearcherFactory">
<property name="directory" ref="fsDirectory" />
</bean>
<!-- IndexSearcher -->
<bean id="facetedSoapSearcher" class="net.soapmarket.search.FacetedSoapSearcher">
<property name="searcherFactory" ref="searcherFactory" />
<property name="analyzer">
<bean class="org.apache.lucene.analysis.SimpleAnalyzer" />
</property>
<property name="facetsDao" ref="facetsDao" />
</bean>
|
Here is the source code for the Searcher.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 | public class FacetedSoapSearcher extends LuceneSearchSupport {
private FacetsDao facetsDao;
public void setFacetsDao(FacetsDao facetsDao) {
this.facetsDao = facetsDao;
}
public Query getQueryFromParameterMap(Map<String,String[]> parameters) {
if (parameters == null || parameters.size() == 0) {
RangeQuery query = new RangeQuery(new Term("name", "a*"), new Term("name", "z*"), true);
return query;
} else {
BooleanQuery query = new BooleanQuery();
for (String parameter : parameters.keySet()) {
String[] parameterValues = parameters.get(parameter);
if (parameterValues.length > 0) {
if (StringUtils.isNotBlank(parameterValues[0])) {
TermQuery tQuery = new TermQuery(new Term(parameter, parameterValues[0]));
query.add(tQuery, Occur.MUST);
}
}
}
return query;
}
}
@SuppressWarnings("unchecked")
public List<String> search(Query query) {
List<String> results = getTemplate().search(query, new HitExtractor() {
public Object mapHit(int id, Document doc, float score) {
String name = doc.get("name");
return name;
}
});
return results;
}
@SuppressWarnings({ "unchecked", "deprecation" })
public List<Facet> getFacets(final Query baseQuery,
final Map<String,String[]> baseRequestParameters) {
List<Facet> facetCounts = new ArrayList<Facet>();
for (String facetName : facetsDao.getAllFacetNames()) {
Facet facet = new Facet();
facet.setName(facetName);
if (baseRequestParameters.get(facetName) != null) {
// facet already exists in the request, this will only have reset option
facet.setAllQueryString(buildFacetResetQueryString(facetName, baseRequestParameters));
facetCounts.add(facet);
} else {
List<String> facetValues = facetsDao.getFacetValues(facetName);
List hitCounts = new ArrayList<NameValueUrlTriple>();
for (String facetValue : facetValues) {
final QueryFilter filter = new QueryFilter(
new TermQuery(new Term(facetName, facetValue)));
Integer numHits = (Integer) getTemplate().search(new SearcherCallback() {
public Object doWithSearcher(Searcher searcher) throws IOException, ParseException {
try {
Hits hits = searcher.search(baseQuery, filter);
return hits.length();
} finally {
searcher.close();
}
}
});
if (numHits > 0) {
hitCounts.add(new NameValueUrlTriple(facetValue, String.valueOf(numHits),
buildQueryString(baseRequestParameters, facetName, facetValue)));
}
}
facet.setHitCounts(hitCounts);
if (hitCounts.size() > 0) {
facetCounts.add(facet);
}
}
}
return facetCounts;
}
/**
* Builds up the url for the facet reset (remove it from the query).
*/
@SuppressWarnings("deprecation")
private String buildFacetResetQueryString(String facetName,
Map<String,String[]> baseRequestParameters) {
StringBuilder facetResetQueryStringBuilder = new StringBuilder();
int i = 0;
for (String parameterName : baseRequestParameters.keySet()) {
String parameterValue = baseRequestParameters.get(parameterName)[0];
if (parameterName.equals(facetName)) {
continue;
}
if (i > 0) {
facetResetQueryStringBuilder.append("&");
}
facetResetQueryStringBuilder.append(parameterName).
append("=").
append(URLEncoder.encode(parameterValue));
i++;
}
return facetResetQueryStringBuilder.toString();
}
/**
* Builds up the query string for the faceted search for this facet.
*/
@SuppressWarnings("deprecation")
private String buildQueryString(Map<String,String[]> baseRequestParameters,
String facetName, String facetValue) {
StringBuilder queryStringBuilder = new StringBuilder();
int i = 0;
for (String parameterName : baseRequestParameters.keySet()) {
String[] parameterValues = baseRequestParameters.get(parameterName);
if (i > 0) {
queryStringBuilder.append("&");
}
queryStringBuilder.append(parameterName).
append("=").
append(URLEncoder.encode(parameterValues[0]));
i++;
}
queryStringBuilder.append("&").
append(facetName).append("=").append(URLEncoder.encode(facetValue));
return queryStringBuilder.toString();
}
}
|
And the (partial) source code for the Facet bean, all the member variables have associated getter and setter methods. The Facet bean is a convenient abstraction that simplifies our Searcher code as well as our JSP code (shown below).
1 2 3 4 5 6 7 8 | public class Facet {
private String name;
private List<NameValueUrlTriple> hitCounts;
private String allQueryString;
// getters and setters (omitted for brevity)
}
|
The Controller and JSP
The Controller is really simple. It is built by Spring with a reference to the Searcher. The controller gets the incoming request and delegates most of the work to the Searcher. The Searcher builds the Lucene Query object from the parameters and passes it back to the Controller, which uses the Lucene Query to issue a search() and getFacets() call back to the Searcher, puts the results in the ModelAndView, and forwards to the search JSP. The Spring configuration is shown below:
1 2 3 4 | <!-- Controller -->
<bean id="facetedSearchController" class="net.soapmarket.controller.FacetedSearchController">
<property name="facetedSoapSearcher" ref="facetedSoapSearcher" />
</bean>
|
And here is the code for the Controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | public class FacetedSearchController implements Controller {
private FacetedSoapSearcher facetedSoapSearcher;
public void setFacetedSoapSearcher(FacetedSoapSearcher facetedSoapSearcher) {
this.facetedSoapSearcher = facetedSoapSearcher;
}
@SuppressWarnings("unchecked")
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response)
throws Exception {
ModelAndView mav = new ModelAndView();
Map<String,String[]> parameters = request.getParameterMap();
Query query = facetedSoapSearcher.getQueryFromParameterMap(parameters);
mav.addObject("category", parameters.get("category")[0]);
mav.addObject("results", facetedSoapSearcher.search(query));
mav.addObject("facets", facetedSoapSearcher.getFacets(query, parameters));
mav.addObject("categoryName", "Dishwashing Soaps"); // hardcoded for now
mav.setViewName("search");
return mav;
}
}
|
And the code for the JSP is here:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | <%@ page language="java" import="java.util.*" pageEncoding="UTF-8"%>
<%@ page session="false" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<body>
<h2>${categoryName}</h2>
<table cellspacing="0" cellpadding="0" border="1" width="100%">
<tr valign="top">
<td><font size="-1">
<p><b><a href="/soapmarket/search.do?category=${category}">Reset search</a></b></p>
<c:forEach var="facet" items="${facets}">
<c:choose>
<c:when test="${not empty facet.allQueryString}">
<p><b><a href="/soapmarket/search.do?${facet.allQueryString}">See all ${facet.name}</a></b></p>
</c:when>
<c:otherwise>
<b>Search by ${facet.name}</b><br>
<ul>
<c:forEach var="hitCount" items="${facet.hitCounts}">
<li><a href="/soapmarket/search.do?${hitCount.queryString}">${hitCount.name} : (${hitCount.value})</a></li>
</c:forEach>
</ul><br>
</c:otherwise>
</c:choose>
</c:forEach>
</font></td>
<td>
<ol>
<c:forEach var="result" items="${results}">
<li>${result}</li>
</c:forEach>
</ol>
</td>
</tr>
</table>
</body>
</html>
|
Scope for improvement
Two issues not addressed in this implementation are performance and maintainability. For this prototype, I am using a dataset of about 27 records which have about 6 facets. Performance can be improved on the relational database end by normalizing the facet information. From what I heard from search engineers at my previous job, and because Lucene depends on an inverted index, Lucene scales very well to large datasets, so that is probably not an issue. The other aspect is maintainability. We are using a new field for each facet, which would grow messy as more facets are added (even in a controlled vocabulary environment). It may be better to store all the facets in a single field. This will require modifications to both the indexer and searcher.
















