The Flask Mega-Tutorial, Part X: Full Text Search (2012)

Posted by
on under

(Great news! There is a new version of this tutorial!)

This is the tenth article in the series in which I document my experience writing web applications in Python using the Flask microframework.

The goal of the tutorial series is to develop a decently featured microblogging application that demonstrating total lack of originality I have decided to call microblog.

NOTE: This article was revised in September 2014 to be in sync with current versions of Python and Flask.

Here is an index of all the articles in the series that have been published to date:

Recap

In the previous article in the series we've enhanced our database queries so that we can get results on pages.

Today, we are going to continue working on our database, but in a different area. All applications that store content must provide a search capability.

For many types of web sites it is possible to just let Google, Bing, etc. index all the content and provide the search results. This works well for sites that have mostly static pages, like a forum. In our little microblog application the basic unit of content is just a short user post, not a whole page. The type of search results that we want are dynamic. For example, if we search for the word "dog" we want to see blog posts from any users that include that word. It is obvious that until someone searches for that word there is no page that the big search engines could have indexed with these results, so clearly we have no choice other than rolling our own search.

Introduction to full text search engines

Unfortunately support for full text search in relational databases is not well standardized. Each database implements full text search in its own way, and SQLAlchemy at this time does not have a full text search abstration.

We are currently using SQLite for our database, so we could just create a full text index using the facilities provided by SQLite, bypassing SQLAlchemy. But that isn't a good idea, because if one day we decide to switch to another database we would need to rewrite our full text search capability for another database.

So instead, we are going to let our database deal with the regular data, and we are going to create a specialized database that will be dedicated to text searches.

There are a few open source full text search engines. The only one that to my knowledge has a Flask extension is Whoosh, an engine also written in Python. The advantage of using a pure Python engine is that it will install and run anywhere a Python interpreter is available. The disadvantage is that search performance will not be up to par with other engines that are written in C or C++. In my opinion the ideal solution would be to have a Flask extension that can connect to several engines and abstract us from dealing with a particular one in the same way Flask-SQLAlchemy gives us the freedom to use several database engines, but nothing of that kind seems to be available for full text searching at this time. Django developers do have a very nice extension that supports several full text search engines called django-haystack. Maybe one day someone will create a similar extension for Flask.

But for now, we'll implement our text searching with Whoosh. The extension that we are going to use is Flask-WhooshAlchemy, which integrates a Whoosh database with Flask-SQLAlchemy models.

Python 3 Compatibility

Unfortunately, we have a problem with Python 3 and these packages. The Flask-WhooshAlchemy extension was never made compatible with Python 3. I have forked this extension and made a few changes to make it work, so if you are on Python 3 you will need to uninstall the official version and install my fork:

$ flask/bin/pip uninstall flask-whooshalchemy
$ flask/bin/pip install git+git://github.com/miguelgrinberg/flask-whooshalchemy.git

Sadly this isn't the only problem. Whoosh also has issues with Python 3, it seems. In my testing I have encontered this bug, and to my knowledge there isn't a solution available, which means that at this time the full text search capability does not work well on Python 3. I will update this section once the issues are resolved.

Configuration

Configuration for Flask-WhooshAlchemy is pretty simple. We just need to tell the extension what is the name of the full text search database (file config.py):

WHOOSH_BASE = os.path.join(basedir, 'search.db')

Model changes

Since Flask-WhooshAlchemy integrates with Flask-SQLAlchemy, we indicate what data is to be indexed for searching in the proper model class (file app/models.py):

from app import app

import sys
if sys.version_info >= (3, 0):
    enable_search = False
else:
    enable_search = True
    import flask_whooshalchemy as whooshalchemy

class Post(db.Model):
    __searchable__ = ['body']

    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.String(140))
    timestamp = db.Column(db.DateTime)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'))

    def __repr__(self):
        return '<Post %r>' % (self.body)

if enable_search:
    whooshalchemy.whoosh_index(app, Post)

The model has a new __searchable__ field, which is an array with all the database fields that will be in the searchable index. In our case we only want to index the body field of our posts.

We also have to initialize the full text index for this model by calling the whoosh_index function. Note that since we know that the search capability currently does not work on Python 3 we have to skip its initialization. Once the problems in Whoosh are fixed the logic around enable_search can be removed.

Since this isn't a change that affects the format of our relational database we do not need to record a new migration.

Unfortunately any posts that were in the database before the full text engine was added will not be indexed. To make sure the database and the full text engine are synchronized we are going to delete all posts from the database and start over. First we start the Python interpreter. For Windows users:

flask\Scripts\python

And for everyone else:

flask/bin/python

Then in the Python prompt we delete all the posts:

>>> from app.models import Post
>>> from app import db
>>> for post in Post.query.all():
...    db.session.delete(post)
>>> db.session.commit()

Searching

And now we are ready to start searching. First let's add a few new posts to the database. We have two options to do this. We can just start the application and enter posts via the web browser, as regular users would do, or we can also do it in the Python prompt.

From the Python prompt we can do it as follows:

>>> from app.models import User, Post
>>> from app import db
>>> import datetime
>>> u = User.query.get(1)
>>> p = Post(body='my first post', timestamp=datetime.datetime.utcnow(), author=u)
>>> db.session.add(p)
>>> p = Post(body='my second post', timestamp=datetime.datetime.utcnow(), author=u)
>>> db.session.add(p)
>>> p = Post(body='my third and last post', timestamp=datetime.datetime.utcnow(), author=u)
>>> db.session.add(p)
>>> db.session.commit()

The Flask-WhooshAlchemy extension is nice, because it hooks up into Flask-SQLAlchemy commits automatically. We do not need to maintain the full text index, it is all done for us transparently.

Now that we have a few posts in our full text index we can issue searches:

>>> Post.query.whoosh_search('post').all()
[<Post u'my second post'>, <Post u'my first post'>, <Post u'my third and last post'>]
>>> Post.query.whoosh_search('second').all()
[<Post u'my second post'>]
>>> Post.query.whoosh_search('second OR last').all()
[<Post u'my second post'>, <Post u'my third and last post'>]

As you can see in the examples above, the queries do not need to be limited to single words. In fact, Whoosh supports a pretty powerful search query language.

Integrating full text searches into the application

To make the searching capability available to our application's users we have to add just a few small changes.

Configuration

As far as configuration, we'll just indicate how many search results should be returned as a maximum (file config.py):

MAX_SEARCH_RESULTS = 50

Search form

We are going to add a search form to the navigation bar at the top of the page. Putting the search box at the top is nice, because then the search will be accessible from all pages.

First we add a search form class (file app/forms.py):

class SearchForm(Form):
    search = StringField('search', validators=[DataRequired()])

Then we need to create a search form object and make it available to all templates, since we will be putting the search form in the navigation bar that is common to all pages. The easiest way to achieve this is to create the form in the before_request handler, and then stick it in Flask's global g (file app/views.py):

from forms import SearchForm

@app.before_request
def before_request():
    g.user = current_user
    if g.user.is_authenticated:
        g.user.last_seen = datetime.utcnow()
        db.session.add(g.user)
        db.session.commit()
        g.search_form = SearchForm()

Then we add the form to our template (file app/templates/base.html):

<div>Microblog:
    <a href="{{ url_for('index') }}">Home</a>
    {% if g.user.is_authenticated %}
    | <a href="{{ url_for('user', nickname=g.user.nickname) }}">Your Profile</a>
    | <form style="display: inline;" action="{{ url_for('search') }}" method="post" name="search">{{ g.search_form.hidden_tag() }}{{ g.search_form.search(size=20) }}<input type="submit" value="Search"></form>
    | <a href="{{ url_for('logout') }}">Logout</a>
    {% endif %}
</div>

Note that we only display the form when we have a logged in user. Likewise, the before_request handler will only create a form when a user is logged in, since our application does not show any content to guests that are not authenticated.

Search view function

The action field of our form was set above to send all search requests the the search view function. This is where we will be issuing our full text queries (file app/views.py):

@app.route('/search', methods=['POST'])
@login_required
def search():
    if not g.search_form.validate_on_submit():
        return redirect(url_for('index'))
    return redirect(url_for('search_results', query=g.search_form.search.data))

This function doesn't really do much, it just collects the search query from the form and then redirects to another page passing this query as an argument. The reason the search work isn't done directly here is that if a user then hits the refresh button the browser will put up a warning indicating that form data will be resubmitted. This is avoided when the response to a POST request is a redirect, because after the redirect the browser's refresh button will reload the redirected page.

Search results page

Once a query string has been received the form POST handler sends it via page redirection to the search_results handler (file app/views.py):

from config import MAX_SEARCH_RESULTS

@app.route('/search_results/<query>')
@login_required
def search_results(query):
    results = Post.query.whoosh_search(query, MAX_SEARCH_RESULTS).all()
    return render_template('search_results.html',
                           query=query,
                           results=results)

The search results view function sends the query into Whoosh, passing a maximum number of search results, since we don't want to be presenting a potentially large number of hits, we are happy showing just the first fifty.

The final piece is the search results template (file app/templates/search_results.html):

<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
  <h1>Search results for "{{ query }}":</h1>
  {% for post in results %}
      {% include 'post.html' %}
  {% endfor %}
{% endblock %}

And here, once again, we can reuse our post.html sub-template, so we don't need to worry about rendering avatars or other formatting elements, since all of that is done in a generic way in the sub-template.

Final words

We now have completed yet another important, though often overlooked piece that any decent web application must have.

The source code for the updated microblog application is available below:

Download microblog-0.10.zip.

As always, the above download does not include a database or a flask virtual environment. See previous articles in the series to learn how to create these.

I hope you enjoyed this tutorial. If you have any questions feel free to write in the comments below. Thank you for reading, and I will be seeing you again in the next installment!

Miguel

Become a Patron!

Hello, and thank you for visiting my blog! If you enjoyed this article, please consider supporting my work on this blog on Patreon!

151 comments
  • #51 Javier said

    Very old post, hope I'm not resurrecting an old issue...

    I'm sure I'm using Flash-WhooshAlchemy 0.55a , but I stumble with the problem that the query from the search form is a dictionary. That is, g.search_form.data throws a dictionary {'search': 'my sample query...'}

    So at the views.search method I have to first read the 'search' key and send that as the query to the search_results method. That makes totally sense, but can't seem anything related about that on your text.

    Any ideas why this behaviour I'm having differs from what you wrote?

    BTW very great set of tutorials!

  • #52 Miguel Grinberg said

    @Javier: try g.search_form.search.data. When you do g.search_form.data you are getting the data for all the fields in a dictionary.

  • #53 UrduD said

    Hi Miguel, Thank you very much for taking time to put thois tutorial up. I would not be using FTS if it were not for this article.
    I see that whoosh alchemy seems to be a good but disappointing chioce for a beginner like me. Not much examples or documentation to be found.
    Please help - Can I use whoosh directly as I want to use analysis functionality of whhosh to do fuzzy search of unicode words. Each column has a single word. Two such columns have been adding to searchable. Thanks again.

  • #54 Miguel Grinberg said

    @UrduD: you can certainly use Whoosh directly if you prefer that, but then you cannot take advantage of the integration with SQLAlchemy models, as that is done by Flask-WhooshAlchemy. If you go with Whoosh alone you will need to maintain the indexes yourself.

  • #55 UrduD said

    Thanks Miguel. I liked Flask-WhooshAlchemy for reasons you mentioned. Is there a way to get to whoosh's QueryParser & searcher objects while using Flask-WhooshAlchemy? Excuse me for being a beginner yet looking for advanced features.

  • #56 Miguel Grinberg said

    @UrduD: have you read the Flask-WhooshAlchemy source code? The extension is pretty short, my recommendation is that you take the code and modify it to your needs.

  • #57 Aaron said

    There's an error in the code about using git to instal your version of whoosh when using Python 3. Right now you have "flask/bin/pip git+git://github.com/miguelgrinberg/flask-whooshalchemy.git" but it should be "flask/bin/pip install git+git://github.com/miguelgrinberg/flask-whooshalchemy.git".

    (The error is that it's missing the "install" command before the path to the git repo.)

  • #58 Miguel Grinberg said

    @Aaron: thanks, I updated the article.

  • #59 Martin said

    Hi, thanks for the awesome tutorials and book. It has been great help for learning Flask. I just wanted to let you know that using your version of flask-whooshalchemy and Whoosh 2.6.0 on python 3.4.1 works perfectly :).

  • #60 Sean said

    I can't for the life of me work out why this doesn't work, when I try to search for something, I am presented with this:

    AttributeError: 'BaseQuery' object has no attribute 'whoosh_search'

    I have gone through the tutorial word for word, and thus far been able to fix (with a lot of effort, pesky typos) a lot of the errors I have encountered. This one has completely stumped me, though.

  • #61 Miguel Grinberg said

    @Sean: my guess is that you did not call whooshalchemy.whoosh_index(app, Post) at the bottom of models.py. Compare your source file with mine on GitHub to make sure they look the same.

  • #62 Sean said

    Hmm. I already have that line. I'll come back to it with fresh eyes, but I have compared yours on GitHub, or as best I could given that I haven't completed the tutorial yet. So I didn't want to add in code I have yet to come across.

  • #63 Sean said

    Might I point out, that before adding it to the template, I got the same error via the interpreter:

    Post.query.whoosh_search('post').all()
    Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
    AttributeError: 'BaseQuery' object has no attribute 'whoosh_search'

    Any other possible ideas you might have would be helpful.

  • #64 Miguel Grinberg said

    @Sean: in the console, try:

    import flask.ext.whooshalchemy as whooshalchemy
    from app import app
    from app.models import Post
    whooshalchemy.whoosh_index(app, Post)
    Post.query.whoosh_search('post').all()

    Does it work this way?

  • #65 Dmitry said

    Is it possible to paginate search results?

  • #66 Miguel Grinberg said

    Dmitry: the flask-whooshalchemy extension does not do pagination at this time, it only takes a "limit" argument to specify how many results to return.

  • #67 Ramchandra Apte said

    This tutorial really helped with integrating Whoosh into my website. Thanks!

  • #68 Bueno said

    Miguel, you are doing an incredible job with these posts, thank you

    I had the same problem as @Sean in comment #60
    AttributeError: 'BaseQuery' object has no attribute 'whoosh_search'

    This occurs because the IF that check the python version in file models.py doesn't get in else condition when i use python version 2. This way, the 'whooshalchemy' is not imported and the line whooshalchemy.whoosh_index(app, Post) does not executed because the flag enable_search is False

    If you change the line
    if sys.version_info >= (3.0):
    for
    if sys.version_info.major >= 3:
    all the things works

  • #69 Vivian said

    Hi Miguel!

    I followed this tutorial to do something similar. I want create a search engine that takes in 3 fields and searches a table for a tuple that matches those 3 fields. In my case they're called state, city, and activity. I've gotten the forms, the models, views etc written out, but the issue I'm having is how to pass the form information state city and activity from the search method to the search_result method. Should I store state, city, and activity in a list called query, then in the search.html form, have my computer somehow store the list query in /search_results/<query>? I don't know if this is the best way to go about multiple attribute searching, so if you have any advice that'd be great.

    Thanks so much!
    Vivian

  • #70 Miguel Grinberg said

    @Vivian: you can code your search_result method with multiple arguments. For example, you can use a route like this: "/search_results/<state>/<city>/<activity>". Another option is to pass these three values as query string arguments, so then your route is "/search_results" and you get the three values from request.args. In this case your URL would look like this, for example: "/search_results?state=OR&city=Portland&activity=run".

  • #71 John said

    Is there an equivalent of Whoosh for NoSQL?

  • #72 Taylor said

    Hi Miguel,

    I'm trying to index two tables - a Client table and a Condo table. However, when I run whoosh_index() on each of these in turn, I only get indexed results for the table I indexed last. The relevant code is here in a bug report: https://github.com/gyllstromk/Flask-WhooshAlchemy/issues/29

    Any idea how to index multiple tables? Am I missing something here?

    Thanks!

  • #73 Miguel Grinberg said

    @Taylor: I haven't tried to index more than one table, but briefly looking at the code of the extension it appears to be fully in support of that. My recommendation is that you debug insertions, to make sure that any time you insert a record in your tables whoosh is invoked to do the same. If you run with a debugger, you can put a breakpoint in the _after_flush() function, which the extension sets as a callback with sqlalchemy. If you don't normally use a debugger, I recommend that you use pudb.

  • #74 John Lin said

    Hi Miguel,

    Thanks for the post and answering my other questions. I have recently changed my database to have post_text be post_text1 (and I also have a 2 and 3). Anyways, I am getting the following error:

    "% (name, schema))
    UnknownFieldError: No field named 'post_text1' in <Schema: ['id', 'post_text']>"

    It seems to be hanging on to my old name for column rather than using a new one. How do I resolve this?

  • #75 Miguel Grinberg said

    @John: did you create a database migration for your change?

Leave a Comment