A search engine in 80 lines of Python
alexmolas.com
alexmolas.com
https://github.com/softwaredoug/searcharray
Why Pandas? Because BM25 is one thing, but you also want to combine with other factors (recency, popularity, etc) easily computed in pandas / numpy...
BTW phrases are the hard thing. There are a lot of edge case in phrase matching. Not to mention slop, etc. And you want to compact positions into as little memory as possible
https://github.com/softwaredoug/searcharray/blob/main/search...
I'll give a look to your project and try it for experimenting, thanks for sharing.
It's one I point people at all the time when they ask me why something isn't working as expected in any standard search tool, and something I reference from time to time to refresh my own knowledge.
Well worth the money.
I think I tested it thoroughly but any feedback would be appreciated!
Edit: I delta-encoded and base36-encoded the positions
I have my own feed setup with sites I like to frequent [1].
[0] https://github.com/abetusk/www.mechaelephant.com/tree/releas...
how did you filter which posts appear in the feed?
EDIT: I was able to make it work, I replaced the node module xmltojson with xq (python lib available from pip)
Also, a lot of subject matter experts hang out here so it just happens a lot :)
So with that in mind, approaching the problem not as one of how to make the search engine bigger, but the data smaller (physically or better signal/noise ratio) goes a very long way.
class SearchEngine:
def __init__(self, k1: float = 1.5, b: float = 0.75):
self._index: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
self._documents: dict[str, str] = {}
self.k1 = k1
self.b = b
I've no idea what `k1` or `b` are. Nor is there a single comment in the entire file. Are comments considered unfashionable these days?Looking at `_documents`, I'm guessing the keys are URLs and the values contents of those URLs, but i might be wrong.
The whole thing looks like it would've been a useful resource for people to learn how to build search engines with, that people could build on, had the writer been bothered to document it. But as it is I'm disappointed with the poor code.
If I wanted a catchy title for the post I needed to cut the number of LOC as much as possible;)
Joking apart, thanks for your feedback. I agree that usually it's better to have documentation and code together, but in this case since it's an educational project I decided to split code and documentation, and document the code in a blog post.
A comment would be useful but they are also instantly recognisable to anyone familiar with the problem.
I always love reading those when not familiar. It's almost as funny as reading something one already knows, waiting for the punch line...
Names can convey proper semantic information about what your program does, use them godammit
There are 2 schools of thought on which one is clearer,
F = G * m1 * m2 / r**2
or force = gravitational_constant * mass_of_body_1 * mass_of_body_2 / distance_between_bodies ** 2> F = G * m1 * m2 / r**2
Computer scientist:
nonrelativistic_gravitational_force = (
Physics.NonRelativistic.Gravity.gravitational_constant
* body1.NonRelativistic.mass()
* body2.NonRelativistic.mass()
/ body1.NonRelativistic.distanceTo(body2) ** 2
)*# Get force of gravity as below to be used later
F = G * m1 * m2 / r*2
Software engineer 5 years after uni:
gravitational_force = (
PhysicsContextConstructorFactory.createByRelativisticEnum(SystemConfig.getRelativisticEnum()).construct().getGravitationalConstant().value()
* body1.getRelativisticContext(SystemConfig.getRelativisticEnum()).mass().value()
* body2.getRelativisticContext(SystemConfig.getRelativisticEnum()).mass().value()
/ SystemConfig.getRelativisticContext(SystemConfig.getRelativisticEnum()).distanceTo(body1, body2).value() \* PlatformUnsignedInt(PlatformMinMaxAwareUnsignedInt(2).value()).getUnsignedInt().value()
)*
Software engineer after 15 years:# Newton's law of universal gravitation is well within wanted margin of error
F = G * m1 * m2 / r*2
However, in this particular case the undescriptive names are like this for historical reasons. I agree these are not the best names, but are the names used in the literature. If I was working in a physics I would probably use "c" as the speed of light or "kb" as the Boltzmann constant, which are non very descriptive names.
But even then, those libraries aren't related to search engines. If we start counting generic dependencies like pandas and fastapi, we might as well start counting the millions of loc of the operating system needed to run this search engine, and the firmware in the network card. Maybe even account for the complexity of the hardware this is running on.
You can see how your 1-liner pitch doesn't fulfill this expectation.
Here is a recommendation engine in < 20 lines of python to go along your search engine (if you keep session logs of clicked urls).
def build_recommendations(logs: List[List[str]], window_size: int = 10,
max_recommendations_per_url: int = 50) -> dict:
recommendations = {}
for session in logs:
for i in range(0, len(session)-1):
url_id = session[i] # reference element
window = session[i+1 : i+window_size] # sliding window
recommendations[url_id] = recommendations.get(url_id, {})
for pos, next_link in enumerate(window):
weight = window_size - pos # elements in the window get decreasing weight proportional to their distance from the reference element
recommendations[url_id][next_link] = recommendations[url_id].get(next_link, 0)
recommendations[url_id][next_link] += weight
for url_id, link_recommendations in recommendations.items():
# sort and truncate the recommendations
recommendations[url_id] = dict(sorted(link_recommendations.items(), key=lambda item: item[1], reverse=True)[:max_recommendations_per_url])
return recommendations
recommendations = build_recommendations(your_logs)
print(list(recommendations[some_url_id].keys())) # gives an ordered list of the recommended url_ids for the given url_id
With some tweaking (mix typed queries and clicked urls in the logs you feed) you can get a spellcheck suggestion out of it as well :)I needed something like this once, but at a little bit larger scale (few tens of thousands of documents). The answer, as always, was [sqlite](https://www.sqlite.org/fts5.html); structurally though it's just what you have here but with someone else writing the inverted-index persistence layer.
At least only some of the time, unfortunately. What power users want is "grep for the Web", not "Google, tell me what you want me to see."
I can almost guarantee that nobody actually wants this. "Grep for the web" is strictly bad compared to a search engine that does the tiniest amount of query expansion. Google is definitely taking too many liberties in interpreting the query, but there are many things any search engine should do that will be a straight improvement over not doing them.
The problem with Google search right now is that it's hard to reason about why it gives the results it does, seemingly because they rely too heavily on embeddings to compare strings. It's frustrating when "cat food" matches "dog restaurant" because the two were semantically close in some embedding space that doesn't quite align with human reasoning.
I believe there's a saying about a woodchuck somewhere in here
(sometimes you see those articles like 'built your own search engine' and it's a guide on how to install searxng or yacy or something.)
If PED(x, y) <= delta, then |N(x) ∩ N(y)| >= |N(x)| - n ∙ delta
That is, x and y must have at least |N(x)| - n ∙ delta n-grams in common to have a PED(x, y) less then or equal to delta.
If you now have an input x, you calculate N(x) and retrieve all postings from the n-gram index for each n-gram of x. You can now merge all of these postings and get a list that looks like this: [worda, worda, worda, wordb, wordb, wordc] (for each q-gram x and some word y have in common, you get one entry of y). If you merge the duplicates, you get: [(worda, 3), (wordb, 2), (wordc, 1)], and for each y (in this case, worda, wordb, wordc), the number in the corresponding tuple is |N(x) ∩ N(y)|.
If this number is larger than |N(x)| - n ∙ delta, you explicitly compute PED(x, y) and check whether it is below your threshold. If the number is smaller, you can simply skip it, saving you large amounts of costly PED calculations.
Your result is a list of words y with a PED(x, y) to the input x below some threshold, and you can then use this list of words to query your existing index.
I used this approach many years ago to implement a fuzzy client-side JS search engine on https://dont.watch/ (if you look into the JS code, you can see that the inverted index and the (compressed) n-gram index are simply transferred in the JS-file). The actual search engine is around 300 lines of JS, with no external dependencies and some very basic heuristics to improve the search results).
- A built-in dictionary type, used for indexing words
- Clean and easy to read code, which is one of Python's core strengths
- It's fast to draft code in, perfect for toy programs
- Easy async support, which the author comments on
- Plenty of libraries to do the heavy lifting of tasks not focused on by the post, such as hosting a web server, rendering template HTML, and parsing CLI arguments
Yes, Python is not fast relative to C or Rust, but it's perfect for this type of project.
> This implementation doesn’t pretend to be a production-ready search engine, just a usable toy example showing how a search engine works under the hood.
The whole point is expressiveness.
And once you do, odds are you'd find most of the runtime spent in very small portions of code doing fairly basic composable operations on large compressed arrays, and you can get very far with just rewriting a tiny core in something faster.
The number of people who need a scale where this is hard to do fast enough is small...
You can see my comment about SearchArray above, but you can do a lot of native performance comparable things if you embrace array based programming
It was a fun little project but definitely way more than 80 LOC :)
But, let's talk scale and features. As it stands, handling bigger data sets or adding more complex search features might be tough. On the features side, playing around with query operators or n-gram indexing could seriously level up your search results.
Expanding beyond RSS for content could also give your engine a nice touch. Just throwing in my thoughts – been around the block with search tech a bit. Can't wait to see what's next for your project!
- some RSS feeds are protected by cloudflare. It is true however that it is not necessary for majority of blogs. If you would like to do more then selenium would be a way to solve "cloudflare" protected links
- sometimes even selenium headless is not enough and full blown browser in selenium is necessary to fool it's protection
- sometimes even that is not enough
- then I started to wonder, why some RSS feeds are so well protected by cloudflare, but who am I to judge?
- sometimes it is beneficial to cover user agent. I feel bad for setting my user agent to chrome, but again, why RSS feeds are so well protected?
- you cannot parse, read entire Internet, therefore you always need to think about compromises. For example I have narrowed area of my searches in one of my projects to domains only. Now I can find most of the common domains, and I sort them by their "importance"
- RSS links do change. There need to be automated means to disable some feeds automatically to prevent checking inactive domains
- I do not see any configurable timeout for reading a page, but I am not familiar with aiohttp. Some pages might waste your time
- I hate that some RSS feeds are not configured properly. Some sites do not provide a valid meta "link" with "application/rss+xml". Some RSS feeds have naive titles like "Home", or no title at all. Such a waste of opportunity
My RSS feed parser, link archiver, web crawler: https://github.com/rumca-js/Django-link-archive. Especially interesting could be file rsshistory/webtools.py. It is not advanced programming craft, but it got the job done.
Additionally, in other project I have collected around 2378 of personal sites. I collect domains in https://github.com/rumca-js/Internet-Places-Database/tree/ma... . These files are JSONs. All personal sites have tag "personal".
Most of the things are collected from:
I wanted also to process domains from https://downloads.marginalia.nu/, but haven't got time to read structure of the files
Feel free to shoot me an email if you have any questions about how to use the files. They're very much there to fan the flames of other indie search projects :D